Decimals, Precision, and Validation: A Teardown of a Solana Stablecoin Engine

A stablecoin program is a measurement instrument. It promises that one unit in equals a known unit out, and almost every way it breaks is a measurement error: a conversion scaled the wrong way, a division taken before a multiplication, an integer too small to hold the intermediate, or an input nobody checked. This is a teardown of those failures in a real Solana stablecoin engine, with the actual vulnerable code and the fixes, so you can read your own mint, burn, and swap paths the same way I read theirs.

The material comes from a review I led for Cantina on Perena Prime, a USD-Star and USD-Prime stablecoin system. The review surfaced 20 issues, four of them critical, and the striking part is how few were exotic. Most were arithmetic and validation, the two things a stablecoin cannot get wrong. By the end you will have a concrete checklist for the whole family.

Diagram of the mint and burn decimal conversion pipeline showing where wrong-direction scaling silently corrupts the output token amount

I open every mint and burn path the same way, by asking one question before I read any business logic: what are the units at each step, and where do they change. On Solana that question has teeth, because amounts are raw integers and the decimal count lives off to the side on the mint.

The decimal contract is the whole game

A token amount on Solana is an integer with no inherent scale. A balance of 1_000_000 means one dollar at six decimals and one-thousandth of a dollar at nine. The decimals live on the mint, not on the amount, so any time a program moves value between two tokens with different decimal counts it has to rescale, and the direction of that rescale is load-bearing.

Perena got the direction backwards. In the USD-Star mint and burn path, the code scaled the amount by the difference between the asset decimals and the stablecoin decimals, but it multiplied where it should have divided and divided where it should have multiplied.

// VULNERABLE: scaling applied in the wrong direction
if asset_info.decimals > usd_star_decimal {
    amount = amount.checked_mul(           // BUG: more asset decimals means SCALE DOWN, not up
        10u128.checked_pow((asset_info.decimals - usd_star_decimal) as u32)
            .ok_or(ProgramError::ArithmeticOverflow)?,
    ).ok_or(ProgramError::ArithmeticOverflow)?;
} else if asset_info.decimals < usd_star_decimal {
    amount = amount.checked_div(           // BUG: fewer asset decimals means SCALE UP, not down
        10u128.checked_pow((usd_star_decimal - asset_info.decimals) as u32)
            .ok_or(ProgramError::ArithmeticOverflow)?,
    ).ok_or(ProgramError::ArithmeticOverflow)?;
}

When the asset carries more decimals than the stablecoin, each stablecoin unit is worth more raw asset units, so converting an asset amount into stablecoin terms means dividing it down. The vulnerable code multiplied, inflating the result by a factor of ten per extra decimal. A nine-decimal asset against a six-decimal stablecoin is off by a thousand times. The fix is to swap the two operations so the larger-decimal side divides and the smaller-decimal side multiplies.

// FIXED: direction matches the decimal relationship
if asset_info.decimals > usd_star_decimal {
    amount = amount.checked_div(
        10u128.checked_pow((asset_info.decimals - usd_star_decimal) as u32)
            .ok_or(ProgramError::ArithmeticOverflow)?,
    ).ok_or(ProgramError::ArithmeticOverflow)?;
} else if asset_info.decimals < usd_star_decimal {
    amount = amount.checked_mul(
        10u128.checked_pow((usd_star_decimal - asset_info.decimals) as u32)
            .ok_or(ProgramError::ArithmeticOverflow)?,
    ).ok_or(ProgramError::ArithmeticOverflow)?;
}

Put real numbers on it. A user deposits one whole unit of a nine-decimal asset, a raw amount of 1_000_000_000, into a six-decimal stablecoin. The correct result divides by ten cubed to land at 1_000_000, one stablecoin unit, a fair one-for-one mint. The vulnerable code multiplied by ten cubed instead and returned 1_000_000_000_000, crediting the depositor a thousand stablecoins for one unit of asset. The mirror case, a low-decimal asset against a high-decimal stablecoin, divides where it should multiply and mints a user almost nothing for a real deposit. One direction drains the protocol, the other robs the user, and a single wrong comparison branch decides which.

The reason this ships is that it is invisible when the two decimals are equal, which is the case in most local tests against a single six-decimal token. The bug only wakes up when a real asset with a different decimal count arrives. A sibling finding in the USD-Prime path was worse, because it skipped the asset-versus-stablecoin rescale entirely and divided only by the price, leaving the result in the wrong decimal base and handing users a fraction of what they were owed.

