NEU: Helius übernimmt Light Protocol
Solana-Arithmetik – mathematische Berechnungen mit Solana-Programmen oder Smart Contracts
Blog/Grundlagen

Solana-Arithmetik: Best Practices für Finanz-Apps

EntwicklerbildungMike MacCana auf XMike MacCana auf LinkedIn
9 Min. Lesezeit

Danke an 0xIchigo und Lostin für die redaktionelle Arbeit und ihre Beiträge zu diesem Artikel.

Solana ist ein hart umkämpftes Umfeld. Ganz gleich, ob du an einem neuen Kreditprotokoll, Aggregator, Prognosemarkt, einer RWA-Tokenisierung oder etwas anderem arbeitest: Es kann verlockend sein, schnell ins Mainnet zu gehen.

Bedenke jedoch unbedingt, dass Blockchain-Anwendungen aufgrund der von ihnen verwalteten Werte grundsätzlich Finanzanwendungen sind.

Wenn du bisher keine Finanz-Apps entwickelt hast – weder auf einer Blockchain noch im traditionellen Finanzwesen –, solltest du wissen, wie wichtig Finanzmathematik ist.

Es gibt viele hervorragende Ressourcen zu Solana-spezifischen Programmierthemen: welche Accounts jede Instruction signieren müssen, welchen Programmen die verwendeten Accounts gehören, wie sich Angriffe durch erneut geöffnete Accounts vermeiden lassen und mehr. Mit den Account-Constraints von Anchor lassen sich viele dieser Prüfungen leichter implementieren. Rust bietet außerdem einige sinnvolle Standardeinstellungen und erkennt im Debug-Modus etwa ganzzahlige Über- und Unterläufe.

Sichere Finanzprogrammierung umfasst jedoch mehr als Solana-spezifische Themen. Ein einziger Fehler in der Token-Arithmetik kann zu Verlusten, unbeabsichtigter Inflation und verärgerten Nutzern führen. Da das Transaktionsvolumen auf Solana höher ist als auf anderen Blockchains, können Schwachstellen dort noch schneller ausgenutzt werden.

Viele Programmiertechniken aus dem traditionellen Finanzwesen haben nichts mit Blockchain zu tun. Deshalb erhalten sie nicht die nötige Aufmerksamkeit, obwohl sie für den Schutz der Token deiner Nutzer unverzichtbar sind.

Dieser Artikel behandelt insbesondere folgende Themen:

  • Ganzzahlen und Untereinheiten verwenden
  • Präzisionsverluste vermeiden – zuerst multiplizieren, dann dividieren
  • Einheitliche Rundungsregeln
  • Zinsberechnungen ohne Gleitkommazahlen

Ganzzahlen und Untereinheiten verwenden

Mangelnde Präzision ist eine häufige Schwachstelle in Smart Contracts. Ungenaue mathematische Operationen können ausgenutzt werden. Für sichere mathematische Operationen in Solana-Anwendungen müssen wir Ganzzahlen und Untereinheiten verwenden.

Sehen wir uns ein einfaches Beispiel an.

Im traditionellen Finanzwesen wurde jede deiner Transaktionen „in USD“ tatsächlich in Cent ausgeführt. Bei GBP erfolgte sie in Pence.

Genauso solltest du Transaktionen in SOL mit Lamports und Transaktionen in USDC mit Millionsteln eines USDC verarbeiten.

Cent, Pence und Lamports sind allesamt Untereinheiten (auch Basiseinheiten genannt). Der Grund dafür ist einfach: Computer können Gleitkommazahlen nicht exakt verarbeiten.

So sieht die Zahl Eins binär aus:

ZweiunddreißigerSechzehnerAchterViererZweierEiner
000001

So sieht die Zahl Neun binär aus:

ZweiunddreißigerSechzehnerAchterViererZweierEiner
001001

Aber wie würdest du beispielsweise 0,3 USDC darstellen?

Die richtige Antwort lautet: Das geht nicht:

Code
let answer = 0.1 + 0.2;
msg!("0.1 + 0.2 = {}", answer);

Das ergibt folgendes Ergebnis:

Programm protokollierte: "0.1 + 0.2 = 0.30000000000000004"

Behandle stattdessen Dollar, GBP, SOL, USDC und jede andere „Währung“ als ganzzahligen Betrag ihrer Untereinheit.

So addierst du beispielsweise 0,1 und 0,2 mit USDC:

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

Das ergibt folgendes Ergebnis:

Programm protokollierte: "100000 + 200000 = 300000"

Die Verwendung von Untereinheiten verhindert diesen Verlust.

Entwickler sollten die Dezimalstellen eines Tokens berücksichtigen und daran denken, dass nicht alle Token dieselbe Anzahl von Dezimalstellen haben.

Ganzzahlen für Token-Beträge zu verwenden, mag selbstverständlich erscheinen. Bedenke aber, dass du Ganzzahlen überall verwenden musst, nicht nur für Token-Beträge.

Prozentsätze

Prozentsätze sollten als ganze Zahlen angegeben werden. Meist werden Basispunkte verwendet, die als bps dargestellt werden.

