☀️

Rustで使う!Solanaクライアントフレームワーク

2024/12/15に公開

1. はじめに

こんにちは。magitoと申します。

https://twitter.com/regolith1223

筆者は普段、ブロックチェーンドメインにてbot[1]を開発・運用しています。

今年は主にSolana DeFiを戦場としていました。
これまでの活動概要については以下の記事にまとめていますので、もし興味があればご一読ください。
https://note.com/magimagi1223/n/ncc28d3f049f6
https://note.com/magimagi1223/n/nc8dbdfad7b29

大量のDeFi取引を高速実行するbotプログラムには、その性質上、(開発時の生産性や幸福感のみならず)非常に高い実行時性能と安全性が求められます。そのため、オンチェーンプログラム[2]、オフチェーンクライアントともに全てのbotプログラムをRust言語で実装しています。

本稿では、筆者がSolanaブロックチェーンにて実際に開発・運用しているbotコードベースの中から、メインネット運用やテスト環境などの様々なシーンでブロックチェーンシステムと相互作用するために採用しているクライアントフレームワークを抜粋し、SolanaにおけるRustクライアントの設計事例としてその技術的な概要をご紹介したいと思います。

ソースコードは以下のリポジトリに置いてあります。

https://github.com/mag1to/dexter-solana-demo

Solanaにおけるアプリケーションサイドの開発でRustクライアントをがっつり作り込むケースはレアかもしれませんが、筆者と同じような(やや特殊な)事情をもつSolana開発者の方々の参考になれば幸いです。

2. クライアントとは?

本稿では「クライアント」という言葉を「ネットワークを介してリモートの接続先から何らかのサービスを享受するもの」というニュアンスで用います。つまり、「サーバ(サービスを提供するもの)」に対する「クライアント(サービスを享受するもの)」を指します。なお、ここでは「サーバ」として「バリデータクラスタによって運営されるブロックチェーンシステム」が対応します。

より具体的には、Solanaブロックチェーンにおけるクライアント[3]は、例えば以下のような仕事を行います。

  • (A1)トークンの残高を確認する、DEXのスワップレートを確認する、レンディングプールのAPRを確認する
  • (A2)トークンを送金する、DEXでトークンを交換する、レンディングプールにトークンをデポジットする

このように、ブロックチェーンを実際に利用するエンドユーザの立場から見ると、クライアントはユーザの目的や状況に応じて様々な仕事を行いますが、技術的な観点では、それぞれ以下の処理を実行しているだけともいえます。

  • (B1)ブロックチェーンに保存されているステート[4]やレコード[5]を取得する
  • (B2)ブロックチェーンに対してトランザクションメッセージを送信する

本稿では便宜的に、(B1)や(B2)のようなブロックチェーンシステムとの基本的な相互作用を実現するためにクライアントが備える低水準の機能を「クライアント基本機能」とよぶことにします。一方、「クライアント基本機能」を土台として、エンドユーザの具体的な目的や状況に合わせてアドホックに与えられる(A1)や(A2)のような高水準の機能を「クライアント応用機能」とよぶことにします。

素晴らしいことに、フロントエンドUIを介してブロックチェーンアプリケーションを操作するエンドユーザは、「クライアントにはどのような役割があるのか」「どのような仕組みで動いているのか」といった小難しいことを考える機会は(そしてその必要も)ほとんどないことでしょう。なぜなら、お使いのウォレットアプリやウェブブラウザの裏側で、何らかのプログラミング言語(おそらくJavaScriptでしょう)で書かれたクライアントプログラムが必要な仕事をユーザの代わりに全てやってくれているからです。

その一方で、ブロックチェーンアプリケーション開発者やbotトレーダーなどの技術者は、開発対象に搭載するクライアント機能の一部[6]あるいは全部を自らの手で構築する必要があります。

3. SolanaのRustクライアントいろいろ

ここから少しずつ技術的な話に踏み込んでいきます。

実は、本稿でいう「クライアント基本機能」に相当するものは、SolanaリポジトリのRustクレートにてくつか実装されており、これらのクレートを必要に応じて依存関係に追加することで、だれでもクライアントコードの中でライブラリとして利用することができます。

それらのうち、筆者が開発の中で主に利用しているものを3つご紹介します。

3.1. RpcClient

https://docs.rs/solana-client/latest/solana_client/rpc_client/struct.RpcClient.html
https://docs.rs/solana-client/latest/solana_client/nonblocking/rpc_client/struct.RpcClient.html