Takeaway: in any cross-token amount, write down the unit at each line, and prove that more decimals on a side divides and fewer decimals multiplies before you trust a single number.

Order of operations: divide before multiply and you round to zero

Integer arithmetic has no fractions. Every division throws away the remainder, so the order in which you divide and multiply decides whether you keep precision or destroy it. Divide first and a small numerator collapses to zero before the multiply ever runs.

The USD-Prime burn path computed the asset payout by dividing the amount by the price and then multiplying by ten to the exponent.

// VULNERABLE: divide first, so small values floor to zero
amount = amount
    .checked_div(asset_price.price as u64)      // e.g. 99e6 / 1e8 = 0
    .ok_or(ProgramError::ArithmeticOverflow)?;
amount = amount
    .checked_mul(
        10u64.checked_pow(asset_price.exponent.unsigned_abs())
            .ok_or(ProgramError::ArithmeticOverflow)?,
    )                                            // 0 * 1e8 = 0
    .ok_or(ProgramError::ArithmeticOverflow)?;

Walk the numbers. A Pyth price with an exponent of negative eight carries a raw price near 1e8. A user burning ninety-nine USD-Prime at six decimals brings an amount near 99e6. The first line computes 99e6 / 1e8, which is zero in integer math, and the second line multiplies zero by 1e8 and stays zero. The program then transfers zero asset to the user and burns their USD-Prime anyway. The user pays and receives nothing. The fix is to multiply into the larger base first, then divide.

// FIXED: multiply first to preserve precision, then divide
amount = amount
    .checked_mul(
        10u64.checked_pow(asset_price.exponent.unsigned_abs())
            .ok_or(ProgramError::ArithmeticOverflow)?,
    )
    .ok_or(ProgramError::ArithmeticOverflow)?;
amount = amount
    .checked_div(asset_price.price as u64)
    .ok_or(ProgramError::ArithmeticOverflow)?;

This is the quietest critical I find, because every happy-path test passes. When the test amount is large relative to the price, the division leaves a sane number and nobody notices the floor waiting at the small end. The asymmetry is the bug, and symmetric tests never reach it. I have flagged the same divide-before-multiply shape across unrelated Solana programs, and it is almost always paired with a test suite that only ever burns round, large amounts.

Takeaway: multiply before you divide, and write at least one test with a tiny numerator against a large price, because that is the only test that exercises the floor.

Integer width: the intermediate is bigger than you think

Multiplying first fixes precision but introduces the opposite risk, because the intermediate product can outgrow the type that holds it. A u64 stops near 1.8e19, while a u128 reaches about 3.4e38, a gap documented in the Rust primitive types. A stablecoin multiplying an amount by a price routinely produces an intermediate that fits in neither your intuition nor a u64.

Number line diagram comparing the u64 and u128 maximum ranges and marking where an amount times price overflows before the divide brings it back down

The USD-Prime mint path multiplied the asset amount by the price inside a u64 before dividing back down by the exponent.

// VULNERABLE: u64 multiply overflows before the divide can shrink it
let mut amount: u64 = instruction_data
    .asset_amount
    .checked_mul(asset_price.price as u64)   // 1e4 * 1e10 overflows realistic inputs
    .ok_or(ProgramError::ArithmeticOverflow)?;
amount = amount
    .checked_div(
        10u64.checked_pow(asset_price.exponent.unsigned_abs())
            .ok_or(ProgramError::ArithmeticOverflow)?,
    )
    .ok_or(ProgramError::ArithmeticOverflow)?;

A normal token amount near 1e9 times a price feed scaled to eight decimals lands the intermediate well past 1e19, so checked_mul returns None and the instruction reverts. Because the checked call fails closed, this is a denial of service rather than a fund loss, but it bricks minting under ordinary conditions. The fix is to do the wide arithmetic in u128, divide while still wide, and only then narrow back to u64 with a fallible conversion that rejects anything that does not fit.

// FIXED: widen for the multiply and divide, then narrow safely
let amount: u128 = (instruction_data.asset_amount as u128)
    .checked_mul(asset_price.price as u128)
    .ok_or(ProgramError::ArithmeticOverflow)?;
let divisor: u128 = 10u128
    .checked_pow(asset_price.exponent.unsigned_abs() as u32)
    .ok_or(ProgramError::ArithmeticOverflow)?;