4,74 % entsprechen beispielsweise 474 bps.

Zinseszins

Zinseszinsen solltest du nicht mit e (der Eulerschen Zahl) berechnen. Sie stellt den Grenzwert des Zinseszinses dar, wenn die Verzinsungshäufigkeit gegen unendlich geht.

Mit e ist zwar eine „stetige Verzinsung“ möglich, also im Wesentlichen eine glatte Zinseszinskurve. e selbst wird in Rust jedoch als Gleitkommazahl dargestellt.

Bei der Verwendung von e treten sowohl Rundungsfehler als auch andere Ergebnisse als bei alternativen Methoden auf.

Beides zeigen wir später in diesem Artikel.

Über- und Unterläufe

Rust speichert Ganzzahlen in Variablen fester Größe. Je nachdem, ob eine Variable vorzeichenbehaftet oder vorzeichenlos ist, kann sie daher nur eine bestimmte Menge an Speicher belegen.

Der Typ u8 kann beispielsweise jeden Wert von 0 bis 255 enthalten. Speichern wir jedoch einen Wert außerhalb dieses Bereichs, kommt es zu einem Über- oder Unterlauf.

Was ist ein Überlauf?

Ein Überlauf tritt auf, wenn der Wert die maximale Kapazität des Variablentyps überschreitet und deshalb auf den Minimalwert zurückspringt.

Wenn wir beispielsweise versuchen, 256 in einem u8 zu speichern, springt der Wert auf 0 zurück. 257 wird zu 1, 258 zu 2, 511 zu 255 und 512 wieder zu 0.

Was ist ein Unterlauf?

Ein Unterlauf tritt auf, wenn der Wert unter dem kleinstmöglichen Wert liegt und auf den Maximalwert springt. Bei einem u8 würde -1 zu 255, -2 zu 254 und so weiter. Kurz gesagt: Ein Unterlauf verhält sich wie ein Überlauf, nur in die entgegengesetzte Richtung.

Rust verfügt über mehrere Prüfungen, die bei einem Über- oder Unterlauf zur Laufzeit eine Programmpanik auslösen. Diese Prüfungen sind jedoch nicht enthalten, wenn du im Release-Modus kompilierst. Und die Toolchain, die ein zentraler Bestandteil der Solana-Entwicklungsumgebung ist, kompiliert Solana-Programme standardmäßig im Release-Modus.

Weitere Informationen zu Über- und Unterläufen und dazu, wie du sie verhinderst, findest du in unserem Leitfaden zur Sicherheit von Solana-Programmen.

Zuerst multiplizieren, dann dividieren

Es fühlt sich oft natürlicher an, vor dem Multiplizieren zu dividieren. Sehen wir uns als Beispiel die Entwicklung eines Prognosemarkts an. Wenn ein Nutzer auf ein Ergebnis setzt, das eintritt, müssen wir die Gewinnauszahlung berechnen. Gewinner auf Prognosemärkten werden entsprechend ihrem Anteil an den Einsätzen auf das richtige Ergebnis ausgezahlt. Gedanklich lässt sich das sehr einfach so darstellen:

Gewinnpool ÷ Gesamteinsätze auf das richtige Ergebnis × Einsatzbetrag

Das wirkt sehr natürlich.

Zuerst teilen wir den Gewinnpool in kleine Teile auf, die jeweils einem „Anteil“ am Gewinn entsprechen. Dann berechnen wir, wie viele Anteile jede Person erhält.

Wenn wir jedoch zuerst multiplizieren, erhalten wir ein größeres Zwischenergebnis. Dadurch verringern wir die Auswirkungen von Rundungsfehlern bei der anschließenden Division.

Gewinnpool × Einsatzbetrag ÷ Gesamteinsätze auf das richtige Ergebnis

Beispiel eines Prognosemarkts

Hier ist eine kurze Demonstration. Würden Menschen statt Computer die Rechnung ausführen, wäre das richtige Ergebnis 10,5.

Versuchen wir zuerst zu dividieren:

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

Das ergibt folgendes Ergebnis:

Programm protokollierte: "7 / 2 * 3 = 9"

Durch die Division zu Beginn verlieren wir 1,5 Untereinheiten.

Was passiert, wenn wir zuerst multiplizieren?

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

Das ergibt folgendes Ergebnis:

Programm protokollierte: "7 * 3 / 2 = 10"

Wenn wir zuerst multiplizieren, beträgt der Rundungsverlust nur 0,5 Untereinheiten.

Dieses Konzept lässt sich allgemeiner formulieren: „Führe zuerst alle Operationen aus, die die Größenordnung erhöhen.“

Wenn du beispielsweise einen komplexeren Ausdruck mit Potenzen oder Wurzeln berechnest, etwa bei einer Risikoberechnung, berechne zuerst die Potenzen und später die Wurzeln. Bei Festkommaarithmetik kann eine Division zu Beginn zu Präzisionsverlusten führen, wenn der Quotient vor der Multiplikation abgerundet wird.

Einheitliche Rundungsregeln verwenden