Solanaネットワーク上のRPCノードとの通信(JSON-RPC)に使用するクライアントです。同期版と非同期版(nonblocking)の2種類があります。メインネット実運用での利用が主ですが、開発環境においても、ローカルのバリデータクラスタを含むテスト環境(solana-test-validatorクレート)を用いてテストを実行する際にも重宝しています。テストに使用する場合、(たとえクラスタをローカルに立ち上げたとしても)JSON-RPCのプロトコル実装に起因するオーバーヘッド(JSONシリアライズ/デシリアライズやネットワークIOなど)が存在するため、後述のクライアントに比べてテスト実行が低速になる可能性があります。

3.2. BanksClient

https://docs.rs/solana-banks-client/latest/solana_banks_client/struct.BanksClient.html

オンチェーンプログラムのユニットテストを実行するテスト環境(solana-program-testクレート)にてSolanaランタイムとのインタフェースとして使用します。APIがやや貧弱ということもあり、筆者の場合、テスト環境にてBank/BankForks(後述)が使える場合はそちらを採用してしまうので、利用機会は他と比べてそれほど多くありません。

3.3. Bank

https://docs.rs/solana-runtime/latest/solana_runtime/bank/struct.Bank.html

こちらはクライアントというより、Solanaランタイムを構成するストレージシステムの機能要素です。
アプリケーションやbotの開発においては、(Solanaランタイムがお手元にあることが必須要件のため)ローカルでのテストやデバッグ用途に限定されます。特に、入力トランザクションやアカウントデータに関する各種検証ステップをバイパスしてステートを可変操作できるため、テストケースの前提条件を詳細に設定したい場合や、テストスイートの実行を高速化したい場合に有用です。ただし、アプリケーションやbotのクライアントとして直接利用するにはAPIが低水準すぎて扱いにくいという難点があります。そのため、筆者の場合、これらを薄くラップしたものにエルゴノミックなメソッドを生やして間接的に使っています。

以上のRustクライアントをそれぞれ比較してみると、「ブロックチェーンシステムに対してステート読取やトランザクション送信を実行する」という点では共通するものの、「入力データ検証の有無」「実行速度」「利用できる環境要件」など様々な側面で異なることがわかります。したがって、開発プロセスの中で直面する様々な状況やニーズに応じてこれらを上手く使い分ける必要があります[7]

4. クライアント開発のパターン

ここまでに述べたとおり、クライアントの機能を「基本機能」と「応用機能」の2つに分類しましたが、実際の開発現場では、これら2つのうち、どちらか片方のみに専念できるパターンが一般的なのではないでしょうか。

  • ブロックチェーンプロトコルや汎用ライブラリの開発者は、「基本機能」に関心がある一方で、「応用機能」についてはそれほどかもしれない(例えば、少数のコアプログラムやテスト用のフェイクプログラムが使えれば十分かもしれない)。
  • アプリケーションの開発者は、「応用機能」に関心がある一方で、「基本機能」についてはそれほどかもしれない(例えば、JSON-RPCが使えれば十分かもしれない)。

それに対し、「基本機能」と「応用機能」の重要性が釣り合うパターンも想定できます。
例えば、ちょうど今の筆者のように、ブロックチェーン上でbotを開発・運用しているような場合です。

  • ブロックチェーンシステムやアプリケーションとのインテグレーションを行うため、実運用だけでなくテスト・デバッグなどの様々な開発タスクの中で複数の「基本機能」を使い分ける必要がある。
  • 収益機会を最大化するため、様々なアプリケーション[8]に向けて多数の「応用機能」を実装する必要がある。

このような「二刀流」のケースでは、ある種の厄介な問題に直面することがあります。

5. クライアント開発の落とし穴:組み合わせによる複雑化

以下のような状況を想定しましょう。

  • JSON-RPCを使ってメインネット上のトークンアカウント残高(balance)を取得したい

実装方法としてすぐに思いつくアプローチは、以下のようにRpcClientをラップしたTokenClientWithRpcを定義し、その構造体のメソッドとしてget_token_account_balanceを実装することです[9]

use solana_client::rpc_client::RpcClient;
use solana_sdk::account::{Account, ReadableAccount};
use solana_sdk::pubkey::Pubkey;

