新着:HeliusがLight Protocolを買収
Solanaの算術演算 — Solanaプログラムやスマートコントラクトで計算する方法
ブログ/基礎

Solanaの算術演算:金融アプリ構築のベストプラクティス

開発者教育XのMike MacCanaLinkedInのMike MacCana
読了時間:9分

この記事の編集と執筆に協力してくださった0xIchigo氏とLostin氏に感謝します。

Solanaは競争の激しい領域です。新しいレンディングプロトコル、アグリゲーター、予測市場、RWAトークン化など、何を開発している場合でも、急いでメインネットに公開したくなるかもしれません。

しかし、ブロックチェーンアプリケーションは価値を管理するため、本質的に金融アプリケーションであることを忘れてはいけません。

ブロックチェーンでも従来型金融でも、これまで金融アプリを構築した経験がない場合は、金融数学の重要性を理解しておく必要があります。

Solana固有のプログラミングトピックには、優れた資料が数多くあります。各命令に署名する必要があるアカウントの確認、使用するアカウントの所有プログラムの確認、アカウント再オープン攻撃の回避などです。Anchorのアカウント制約を使うと、こうしたチェックの多くを簡単に実装できます。また、Rustには、Debugモードで整数のオーバーフローとアンダーフローを検出するなど、適切なデフォルト設定もあります。

安全な金融プログラミングでは、Solana固有のトピック以外にも注意すべきことがあります。 トークン演算のたった1つのエラーが、資産の流出、意図しないインフレーション、ユーザーの不満につながる可能性があります。ほかのブロックチェーンよりもトランザクション量が多いSolanaでは、脆弱性がさらに短時間で悪用される可能性があります。

従来型金融で使われる金融プログラミング手法の多くは、ブロックチェーンとは無関係です。そのため十分に注目されていませんが、ユーザーのトークンを保護するうえで欠かせません。

この記事では、具体的に次の内容を取り上げます。

  • 整数と最小単位の使用
  • 精度低下の回避 — 乗算してから除算
  • 一貫した丸めポリシー
  • 浮動小数点数を使わない利息計算

整数と最小単位を使用する

精度の不足は、スマートコントラクトでよく見られる脆弱性です。数学的演算が正確でなければ、脆弱性につながる可能性があります。Solanaアプリケーションで安全に数学的演算を行うには、整数と最小単位を使用する必要があります。

基本的な例を見てみましょう。

従来型金融では、「USD」で行ったあらゆる取引が、実際にはセント単位で処理されています。GBPを使用する場合は、ペンス単位で処理されます。 

同様に、SOLのトランザクションはlamport単位で、USDCのトランザクションは100万分の1 USDC単位で処理する必要があります。

セント、ペンス、lamportはすべて最小単位(基本単位とも呼ばれます)です。すべてを最小単位で処理する理由は単純です。コンピューターは浮動小数点数を正確に扱えないからです。

2進数で1を表すと、次のようになります。

32の位16の位8の位4の位2の位1の位
000001

2進数で9を表すと、次のようになります。

32の位16の位8の位4の位2の位1の位
001001

では、たとえば0.3 USDCはどのように表現すればよいでしょうか。 

正解は、表現できない、です。 

コード
let answer = 0.1 + 0.2;
msg!("0.1 + 0.2 = {}", answer);

結果は次のようになります。

プログラムのログ:"0.1 + 0.2 = 0.30000000000000004"

代わりに、ドル、GBP、SOL、USDCなど、あらゆる「通貨」を最小単位の整数として扱います。

たとえば、USDCで0.1と0.2を加算する場合は、次のようにします。

コード
let answer_ints: u128 = 100000 + 200000;
msg!("100000 + 200000 = {}", answer_ints);

結果は次のようになります。

プログラムのログ:"100000 + 200000 = 300000"

最小単位を使用すれば、このような精度低下は発生しません。

開発者はトークンの小数桁数を使用する必要がありますが、すべてのトークンが同じ小数桁数とは限らないことを忘れてはいけません。

トークン量に整数を使うのは当然に思えるかもしれません。しかし、トークン量だけでなく、あらゆる箇所で整数を使用する必要があります。

パーセンテージ

パーセンテージは整数で表す必要があります。一般的にはベーシスポイント(「bips」と呼ばれることもあります)が使用され、bpsと表記されます。

たとえば、4.74%は474 bpsです。

複利

複利の計算には、複利計算の頻度が無限大に近づくときの極限を表す e(オイラー数)を使用すべきではありません。