Uneinheitliche Rundungsregeln können kleine Abweichungen bei einzelnen Token erzeugen. Dadurch können Token scheinbar verschwinden oder aus dem Nichts entstehen. Mit der Zeit kann dies die Liquidität eines Programms aufzehren oder zu einer unbeabsichtigten Token-Inflation führen.

Wie Will Thieme von Orca in seinem Breakpoint-Vortrag über das Umgehen häufiger Fallstricke in Solana-Programmen erklärte, wirkt der Gewinn eines einzelnen Tokens nicht besonders groß.

Solana verarbeitet jedoch echte Transaktionen – im Sinne des traditionellen Finanzwesens – mit mehreren Instructions und niedrigen Transaktionsgebühren. Daher ist es möglich, eine Transaktion mit vielen einzelnen Instructions zu füllen, die Off-by-one-Fehler ausnutzen, und sie dann sehr günstig auszuführen.

Rundungsfehler sind unvermeidbar, lassen sich aber mit einheitlichen Rundungsregeln kontrollieren. Lege im Voraus fest, ob du auf- oder abrundest und wie du „halbe“ Werte, also 0,5, behandelst. Kaufmännisches Runden ist weiter verbreitet und in einigen Finanzstandards vorgeschrieben. Entscheidend ist, dass du diese Regeln im gesamten Code einheitlich anwendest.

Rust-spezifische Besonderheiten beim Runden

Entwickler sollten mehrere potenziell problematische Rust-spezifische Funktionen kennen, da Rundungsoperationen häufig Präzisionsverluste verursachen. Die Wahl der Rundungsmethode kann die Genauigkeit und das Verhalten deines Programms erheblich beeinflussen.

Runden oder Abrunden

Die Funktion try_round_u64() rundet beispielsweise auf die nächste ganze Zahl. Wenn wir ein Programm entwickeln, das Sicherheiten in Liquidität umwandelt, könnte das Aufrunden dazu führen, dass mehr Liquiditäts-Token geprägt werden, als durch die bereitgestellten Sicherheiten gerechtfertigt sind. Stattdessen sollten wir mit der Funktion try_floor_u64() auf die nächstkleinere ganze Zahl abrunden.

Sättigende arithmetische Funktionen

Entwickler verwenden außerdem häufig arithmetische saturating_*-Funktionen wie saturating_add, um Werte auf ihr jeweiliges Maximum oder Minimum zu begrenzen und so Über- und Unterläufe zu verhindern. Diese Funktionen können jedoch zu schwer erkennbaren Präzisionsverlusten führen.

Wenn deine Funktion beispielsweise einen Transaktionsbetrag mit einem Belohnungsmultiplikator multipliziert und das Produkt den Maximalwert seines Variablentyps überschreitet, erhält dein Nutzer eine zu geringe Belohnung.

Das solltest du unbedingt berücksichtigen, insbesondere weil dein Programm Festkommaarithmetik verwenden sollte.

Zinsen ohne Gleitkommazahlen berechnen

Eine häufig verwendete Methode zur Berechnung von Zinseszinsen nutzt die Eulersche Zahl e. Doch e ist eine Gleitkommazahl! Vermeide Gleitkommazahlen bei Zinsberechnungen. Verwende stattdessen Festkommaarithmetik.

Die Solana-Bibliothek spl-math enthält PreciseNumber. Wie du dir denken kannst, funktioniert dieser Typ in der stärker eingeschränkten Rust-Umgebung von Solana und kann winzige Dezimalbrüche mit bis zu zwölf Dezimalstellen exakt darstellen.

Code
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()
}

Wenn du den Code ausführst, siehst du die Unterschiede:

  • Programm protokollierte: "Verwendung von spl-math PreciseNumber"
  • Programm protokollierte: "Investition von $1000 zu 5 % über 5 Jahre, mit 1 Verzinsung(en) pro Jahr:"
  • Programm protokollierte: "Endbetrag: $1276"

Im Vergleich dazu bei der Verwendung von e:

  • Programm protokollierte: " Verwendung von e (tu das nicht)"
  • Programm protokollierte: "Investition von $1000 zu 5 % über 5 Jahre, mit 1 Verzinsung(en) pro Jahr:"
  • Programm protokollierte: "Endbetrag: $1284"

Fazit

Token-Mathematik ist ein langweiliges Thema. Wenn du sie jedoch vernachlässigst, kann sie für eine Art von Spannung sorgen, die du nicht erleben möchtest. Deine On-Chain-Apps sollten Token mit der Präzision und Sicherheit verarbeiten, die Nutzer erwarten.

In diesem Artikel haben wir behandelt, wie du Ganzzahlen und Untereinheiten verwendest, zuerst multiplizierst, Präzisionsverluste vermeidest, einheitliche Rundungsregeln beibehältst und Zinsen ohne Gleitkommazahlen berechnest.

Nachdem du diese arithmetischen Grundlagen gelernt hast, solltest du deinen Code vor dem Start im Mainnet von jemand anderem prüfen lassen – insbesondere von einem auf Solana spezialisierten Audit-Unternehmen.

Weitere Ressourcen

Helius abonnieren

Bleib bei der Solana-Entwicklung auf dem Laufenden und erhalte Updates, wenn wir neue Beiträge veröffentlichen