use anchor_lang::AccountDeserialize;
use anchor_spl::token::TokenAccount;

pub struct TokenClientWithRpc(RpcClient);

impl TokenClientWithRpc {
    pub fn new<U: ToString>(url: U) -> Self {
        Self(RpcClient::new(url))
    }

    pub fn get_token_account_balance(&self, token_account_pk: &Pubkey) -> anyhow::Result<u64> {
        let account: Account = self.0.get_account(token_account_pk)?;
        let token_account: TokenAccount = TokenAccount::try_deserialize(&mut account.data())?;
        Ok(token_account.amount)
    }
}

fn main() {
    let client: TokenClientWithRpc = TokenClientWithRpc::new("https://api.mainnet-beta.solana.com");
    let token_account_pk: Pubkey =
        solana_sdk::pubkey!("SomeTokenAccount111111111111111111111111111");
    let token_balance: u64 = client.get_token_account_balance(&token_account_pk).unwrap();
    println!("Token balance: {}", token_balance);
}
Token balance: 1000000

ここまでは順調です。

さらに開発が進み、オンチェーンプログラムのテストにおいても同じようにトークンを扱う機能が必要になったとしましょう。このテスト環境における基本機能の実現手段としてBankをベースに採用する場合、以下のようにTokenClientWithBankを新たに追加します。

use solana_runtime::bank::Bank;
use solana_sdk::account::{AccountSharedData, ReadableAccount};
use solana_sdk::pubkey::Pubkey;

use anchor_lang::AccountDeserialize;
use anchor_spl::token::TokenAccount;

pub struct TokenClientWithBank(Bank);

impl TokenClientWithBank {
    pub fn new(bank: Bank) -> Self {
        Self(bank)
    }

    pub fn get_token_account_balance(&self, token_account_pk: &Pubkey) -> anyhow::Result<u64> {
        let account: AccountSharedData = self
            .0
            .get_account(token_account_pk)
            .ok_or_else(|| anyhow::anyhow!("Account not found"))?;
        let token_account: TokenAccount = TokenAccount::try_deserialize(&mut account.data())?;
        Ok(token_account.amount)
    }
}

なんとか対処できました。しかしここで、トークンアカウント残高だけでなく、トークン供給量(supply)も取得したくなったとします。そうすると、先ほど作成した2つの構造体TokenClientWithRpcTokenClientWithBankの両方に、同じシグネチャのメソッドget_mint_supplyを追加することになります。

pub struct TokenClientWithRpc(RpcClient);

impl TokenClientWithRpc {
    pub fn get_token_account_balance(&self, token_account_pk: &Pubkey) -> anyhow::Result<u64> {
        /* ... */
    }

    pub fn get_mint_supply(&self, mint_pk: &Pubkey) -> anyhow::Result<u64> {
        /* ... */
    }
}

pub struct TokenClientWithBank(Bank);

impl TokenClientWithBank {
    pub fn get_token_account_balance(&self, account_pk: &Pubkey) -> anyhow::Result<u64> {
        /* ... */
    }

    pub fn get_mint_supply(&self, mint_pk: &Pubkey) -> anyhow::Result<u64> {
        /* ... */
    }
}

んー、ちょっと嫌な匂いがしてきましたね!

まだ現時点ではかろうじて持ち堪えていますが、今後さらにトークンに関するメソッドが3つ、4つ、と増えていくとどうなるでしょうか?あるいは、トークンだけでなく、スワップやレンディングに関するメソッドも追加したくなったらどうでしょうか?基本機能の異なる複数のクライアント型それぞれに対して、全く同じシグネチャのメソッドを並行して実装しなければなりません。

もっと悪いことに、新たなクライアント(例えば、BanksClientをバックエンドに据えたもの)の導入が必要になったとしたらどうでしょうか?新たに定義したクライアント型に対して既存の応用機能を全て実装し直さなければなりません。

このアプローチの大きな問題点は、「基本機能」と「応用機能」を同じレイヤの中で実装しようとしていることです(そして、これはかつての筆者が実際にやったことです)。

これにより、クライアントコードの中で実装しなければならないメソッドの総数が、「基本機能の集合X」と「応用機能の集合Y」という異なる2つの集合間の要素ペアの組み合わせ総数(直積集合X×Yの要素数\lvert X×Y \rvert)に比例してしまっています[10]

すなわち、クライアントコード開発にて必要となる基本機能の数をM、応用機能の数をNとおくと、