eを使用すると「連続複利」、つまり複利を滑らかな曲線として表現できますが、Rustではe自体が浮動小数点数として表されます。

eを使用すると、丸め誤差が発生するだけでなく、ほかの方法とは異なる結果になります。

この記事の後半で、両方を実演します。

オーバーフローとアンダーフロー

Rustでは整数が固定サイズの変数として保存されます。つまり、変数が符号付きか符号なしかに応じて、メモリ内で使用できる領域が制限されます。

たとえば、u8型には0から255までの値を格納できます。しかし、その範囲外の値を格納すると、オーバーフローまたはアンダーフローが発生します。

オーバーフローとは

オーバーフローとは、値がその変数型に格納できる最大値を超え、最小値に循環することです。

たとえば、u8に256を格納しようとすると0に戻ります。257は1、258は2、511は255になり、512では再び0に戻ります。

アンダーフローとは

アンダーフローとは、値が取り得る最小値を下回り、最大値に循環することです。u8では、-1は255、-2は254というように変換されます。 つまり、アンダーフローはオーバーフローと似ていますが、反対方向に発生します。

Rustには、オーバーフローやアンダーフローが発生した際に、実行時にプログラムをパニックさせるチェックが複数あります。しかし、リリースモードでコンパイルすると、これらのチェックは含まれません。さらに、Solanaの開発環境に不可欠なツールチェーンは、デフォルトでSolanaプログラムをリリースモードでコンパイルします。 

オーバーフローとアンダーフロー、およびその軽減方法について詳しくは、Solanaプログラムセキュリティガイドをご覧ください。

乗算してから除算する

乗算より先に除算するほうが自然に感じられることはよくあります。予測市場を構築する場合を例に考えてみましょう。ユーザーが的中した結果にベットしていた場合、払戻金を計算する必要があります。予測市場の勝者には、的中した結果に対するベット総額のうち、そのユーザーが占める割合に基づいて支払われます。直感的には、次のように考えられます。

勝者用プール ÷ 的中した結果へのベット総額 × ベット額 

これは非常に自然に思えます。

まず勝者用プールを、払戻金の1「シェア」を表す小さな単位に分割し、それぞれに何シェアを渡すかを計算します。

しかし、先に乗算すると中間結果が大きくなります。これにより、その後の除算で生じる丸め誤差の影響を軽減できます。 

勝者用プール × ベット額 ÷ 的中した結果へのベット総額

予測市場の例

簡単な例を見てみましょう。コンピューターではなく人間が計算する場合、正解は10.5です。

まず除算してみます。 

コード
let answer: u128 = 7 / 2 * 3;
msg!("7 / 2 * 3 = {}", answer);

結果は次のようになります。

プログラムのログ:"7 / 2 * 3 = 9"

先に除算すると、1.5最小単位が失われます。

先に乗算するとどうなるでしょうか。

コード
let answer: u128 = 7 * 3 / 2;
msg!("7 * 3 / 2 = {}", answer);

結果は次のようになります。

プログラムのログ:"7 * 3 / 2 = 10"

先に乗算すると、丸めによる損失は0.5最小単位だけです。 

この考え方は、より広く「桁数を増やす処理を先に実行する」と捉えられます。 

たとえば、べき乗やルートを使う複雑な式(リスク計算など)では、べき乗を先に、ルートを後に計算します。固定小数点演算では、先に除算すると、商が切り捨てられた後で乗算されるため、精度が低下する可能性があるからです。

一貫した丸めポリシーを使用する

丸めポリシーに一貫性がなく、1トークン単位の小さな差が生じると、トークンが失われたり、何もないところから生成されたりしたように見える可能性があります。時間が経つにつれて、プログラムの流動性が枯渇したり、意図しないトークンインフレーションが発生したりする可能性があります。

OrcaのWill Thieme氏が、Breakpointの講演Solanaプログラムでよくある落とし穴を回避するで指摘したように、1トークンを得るだけなら大きな問題には見えません。

しかし、Solanaには、複数の命令を含み、トランザクション手数料が低い、従来型金融の意味での実際のトランザクションがあります。そのため、off-by-oneエラーを悪用する多数の命令を1つのトランザクションに詰め込み、非常に低いコストで実行できます。

丸め誤差は避けられませんが、一貫した丸めポリシーで管理できます。切り上げるか切り捨てるか、また「半端」(つまり0.5)をどう処理するかを事前に決めてください。四捨五入のほうが一般的で、一部の金融基準では義務付けられています。最も重要なのは、そのポリシーをコード全体で一貫して適用することです。