let amount: u64 = amount
    .checked_div(divisor)
    .ok_or(ProgramError::ArithmeticOverflow)?
    .try_into()                              // reject values that do not fit u64
    .map_err(|_| ProgramError::ArithmeticOverflow)?;

The same engine carried a second width bug in a supply helper that multiplied a token supply by a fixed denominator to normalize decimals. With six-decimal tokens, the product crossed the u64 ceiling near nineteen million units of supply, and the live token was already past fourteen million. That is not a hypothetical edge, it is a deadline. The cleanest fix there was to delete the normalization the callers never needed, which is its own lesson: arithmetic you do not need is still a liability you carry.

Takeaway: do the multiply and divide in u128, narrow with try_into and a real error, and ask whether each intermediate has a maximum that production will reach.

Account confusion: the program trusts the account list you hand it

Arithmetic aside, the deepest critical was a validation gap. The Solana runtime hands a program the list of accounts the caller chose and trusts the program to confirm each one is what it claims, a model spelled out in the Solana account documentation. Skip a check and the caller substitutes an account of their choosing.

Step trace diagram of the swap self-transfer attack where the attacker passes the reserve as their own account and drains the output reserve without depositing any input

The swap path took user accounts without confirming they belonged to the user. An attacker could pass the bank reserve as both the source of the input transfer and its destination, so the first transfer moved tokens from the reserve back to the reserve, a no-op, while the second transfer still paid out real output tokens. No input arrived, output left, and the call could repeat until the reserve was empty.

// FIXED: prove the accounts belong to the caller and are distinct
require!(user_info.is_signer, CustomError::MissingSigner);
require_keys_eq!(input_user_ata.owner, user_info.key(), CustomError::BadOwner);
require_keys_eq!(output_user_ata.owner, user_info.key(), CustomError::BadOwner);
require_keys_neq!(input_user_ata.key(), input_reserve_info.key(), CustomError::SelfTransfer);
require_keys_neq!(output_user_ata.key(), output_reserve_info.key(), CustomError::SelfTransfer);

A second finding in the same family let an attacker mint unlimited USD-Prime. The unstake path never checked that the USD-Star mint passed in was the real one, so an attacker minted a worthless token they controlled, presented it as USD-Star, and the program burned the trash and minted genuine USD-Prime against it. This is account confusion, the same class that sits behind some of the largest Solana losses on record, including the Cashio drain where a missing ownership check cost tens of millions. The discipline that prevents it is documented well in the Helius program-security guide: check the owner, check the key against the expected address, and check the type.

Anchor encodes most of this into account constraints and an eight-byte type discriminator, which is exactly why raw-account code that opts out of those constraints is where confusion creeps back in. This engine used native account handling across cross-program invocations, so every check it skipped became attacker input.

Takeaway: for every account in a handler, prove three things before you act on it, that it is the right owner, the right key, and the right type, and prove that accounts which must differ actually do.

Token-2022: the amount you ask for is not the amount you get

The protocol assumed a transfer of one hundred tokens delivered one hundred tokens. Token-2022 breaks that assumption. Its token extensions let a mint change transfer behavior, and the transfer fee extension withholds a percentage on every transfer, so a mint configured with a two percent fee delivers ninety-eight when the program recorded one hundred. A stablecoin that mints liabilities against the number it asked for, rather than the number that arrived, is now under-collateralized by the fee on every operation.

The engine accepted any Token-2022 mint without inspecting its extensions. The narrow fix is to allow only extensions the protocol understands and reject the rest.

// FIXED: allow only extensions the protocol can reason about
pub fn is_supported_mint(mint_account: &InterfaceAccount<Mint>) -> bool {
    let mint_info = mint_account.to_account_info();
    let mint_data = mint_info.data.borrow();
    let mint = match StateWithExtensions::<spl_token_2022::state::Mint>::unpack(&mint_data) {
        Ok(m) => m,
        Err(_) => return false,
    };
    for e in mint.get_extension_types().unwrap_or_default() {
        if e != ExtensionType::MetadataPointer {   // extend this allowlist deliberately
            return false;
        }
    }
    true
}

An allowlist is the safe default because it fails closed against extensions that did not exist when you wrote the code. The stronger version, when you do want to support fee-bearing tokens, is to stop trusting the requested amount at all and measure the real delta: read the destination balance before and after the transfer and credit the difference. Either way, the rule is the same, never let the number a user typed stand in for the number the token actually moved.

Takeaway: treat every Token-2022 mint as hostile until its extensions are on an allowlist, and where you can, account for the measured balance change rather than the requested amount.