O(M × N)
のオーダーで実装コストが積的に増加していくということになります。

取り扱っている基本機能や応用機能が少ないうちは気になりませんが、コードベースの成長に伴ってクライアントコードへの要求が肥大していくようなケースでは、いつかはスケールを阻む大きな障壁となってしまうでしょう。

6. クライアントフレームワーク

前置きが長くなってしまいましたが、ここから本題に入ります。

6.1. 目的

本稿で紹介するクライアントフレームワークの目的は、Solanaブロックチェーンに向けたクライアントプログラムを開発・実装・運用・保守する中で、クライアントコードが抱える「基本機能」と「応用機能」という本質的に異なる2つの関心事をソースコード上で分離することにあります。

6.2. モデル

フレームワークの大まかなモデルの概要を以下に示します。

  1. クライアント基本機能を抽象化してインタフェースとして定義
  2. クライアント基本機能を備える各クライアント具体型がインタフェースを実装
  3. クライアント応用機能を各クライアント具体型ではなくインタフェースに対して実装

このモデルをSolana/Rustの文脈で実現するため、Rustのトレイトシステムを活用して以下のようにローカライズします。

  1. クライアント基本機能に対応する基本機能トレイト(GetAccountProcessTransactionなど)を定義
  2. クライアント基本機能を備える各クライアント具体型(RpcClientBankなど)が基本機能トレイトを実装
  3. クライアント応用機能に対応する応用機能トレイトを(その応用機能を実現するために必要となる)基本機能トレイトのサブトレイト[11]として定義するとともに、その基本機能トレイトを実装する任意の型に対してブランケット実装を与える(拡張トレイトパターン[12]を適用する)

上記のモデルにしたがって完全なクライアントを構築する場合、先述した問題のあるアプローチとは異なり、クライアント応用機能を各クライアント具体型(のラッパ)それぞれに個別に実装せずに済みます。

これにより、クライアントコードの成長に伴って生じる実装コストのオーダーがO(M × N)からO(M + N)程度に改善されます

6.3. コード

ここからはソースコードを参照しながらフレームワークの主要な構成要素について見ていきましょう。

Clientトレイト

pub trait Client {}

impl<C: ?Sized + Client> Client for &C {}

impl<C: ?Sized + Client> Client for &mut C {}

impl<C: ?Sized + Client> Client for Box<C> {}

impl<C: ?Sized + Client> Client for Arc<C> {}

Clientトレイトは、実装型が「基本機能を備えるクライアント具体型」であることを表すマーカトレイトです。後述する基本機能トレイトは全てClientのサブトレイトとして定義されています。このトレイトはメソッドを1つも持たないのでプログラム実行時には何もしませんが、応用機能トレイトのブランケット実装(後述)にてトレイト境界の指定に使用するなど、主に型システム上での便宜を図る目的で存在します。

また、「型TClientならば、そのポインタ型PClientである」とみなせる方が実用上なにかと便利なので、参照型と一般的なスマートポインタ型に対してブランケット実装を与えています(以降の全ての基本機能トレイトについても同様)。

基本機能トレイト(定義)

基本機能トレイトは全てdexter_client_api::baseモジュール下に配置されており、ブロックチェーンシステムに対して行う操作の種類別に、base::getterbase::executorbase::setterの3つのサブモジュールに分かれています。

base::getterトレイト一式

pub trait GetAccount: Client {
    fn get_account(&self, pubkey: &Pubkey) -> ClientResult<Option<Account>>;
}

pub trait GetProgramAccounts: Client + GetAccount {
    fn get_program_accounts(
        &self,
        program_id: &Pubkey,
        filters: Option<Vec<ProgramAccountsFilter>>,
    ) -> ClientResult<Vec<(Pubkey, Account)>>;
}

pub trait GetMultipleAccounts: Client + GetAccount {
    fn get_multiple_accounts(&self, pubkeys: &[Pubkey]) -> ClientResult<Vec<Option<Account>>>;
}

pub trait GetMinimumBalanceForRentExemption: Client {
    fn get_minimum_balance_for_rent_exemption(&self, data_len: usize) -> ClientResult<u64>;
}

pub trait GetLatestBlockhash: Client {
    fn get_latest_blockhash(&self) -> ClientResult<Hash>;
}