Rust固有の丸め処理に関する注意点

丸め処理は精度低下の一般的な原因であるため、開発者は問題が生じる可能性のあるRust固有の関数をいくつか把握しておく必要があります。どの丸め方法を選ぶかによって、プログラムの精度と動作が大きく変わる可能性があります。

四捨五入と切り捨て

たとえば、try_round_u64()関数は最も近い整数に丸めます。担保を流動性に変換するプログラムを構築する場合、切り上げると、提供された担保に見合う量を超える流動性トークンが発行される可能性があります。代わりに、try_floor_u64()関数を使い、最も近い整数に切り捨てる必要があります。

飽和算術関数

また、開発者はオーバーフローとアンダーフローを防ぐため、値をそれぞれの最大値と最小値に制限するsaturating_*算術関数(saturating_addなど)をよく使用します。しかし、これらの関数は気づきにくい精度低下を引き起こす可能性があります。

たとえば、関数内でトランザクション量に報酬倍率を掛け、その積が変数型の最大値を超えると、ユーザーに付与される報酬が本来より少なくなります。

プログラムでは固定小数点演算を使用すべきであるため、この点を意識することが重要です。

浮動小数点数を使わずに利息を計算する

複利の計算ではオイラー数 e がよく使われますが、eは浮動小数点数です。 利息計算には浮動小数点数を使用しないでください。代わりに、固定小数点演算を使用します。

Solanaのspl-mathライブラリにはPreciseNumberがあります。これは、その名のとおり、制約の多いSolanaのRust環境でも動作し、正確な精度を維持しながら非常に小さな小数(小数点以下12桁まで)を表現できます。

コード
use spl_math::precise_number::PreciseNumber;

fn calculate_compound_interest(
   principal: u128,
   rate_basis_points: u128,
   time: u128,
   compounds_per_year: u128,
) -> u128 {
   // Formula: result = principal * (1 + rate/compounds_per_year)^(compounds_per_year * time)
   // Where rate_basis_points is expressed in basis points (500 for 5%)

   // Convert principal to PreciseNumber
   let principal = PreciseNumber::new(principal).unwrap();

   // Convert basis points to decimal percentage (divide by 10000)
   let rate = PreciseNumber::new(rate_basis_points)
       .unwrap()
       .checked_div(&PreciseNumber::new(10_000).unwrap())
       .unwrap();

   // Calculate rate/compounds_per_year
   let rate_per_period = rate
       .checked_div(&PreciseNumber::new(compounds_per_year).unwrap())
       .unwrap();

   // Calculate 'base', which is 1 + rate/compounds_per_year
   let one = PreciseNumber::new(1).unwrap();
   let base = rate_per_period.checked_add(&one).unwrap();

   // Calculate 'total_periods', which is compounds_per_year * time
   let total_periods = compounds_per_year.checked_mul(time).unwrap();

   // Calculate compound_factor, which is (1 + rate/compounds_per_year)^(compounds_per_year * time)
   let compound_factor = base.checked_pow(total_periods).unwrap();

   // Calculate result = principal * compound_factor
   principal
       .checked_mul(&compound_factor)
       .unwrap()
       .to_imprecise()
       .unwrap()
}

コードを実行すると、違いを確認できます。

  • プログラムのログ:"spl-math PreciseNumberを使用"
  • プログラムのログ:"$1000を年利5%で5年間運用し、年1回複利計算:"
  • プログラムのログ:"最終金額:$1276"

一方、eを使用すると次のようになります。

  • プログラムのログ:" eを使用(使用しないでください)"
  • プログラムのログ:"$1000を年利5%で5年間運用し、年1回複利計算:"
  • プログラムのログ:"最終金額:$1284"

まとめ

トークン演算は退屈なテーマですが、注意を怠ると望ましくない事態を招く可能性があります。オンチェーンアプリでは、ユーザーが求める精度と安全性を確保してトークンを扱う必要があります。

この記事では、整数と最小単位の使用、乗算、精度低下の回避、一貫した丸めポリシーの維持、浮動小数点数を使わない利息計算について解説しました。

こうした算術演算の基礎を習得したら、メインネットに公開する前に、第三者、具体的にはSolana専門の監査会社にコードのレビューを依頼してください。

その他のリソース

Heliusを購読

Solana開発の最新情報や新しい記事の公開通知を受け取れます