solana arithmetic — how to do math with solana programs or smart contracts
/Fundamentals

Solana Arithmetic: Best Practices for Building Financial Apps

9 min read

Thanks to 0xIchigo and Lostin for editing and contributing to this article.

Solana is a competitive space—whether you’re working on a new lending protocol, aggregator, prediction market, RWA tokenization, or anything else, it can be tempting to rush to mainnet.

However, it’s crucial to remember that blockchain applications, due to their management of value, are inherently financial applications.

If you haven’t built financial apps before—either on a blockchain or in traditional finance—you should know the importance of financial math.

There are many excellent resources on Solana-specific programming topics—checking what accounts need to sign each instruction, the owning programs of the accounts you use, avoiding account reopening attacks, and so on. Anchor’s account constraints make implementing many of these checks more manageable, and Rust has some smart defaults, like catching integer overflows and underflows in Debug mode.

But there’s more to safe financial programming than Solana-specific topics. A single error in token arithmetic can lead to leaks, unintended inflation and angry users. In Solana, where transaction volumes are higher than other blockchains, holes can be exploited even faster.

Many financial programming techniques from traditional finance have nothing to do with blockchain—so they don’t get the attention they deserve—but are still vital to protect your users' tokens.

In this article, we will specifically be covering:

  • The use of integers and minor units
  • Avoiding precision loss — multiply then divide
  • Consistent rounding policies
  • Use of float-free interest calculations

Use Integers and Minor Units

Lack of precision is a common vulnerability in smart contracts. When mathematical operations are not precise, they can be vulnerable. We need to use integers and minor units to perform safe mathematical operations in Solana applications.

Let’s look at a basic example.

In traditional finance, every interaction you’ve ever done ‘in USD’ was actually done in cents. If you were using GBP, it was done in pence. 

Likewise, your transactions in SOL should be handled in lamports, and your transactions in USDC should be handled in millionths of USDC.

Cents, pence, and lamports are all types of minor units (also called base units), and the reason for doing everything in minor units is simple: computers can’t handle floating point numbers.

Here’s one in binary:

Thirty Twos

Sixteens

Eights

Fours

Twos

Ones

0

0

0

0

0

1

Here’s nine in binary:

Thirty Twos

Sixteens

Eights

Fours

Twos

Ones

0

0

1

0

0

1

But how would you represent, say, 0.3 USDC? 

The correct answer is: you can’t: 

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

Gives this result:

> Program logged: "0.1 + 0.2 = 0.30000000000000004"

Instead, treat dollars, GBP, SOL, USDC and every other ‘currency’ as a whole amount of its minor unit.

For example, to add 0.1 and 0.2 using USDC:

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

Gives this result:

> Program logged: "100000 + 200000 = 300000"

Using minor units instead causes no loss.

Developers should use a token's decimal places while remembering not all tokens have the same number of decimals.

Using integers for token amounts may seem obvious, but also consider that you need to use integers everywhere, not just for token amounts.

Percentages

Percentages should be in whole numbers. Most people use basis points (sometimes called ‘bips’), which are represented as bps.

For example, 4.74% is 474 bps.

Compound Interest