ブロックチェーンシステムのステートデータを読み取る能力(getter)を表します。各トレイトメソッドにはSolana JSON-RPC APIの中に対応するメソッドが存在するので、RPCをよく叩く人にとっては馴染みのあるシグネチャかもしれません。

なお、ほとんどの基本機能トレイトは独立した定義(Client以外とのサブトレイト関係を持たない)となっていますが、実用上の理由により、Get*Accounts系のみGetAccountのサブトレイトとしています(複数アカウントデータの取得が可能ならば単一アカウントデータの取得も可能であることを仮定してよいという考えに基づく)。

base::executorトレイト一式

pub trait ProcessTransaction<T>: Client {
    fn process_transaction(&self, transaction: VersionedTransaction) -> ClientResult<T>;
}

pub trait SimulateTransaction<T>: Client {
    fn simulate_transaction(&self, transaction: VersionedTransaction) -> ClientResult<T>;
}

ブロックチェーンシステムに対してトランザクションを実行する能力(executor)を表します。入力されるトランザクションを実際に処理してステートを更新するか(ProcessTransaction)、シミュレーションのみを行うか(SimulateTransaction)、という目的別に2つのトレイトが用意されています。

それぞれのトレイトがジェネリックになっている(出力型Tがパラメータ化されている)理由は、トランザクションの実行結果として返却できる情報がベースとなるクライアント実装によって異なる(例えば、ステート更新後のアカウントデータを含められるかなど)ことがあるので、その差分に応じて異なる型を返せるようにするためです。

また、本来は「トランザクションメッセージの送信」と「トランザクションの処理」の2つは異なるステップとして区別されるべきですが(例えばRPCの場合、メッセージ送信に成功したからといってオンチェーンでの処理が保証されるわけではない)、筆者のユースケースの範囲内ではこれら2つのステップがまとめて実行されるとみなせるので(ローカルにおけるテスト環境と、パブリックネットワークでもクリティカルでないオンチェーンタスクに使途が限定されている)、シンプルさを優先して2つのステップを同一視する設計としています。

base::setterトレイト一式

pub trait SetAccount: Client {
    fn set_account(&mut self, pubkey: Pubkey, account: Account);
}

pub trait HasRent: Client {
    fn rent(&self) -> Rent;

    fn minimum_balance_for_rent_exemption(&self, data_len: usize) -> u64 {
        self.rent().minimum_balance(data_len).max(1)
    }
}

ブロックチェーンシステムのステートデータを直接上書きする能力(setter)を表します。

これらは言うまでもなくテスト環境に特化した基本機能トレイトであり、リモートのブロックチェーンシステムと通信する類のクライアントには実装できません(例えば、RpcClientでこの機能を実現する正当な方法は存在しません)。

HasRentトレイトは、SetAccount::set_accountにてアカウントデータを書き込む際に、オンチェーンアカウントの作成・維持に必要なデポジット量(rent_exemption)を計算するために使用します。HasRent::minimum_balance_for_rent_exemptionは先ほどのGetMinimumBalanceForRentExemptionのメソッドとほとんど同じですが、こちらはテスト環境でのみ使用されるという前提から、必ず成功する(Resultではなく生の値を返す)シグネチャとなっています。

基本機能トレイト(実装)

ここまでに挙げた基本機能トレイトを具体的なRustクライアント型に実装していきます。ソースコードでは、dexter_client_api::base_implsモジュール下にRpcClientBanksClientBankに対する実装があります。

全てのクライアント型が全ての基本機能トレイトを実装している訳ではなく、そのクライアント型を用いて実現できるトレイトのみを選択的に実装しています。例えば、「Bankはアカウントデータベースを操作できるのでSetAccountを実装するが、RpcClientはそれが不可能なのでSetAccountは実装しない」といった具合です。

実装の詳細はかなり煩雑になるので省略しますが、例えばRpcClientについてはこんな感じです。

impl Client for RpcClient {}

impl GetAccount for RpcClient {
    fn get_account(&self, pubkey: &Pubkey) -> ClientResult<Option<Account>> {
        let response = self.get_account_with_commitment(pubkey, self.commitment())?;
        Ok(response.value)
    }
}

/* ... */

impl GetMinimumBalanceForRentExemption for RpcClient {
    fn get_minimum_balance_for_rent_exemption(&self, data_len: usize) -> ClientResult<u64> {
        Ok(RpcClient::get_minimum_balance_for_rent_exemption(
            self, data_len,
        )?)
    }
}