External signals: oracles and accounts lie by omission

The last family is trust in things outside the program. Two findings here are worth carrying. The first is the oracle. The engine read a Pyth price and checked only that it was not stale, ignoring the confidence interval that Pyth publishes alongside every price. Pyth models each price as a distribution with a confidence band, and its best-practices guide is explicit that consumers should reject prices when the confidence is too wide relative to the price, because a wide band means the market itself does not agree on a value.

// FIXED: reject prices whose confidence band is too wide to trust
let price = price_update.get_price_no_older_than(&Clock::get()?, max_age, &feed_id)?;
let confidence_pct = (price.conf as u128)
    .checked_mul(100)
    .ok_or(ProgramError::ArithmeticOverflow)?
    .checked_div((price.price as i128).unsigned_abs())
    .ok_or(ProgramError::ArithmeticOverflow)?;
require!(confidence_pct <= max_confidence_pct as u128, CustomError::PriceConfidenceTooHigh);

The second is subtler and very Solana. To decide whether it needed to create a user token account, the program checked whether the account held zero lamports. An associated token account holding lamports is not the same as an initialized token account, so an attacker could send a single lamport to the address, skip creation, and force the later checked mint to fail against an uninitialized account, a denial of service. The correct primitive is to call create_associated_token_account_idempotent, which creates the account if missing and does nothing if it already exists, rather than inferring initialization from a balance.

Takeaway: never infer a thing you can verify, validate oracle confidence not just staleness, and use idempotent account creation instead of guessing from lamports.

The right number from the wrong variable

Not every bug is a missing check or a botched conversion. Some are the correct calculation reading the wrong input, and those are the hardest to catch by eye because the code looks deliberate. The stake path converted USD-Prime into USD-Star and applied a fee, but it read the fee rate for the token being minted instead of the token being burned.

// VULNERABLE: converting Prime to Star, but charging the Star fee rate
amount = instruction_data.star_amount
    .checked_mul(FEE_RATE_SCALE - bank.star_fee_rate)   // BUG: should be bank.prime_fee_rate
    .ok_or(ProgramError::ArithmeticOverflow)?
    .checked_div(FEE_RATE_SCALE)
    .ok_or(ProgramError::ArithmeticOverflow)?;

The operation burns USD-Prime, so the fee that applies is the Prime fee, not the Star fee. When the two rates differ, value moves to the wrong side: a higher Prime rate hands users more than they earned and the protocol eats the gap, a lower one shortchanges users. Nothing reverts, no number overflows, and every test that uses equal fee rates passes silently, which is why a reviewer has to trace the economic intent of each variable, not just its type.

This finding is also a useful reminder that a review is a conversation, not a verdict. The team responded that using the same rate for both directions was intentional, and the issue was marked acknowledged rather than fixed. That is a legitimate outcome. My job is to surface that the code charges an asymmetric fee and make sure the choice is deliberate, not accidental. The danger is the silent version where nobody decided.

Takeaway: when a calculation reads a configured parameter, confirm it reads the one that matches the economic action, and treat any pair of near-identical variables, prime versus star, input versus output, as a place a mix-up hides.

Where this generalizes

Every finding above reduces to one of two disciplines. The arithmetic bugs say: a stablecoin is a unit converter, so name the units at each step, multiply before you divide, do wide math in u128 and narrow with a checked conversion, and test the small and large ends, not just the middle. The validation bugs say: the program is the only thing standing between an attacker-chosen account list and your reserves, so prove owner, key, and type for every account, distrust the requested amount when Token-2022 is in play, and validate external signals on their own terms.

When I audit a new mint, burn, or swap path, I run exactly that checklist. Where do units change, and is the direction and order correct. What is the largest intermediate, and does the type hold it. For each account, what stops a stranger from substituting it. Does the token move the amount I recorded. Does the oracle agree with itself. A stablecoin that answers those cleanly has closed the families that drained this one, and the few that remain are the interesting part of the job.

References

  1. Cantina: Securing Solana, a developer's guide
  2. Solana docs: Accounts
  3. Solana docs: Cross-Program Invocation
  4. Anchor: Account constraints
  5. Neodyme: Solana smart contracts, common pitfalls
  6. Helius: A hitchhiker's guide to Solana program security
  7. Solana docs: Token-2022 extensions
  8. Solana docs: Token-2022 transfer fees
  9. Pyth: Price-feed best practices
  10. Rust: u128 primitive type
  11. SPL: Associated Token Account program