Compound interest should not be calculated using e (Euler's number), which represents the limit of compound interest as compounding frequency approaches infinity.

While using e allows for ‘continuous compounding,’ essentially a smooth line for compound interest, e itself is represented as a float in Rust.

You’ll get both rounding errors and a different result when using e versus other mechanisms.

We’ll demonstrate both of these later in this article.

Overflows and Underflows

Rust stores integers as fixed-sized variables. This means that, depending on whether the variable is signed or unsigned, it can only occupy a certain amount of space in memory.

For example, the u8 type can hold any value from 0 to 255. However, if we store a value outside of that range, we’d have an overflow or an underflow.

What is an overflow?

An overflow is when the value exceeds the maximum capacity that variable type can store, so it wraps around to the minimum value.

For example, if we tried storing 256 in a u8, we would wrap around to 0. 257 would wrap to 1, 258 would wrap to 2, 511 would wrap to 255, and 512 would wrap to 0 again.

What is an underflow?

An underflow is when the value is below the minimum possible value and wraps around to the maximum value. For a u8, -1 would wrap to 255, -2 would wrap to 254, and so forth. In short, an underflow is like an overflow, except the behavior happens in the opposite direction.

While Rust has a number of checks that will cause a program to panic at runtime in the event of an overflow or underflow, it does not include these checks when you compile in release mode. And, the toolchain integral to Solana’s development environment, compiles Solana programs in release mode by default

To learn more about overflows and underflows, as well as how to mitigate them, check out our solana program security guide.

Multiply, then Divide

It often feels natural to divide before multiplying—let’s take the example of building a prediction market. When a user bets on an outcome that wins, we must calculate a winning payment. Winners in prediction markets are paid based on their portion of the bets on the winning outcome. Mentally, this maps very neatly to:

win pool ÷ total bets on the winning outcome × bet amount 

This seems very natural.

We first divide the win pool into small pieces representing 1 ‘share’ of the winnings, then work out how many shares to give people.

But if we multiply first, we get a larger intermediate result. This allows us to reduce the impact of rounding errors from the subsequent division. 

win pool × bet amount ÷ total bets on the winning outcome

Prediction Market Example

Here’s a quick demo. If we were doing this as people, not computers, the right answer would be 10.5.

Let’s try dividing first: 

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

Gives this result:

> Program logged: "7 / 2 * 3 = 9"

By dividing first, we’ll lose 1.5 minor units.

What if we multiply first?

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

Gives this result:

> Program logged: "7 * 3 / 2 = 10"

When multiplying first we only have a 0.5 minor unit rounding loss. 

This concept can be expanded more broadly to mean ‘run anything that increases the order of magnitude first.’ 

So, for example, if you’re computing a more complicated expression that uses powers or roots (like calculating risk), run the powers first and roots later. This is because with fixed-point arithmetic, performing division first can result in precision loss if the quotient is rounded down before being multiplied.

Use a Consistent Rounding Policy

An inconsistent rounding policy, creating small differences between a single token, can make tokens seem to be lost or created out of thin air. Over time, this can drain a program’s liquidity or lead to unintended token inflation.

As Will Thieme from Orca pointed out in his Breakpoint talk on Navigating Common Pitfalls in Solana Programs, gaining one token doesn’t seem like a lot.

However, Solana has real (in the traditional finance sense) transactions that have multiple instructions and low transaction fees. So it’s possible to stuff a transaction with many individual instructions that exploit off-by-one errors and then run it very cheaply.

Rounding errors are inevitable but can be managed with a consistent rounding policy. Decide upfront whether you'll round up or down and how you handle ‘half’ (i.e., 0.5)—half up is more common and mandated in some financial standards. The most important thing is you apply that policy consistently throughout your code.

Rust-Specific Rounding Caveats

Developers should be aware of several potentially problematic Rust-specific functions, as rounding operations are a common loss of precision. The choice of rounding method can significantly impact the accuracy and behavior of your program.

Round vs. Floor

For example, the try_round_u64() function rounds to the nearest whole number. If we wanted to build a program that converts collateral into liquidity, rounding up could lead to minting more liquidity tokens than justified by the collateral provided. Instead, we should use the try_floor_u64() function to round down to the nearest whole number.

Saturating Arithmetic Functions

Moreover, developers often use saturating_* arithmetic functions (e.g., saturating_add) to cap values at their respective maximums and minimums to prevent overflows and underflows. However, these functions can lead to subtle losses in precision.

For example, if your function multiplies a transaction amount with a reward multiplier that exceeds the maximum value of the variable type of the product, you’ll be under-rewarding your user.

It’s essential to keep this in mind, especially since your program should be using fixed-point arithmetic.

Use Float-Free Interest Calculations

A common technique for calculating compound interest is using Euler’s number e, but e is a float! Avoid using floats for interest calculations. Instead, use fixed-point arithmetic.

Solana’s spl-math library has PreciseNumber, which (as you can imagine) works on Solana’s more limited Rust environment and can represent tiny decimal fractions (up to 12 decimal places) while maintaining exact precision.

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

You can see the differences by running the code:

  • Program logged: "Using spl-math PreciseNumber"
  • Program logged: "Investment of $1000 at 5% for 5 years, compounded 1 time(s) per year:"
  • Program logged: "Final amount: $1276"

Versus, when using e:

  • Program logged: " Using e (don't do this)"
  • Program logged: "Investment of $1000 at 5% for 5 years, compounded 1 time(s) per year:"
  • Program logged: "Final amount: $1284"

Conclusion

Token math is a boring topic, but not paying attention to it can lead to the kind of excitement you don’t want. Your on-chain apps should handle tokens with the precision and security that users demand.

In this article, we covered using integers and minor units, multiplying, avoiding precision loss, maintaining a consistent rounding policy, and using float-free interest in calculations.

After you’ve learned these arithmetic fundamentals, get someone else—specifically a Solana-focused auditing company—to review your code before launching on mainnet.

Additional Resources

Related Articles

Subscribe to Helius

Stay up-to-date with the latest in Solana development and receive updates when we post