impl GetLatestBlockhash for RpcClient {
    fn get_latest_blockhash(&self) -> ClientResult<Hash> {
        let (blockhash, _) = self.get_latest_blockhash_with_commitment(self.commitment())?;
        Ok(blockhash)
    }
}

/* ... */

RpcClientの場合は、基本機能トレイトメソッドと固有メソッドのシグネチャが似通っているので、このレイヤでは(多少の変換ステップが入るものもありますが)ほとんど対応する固有メソッドに移譲するだけで済んでいます(ProcessTransactionについては例外で、やや複雑なリトライ処理を挟んでいます)。

応用機能トレイト

先述の一連の基本機能トレイトをベースとして、関心のあるドメイン、アプリケーション、ユースケースなどに対して応用機能トレイトをそれぞれ定義・実装していきます。

今回のソースコードの中では基本的なドメインに関するものを一部実装しています。

  • dexter-client-sysクレート Solanaシステムやネイティブプログラムを扱う
  • dexter-client-splクレート SPLプログラムを扱う(現時点ではトークンのみ)
  • dexter-client-anchorクレート Anchor互換型[13]を扱う

例示も兼ねていくつか見ていきましょう(メソッド実装は適宜省略)。

ProgramGetterトレイト

pub trait ProgramGetter: Client {
    fn get_program(&self, program_id: &Pubkey) -> ClientResult<Option<Vec<u8>>>
    where
        Self: GetAccount,
    {
        let Some(program_account) = self.get_account(program_id)? else {
            return Ok(None);
        };

        let loader_id = program_account.owner;
        let program = if loader_id == bpf_loader_upgradeable::id() {
            let program_state: UpgradeableLoaderState = bincode::deserialize(&program_account.data)
                .map_err(|_| ClientError::AccountDidNotDeserialize(*program_id))?;

            let UpgradeableLoaderState::Program {
                programdata_address,
            } = program_state
            else {
                return Err(ClientError::AccountDidNotDeserialize(*program_id));
            };

            let programdata_account = self.try_get_account(&programdata_address)?;

            programdata_account.data[UpgradeableLoaderState::size_of_programdata_metadata()..]
                .to_vec()
        } else if loader_id == loader_v4::id() {
            program_account.data[LoaderV4State::program_data_offset()..].to_vec()
        } else {
            assert!(loader_id == bpf_loader::id() || loader_id == bpf_loader_deprecated::id());
            program_account.data
        };

        Ok(Some(program))
    }

    fn try_get_program(&self, program_id: &Pubkey) -> ClientResult<Vec<u8>>
    where
        Self: GetAccount,
    {
        match self.get_program(program_id)? {
            Some(program) => Ok(program),
            None => Err(ClientError::AccountNotFound(*program_id)),
        }
    }
}

impl<C: ?Sized + Client> ProgramGetter for C {}

このように、応用機能トレイトを「ドメイン」「ベースとなる基本機能の種類(Getter/Executor/Setter)」という2つの軸で定義し、トレイトメソッド毎に必要とする基本機能トレイトをSelf境界として指定した上で、その境界内で利用可能なメソッドのみを使ってデフォルト実装の形で書いていきます。

例えば、ProgramGetter::get_programSelf: GetAccountを境界に持つので、基本機能としてはGetAccount::get_accountのみが利用可能です。実装においてはget_accountを用いてオンチェーンプログラムが格納されているアカウントデータを取得(基本機能に委譲)し、そのコンテンツをデコードして目的のプログラムデータを抽出(応用機能で拡張)しています。

なお、このProgramGetterのように、スーパートレイトとしてGetAccountを指定することでメソッド毎のSelf: GetAccount境界を消去できる場合がありますが、ものによっては同様のことが当てはまらない(同一トレイト内のメソッド間で共通の境界がClient以外に存在しない)場合もあるので、コードベース全体の一貫性という観点から、Clientのみをスーパートレイトとして指定する書き方で統一しています。

TokenGetterトレイト

pub trait TokenGetter: Client {
    /* ... */

    fn get_token_account_balance(&self, token_account: &Pubkey) -> ClientResult<Option<u64>>
    where
        Self: GetAccount,
    {
        /* ... */
    }

    /* ... */

    fn get_mint_supply(&self, mint: &Pubkey) -> ClientResult<Option<u64>>
    where
        Self: GetAccount,
    {
        /* ... */
    }

    /* ... */
}

impl<C: ?Sized + Client> TokenGetter for C {}

前半に登場したTokenClientWith*に対応する応用機能トレイトです。

比較してみると、TokenClientWith*の各構造体の場合は、所望の応用機能を実現するために(それがラップしている)具体的なクライアント型の固有メソッドに依存していましたが、こちらのバージョンでは具体的なクライアント型には一切言及せず、ClientGetAccountといった抽象のみに依存して実装されていることが分かります。

エラー

#[derive(Debug, Error)]
pub enum ClientError {
    #[error("An account {0} already exists")]
    AccountAlreadyExists(Pubkey),
    #[error("An account {0} was not found")]
    AccountNotFound(Pubkey),
    #[error("Failed to deserialize the account {0}")]
    AccountDidNotDeserialize(Pubkey),
    #[error("Failed to serialize the account {0}")]
    AccountDidNotSerialize(Pubkey),
    #[error(transparent)]
    CompileError(#[from] CompileError),
    #[error(transparent)]
    SigningError(#[from] SignerError),
    #[error(transparent)]
    AddressLookupError(#[from] AddressLookupError),
    #[error(transparent)]
    TransactionError(#[from] TransactionError),
    #[error(transparent)]
    ClientSpecific(#[from] ClientSpecificError),
    #[error("domain specific error: {0}")]
    DomainSpecific(Box<dyn StdError + Send + Sync>),
}

クライアントが返却するエラー型の抽象化は諦めました。一つのファットな列挙型ClientErrorを定義し、現時点で想定している具体的なクライアント型(現時点ではRpcClientBanksClientBankの3つのみ)に由来する固有エラー型を適切なバリアントに力技で変換しています。クライアント型によらず共通(意味論的に等価である)とみなせる種類のエラー(アカウントの存在やトランザクションの実行結果に関するものなど)については、各固有エラー型からClientErrorに変換する際に可能な限り同一のバリアントに帰着されることを目指して実装しています(が、おそらくまだ完全ではありません)。

6.4. 効果

6.4.1. 保守性

トレイトシステムを用いて「基本機能」と「応用機能」の実装レイヤを分離することで両者が疎結合になりました。これにより、将来的にどちらか一方のコードを変更することになっても、その変更に際して両者を密接に意識する必要がなくなりました。

  • 基本機能トレイトの(あるクライアント型に対する)実装を追加・変更しても、応用機能トレイト側のコードは修正不要
  • 応用機能トレイトを追加・変更しても、基本機能トレイト側のコードは修正不要

このような独立性によって、長期的な保守性の確保が期待できます。

6.4.2. 柔軟性

応用機能トレイトは抽象インタフェースのみに依存して構築されるため、どのような基本機能の実装に対しても動作します。

これにより、実際に応用機能をプログラム実行時に使用するユーザコードにおいては、バックエンドに据えるクライアント型を状況に応じて柔軟に切り替えることができます。

また、新たにクライアント型を追加する場合にも、基本機能トレイトを満たす実装さえ行えば既存の応用機能群がそのまま動くため、新規クライアントの検証や導入のハードルが下がります。

6.4.3. 拡張性

保守性の項で述べたように、応用機能の追加・変更に際して基本機能側のコードを修正する必要がないため、アプリケーションやユースケースに応じてクライアントAPIを容易に拡張することができます。

6.4.4. モジュール性

応用機能トレイトの実装にあたっては、ブロックチェーンシステムとの相互作用に関連する処理を基本機能トレイト(の実装)に委譲することにより、ドメイン固有の高水準なロジック記述をブロックチェーンシステムに関する煩雑な技術的詳細から切り離してピュアに保つことができます。

これにより、ドメイン毎に関連する応用機能トレイト群をまとめて拡張モジュールとして抽出することが可能になります。

例えば実際に、本稿のソースコードの中ではこのことを利用し、SPLに関連する応用機能トレイト群をdexter-client-splクレートに、Anchorに関連する応用機能トレイト群をdexter-client-anchorクレートにまとめています。

6.5. 制約

6.5.1. 非同期ランタイムに対応していない

本稿にて登場したトレイトメソッドが全て同期関数である(async fnでない)ことからも察しがつく通り、tokioなどの非同期ランタイム下での利用は想定されていません[14][15]

6.5.2. 実行時性能が要求される用途には適さない

リクエスト/レスポンスモデルを前提としていることや、非同期コードに対応していないこと(前述)などから、ブロックチェーンシステムとの相互作用に高速性やリアルタイム性が要求されるような用途には向きません。

筆者は、これらの制約と開発上の利便性とのトレードオフの結果、クライアントコードにおける本フレームワークの導入範囲を「テスト環境全般」あるいは「メインネット運用時のクリティカルでないオンチェーン操作(トークンアカウント作成やデポジットなど)」に限定しています。その一方で、高速性やリアルタイム性が要求される場面でのオンチェーン操作(収益機会に関わるステート読取やトランザクション送信など)に関しては、目的に応じて個別に最適化された方式を別途採用しています。

7. まとめ

本稿では、筆者がSolanaブロックチェーン上のbotを開発・運用する中で直面したコスト問題に対処するために導入している、Rustクライアントフレームワークについて紹介しました。

  • アプリケーションやbotのクライアントプログラムが備える機能を「基本機能」と「応用機能」の2つに大別した
  • トレイトシステムを用いて「基本機能」「応用機能」の実装レイヤを分離することにより、コードベースの成長に伴う実装コスト増加のオーダーが削減された
  • 「基本機能」と「応用機能」の実装が疎結合となることで、保守性・柔軟性・拡張性・モジュール性などの好ましい特性が得られた
  • 非同期文脈で使えない、高性能用途に適さないなど制約も存在するため、導入範囲の検討にあたっては、これらの制約事項を考慮し、導入による利便性とのトレードオフを慎重に行う必要がある

筆者の所感ですが、このフレームワークの導入以降、新規にbotを構築する度に降りかかるドメイン毎のクライアントの構築作業が省力化・高速化されたことで、(最も利益に直結する、本質的に重要な)ビジネスロジックの構築作業により多くの時間を割けるようになったと感じています。

本稿は「Solana」「Rust」という限定されたスコープで書きましたが、基礎としている考え方はソフトウェアエンジニアリング戦略として知られている一般的かつ汎用的なものばかりだと思うので、ぜひ、(SolanaやRustに限らず)複雑化したクライアントコードを整理する際に設計事例の一つとしてヒントにしていただければと思います。

脚注
  1. 本稿では「暗号資産の取引を自動実行するプログラム」を指して用います。 ↩︎

  2. 一般的に使われている「スマートコントラクト」に相当するSolana用語。 ↩︎

  3. Solanaの文脈では「クライアント」という言葉が「バリデータソフトウェア」を指すことがありますが、本稿での意味はそれとは異なります。 ↩︎

  4. 現在のアカウントデータやシステムコンフィグレーションなど。 ↩︎

  5. 過去に処理されたブロック履歴やトランザクション履歴など。 ↩︎

  6. 既存のライブラリを利用する場合 ↩︎

  7. 他にもPubsubClientTpuClientなど様々なRustクライアントが存在しますが、本稿の範囲を超えるため割愛します。 ↩︎

  8. 様々なトークン規格、DEX、レンディングプロトコル、オラクルプロトコルなど。 ↩︎

  9. RpcClientにはこれと全く同じことを行うRpcClient::get_token_account_balanceが元々備わっていますが、ここでは例示のため敢えて一から実装しています。 ↩︎

  10. ここでは「基本機能の集合Xの要素x」として、get_accountprocess_transactionといったAPIではなくRpcClientBankのようなバックエンドとして採用するクライアント型を数えています。 ↩︎

  11. 後述する通り、実際のソースコード上では便宜上、基本機能トレイトをスーパートレイトではなく各トレイトメソッドの境界として指定している。実現されることは同じ。 ↩︎

  12. 参考: https://nossie531.github.io/rust_memo/extension_trait.xhtml ↩︎

  13. AccountSerializeAccountDeserializeを実装しているドメインのアカウント型 ↩︎

  14. 全く使用できないという訳ではありませんが、例えば、同期版RpcClientは内部的に非同期版nonblocking::RpcClientにほぼ全ての処理を委譲しているため、非同期文脈で使おうとするとランタイムがネストしてしまうので動きません。 ↩︎

  15. 現行のRustでは非同期メソッドをもつトレイトに対するサポートがまだ十分とはいえず、今回のようなトレイトベースのプログラミングを多用するフレームワーク設計の中で対応するにはハードルが高いという事情があるからです。 ↩︎

Discussion