How Dimensional Analysis Catches DeFi Bugs Before Deployment

Dimensional Analysis

Most DeFi exploits share a root cause: arithmetic that looks correct but mixes incompatible quantities.

A price where a balance should be. Shares multiplied by shares when you needed shares multiplied by a ratio.

These bugs survive code review, pass unit tests, and detonate in production.

Dimensional analysis a technique every physics student learns in week one catches them at design time, before a single line of code compiles.

Trail of Bits published a detailed treatment of this approach applied to DeFi contracts. This tutorial shows you how to use it.


What Is Dimensional Analysis?

In physics, every quantity carries a dimension. Velocity is meters per second. Force is kilograms × meters per second². You cannot add a velocity to a force, the dimensions don’t match.

The rules are simple:

  1. Both sides of an equation must have the same dimension.
  2. You cannot add or subtract quantities with different dimensions.
  3. Multiplication and division create new compound dimensions.

If an equation violates these rules, the equation is wrong, regardless of what the numbers say.

DeFi Has Dimensions Too

Smart contracts don’t deal with meters and seconds. They deal with tokens, prices, shares, and time. Each is a distinct dimension:

QuantityDimensionExample
Token A balance[A]1000 USDC
Token B balance[B]0.5 ETH
Price of B in A[A]/[B]2000 USDC/ETH
LP shares[shares]500 LP tokens
Liquidity (AMM)√([A]·[B])Uniswap L
Timestamp[time]block.timestamp
Interest rate[1/time]5% per year

Once you assign dimensions to every variable, you can audit formulas without running any code. If the dimensions on the left side of an assignment don’t match the right side, you have a bug.

Worked Example: Solidity Price Calculation Bug

Here is a simplified AMM contract with a subtle pricing bug. It compiles, it passes basic tests, and it will lose user funds.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract BuggyAMM {
    uint256 public reserveA;  // dimension: [A]
    uint256 public reserveB;  // dimension: [B]

    function getSpotPrice() public view returns (uint256) {
        // Intent: return price of B in terms of A → should be [A]/[B]
        uint256 liquidity = sqrt(reserveA * reserveB);
        // liquidity dimension: √([A]·[B])

        return (reserveA * 1e18) / liquidity;
        // dimension: [A] / √([A]·[B])
        //          = [A] / √[A]·√[B]
        //          = √[A] / √[B]
        //          = √([A]/[B])   ← WRONG! This is the square root of a price
    }

    function sqrt(uint256 x) internal pure returns (uint256 y) {
        y = x;
        uint256 z = (x + 1) / 2;
        while (z < y) { y = z; z = (x / z + z) / 2; }
    }
}

Applying Dimensional Analysis

Walk through each variable:

ExpressionDimensionCorrect?
reserveA[A]
reserveB[B]
reserveA * reserveB[A]·[B]
liquidity = sqrt(...)√([A]·[B])
reserveA / liquidity[A] / √([A]·[B]) = √([A]/[B])Mismatch: expected [A]/[B]

The function returns the square root of the price, not the price. When ETH is 2000 USDC, this function returns ~44.7 instead of 2000. Trail of Bits found this exact class of bug during real audits, the CAP Labs vulnerability was a similar dimensional mismatch where a decimal count was passed to a function expecting an asset amount.

The Fix

function getSpotPrice() public view returns (uint256) {
    // reserveA / reserveB → [A]/[B] ✓
    return (reserveA * 1e18) / reserveB;
}

Dimensions: [A] / [B] = [A] / [B]

Worked Example: Rust/Soroban Vault Bug

The same technique works in any language. Here is a Soroban vault contract (Stellar’s smart contract platform) with a share-calculation bug:

use soroban_sdk::{contract, contractimpl, Address, Env};

#[contract]
pub struct BuggyVault;

// Dimensions:
//   deposited assets → [asset]
//   vault shares     → [share]
//   total_assets()   → [asset]
//   total_supply()   → [share]

#[contractimpl]
impl BuggyVault {
    /// Deposit assets, receive shares
    pub fn deposit(env: Env, depositor: Address, assets: i128) -> i128 {
        let total_assets = Self::total_assets(&env);   // [asset]
        let total_shares = Self::total_supply(&env);    // [share]

        if total_shares == 0 {
            return assets; // first depositor gets 1:1
        }

        // BUG: shares = assets * total_assets / total_shares
        // dimension: [asset] * [asset] / [share] = [asset]²/[share] ← WRONG
        let shares = assets * total_assets / total_shares;

        Self::mint_shares(&env, &depositor, shares);
        Self::transfer_assets_in(&env, &depositor, assets);
        shares
    }

    /// Withdraw assets by burning shares
    pub fn withdraw(env: Env, owner: Address, shares: i128) -> i128 {
        let total_assets = Self::total_assets(&env);   // [asset]
        let total_shares = Self::total_supply(&env);    // [share]

        // CORRECT: assets = shares * total_assets / total_shares
        // dimension: [share] * [asset] / [share] = [asset] ✓
        let assets = shares * total_assets / total_shares;

        Self::burn_shares(&env, &owner, shares);
        Self::transfer_assets_out(&env, &owner, assets);
        assets
    }

    fn total_assets(env: &Env) -> i128 { /* ... */ 1000 }
    fn total_supply(env: &Env) -> i128 { /* ... */ 500 }
    fn mint_shares(_env: &Env, _to: &Address, _amount: i128) {}
    fn burn_shares(_env: &Env, _from: &Address, _amount: i128) {}
    fn transfer_assets_in(_env: &Env, _from: &Address, _amount: i128) {}
    fn transfer_assets_out(_env: &Env, _to: &Address, _amount: i128) {}
}

Dimensional Analysis on Deposit

The deposit function should return [share]

shares = assets * total_assets / total_shares
       = [asset] * [asset] / [share]
       = [asset]² / [share]  ← NOT [share]

The dimensions are wrong. The withdraw function has the correct pattern, so to fix we can swap the formula:

// CORRECT: shares = assets * total_shares / total_assets
// dimension: [asset] * [share] / [asset] = [share] ✓
let shares = assets * total_shares / total_assets;

This class of bug, swapping numerator and denominator in share-to-asset conversions, is one of the most common vault vulnerabilities. ERC-4626 vaults on Ethereum have had multiple exploits from exactly this pattern.


Dimensional Analysis vs. Fuzzing vs. Formal Verification

These techniques complement each other. They don’t compete.

TechniqueWhat it catchesWhen to useCost
Dimensional analysisUnit mismatches, formula errors, wrong conversionsDesign phase, code review before deploymentNear zero. Paper and pen.
Fuzzing (Foundry, Echidna)Edge cases, overflows, unexpected state transitionsAfter implementation needs running codeMedium. Requires writing invariants and running compute.
Formal verification (Certora, Halmos)Proves properties hold for all inputsCritical contracts with clear invariantsHigh. Requires specification language expertise.

Dimensional analysis is the only technique that works before code exists. You can audit a whitepaper’s formulas. You can check a spec document. You can review a pull request by annotating dimensions in the margin.

Fuzzing finds what dimensional analysis misses: correct formulas with incorrect implementations (off-by-one, rounding, overflow). But fuzzing cannot tell you that a formula is conceptually wrong if it happens to produce plausible values for the fuzzed inputs.

Formal verification proves that code satisfies a specification but if your specification has the wrong formula, the proof will confirm that you’ve correctly implemented the wrong thing.

Use all three:

  1. Dimensional analysis at design time to validate formulas.
  2. Fuzzing during development to find implementation bugs.
  3. Formal verification for deployed high-value contracts to prove correctness guarantees.

Practical Steps You Can Adopt Today

1. Annotate Your Variables

Follow the convention that Reserve Protocol pioneered. Add dimension comments to every state variable, function parameter, and return value:

/// @param amount [asset] The number of underlying tokens to deposit
/// @return shares [share] The number of vault shares minted
function deposit(uint256 amount) external returns (uint256 shares);

For Rust/Soroban:

/// Deposit underlying assets into the vault.
///
/// * `assets` - `[asset]` amount of underlying token
/// * Returns `[share]` amount of vault shares minted
pub fn deposit(env: Env, depositor: Address, assets: i128) -> i128;

2. Check Every Formula on Paper

Before implementing a calculation, write out the dimensions:

// Goal: convert assets to shares
// shares = assets × (total_shares / total_assets)
//        = [asset] × ([share] / [asset])
//        = [share] ✓

If the dimensions don’t cancel to your target dimension, the formula is wrong.

3. Name Variables by What They Represent

Bad naming hides dimensional bugs:

uint256 value = a * b / c;  // what dimension is "value"?

Explicit naming exposes them:

uint256 priceAPerB = reserveA * 1e18 / reserveB; 

4. Treat Scale as a Separate Concern

Dimensions and scale are different. A value can have the correct dimension (

[A]/[B]

) but the wrong scale (18 decimals vs. 27 decimals). Annotate both:

/// @dev D18{UoA/tok} - price in unit-of-account per token, 18-decimal fixed-point
uint256 public price;

Trail of Bits recommends the

D18{dimension}

notation for this reason.

5. Build a Dimension Cheat Sheet for Your Protocol

Before an audit, create a table of every dimension in your protocol. Map each state variable and function return to that table. Hand this to your reviewers, it dramatically reduces review time and catches mismatches that code review alone misses.


Dimensional analysis is free, fast, and catches an entire category of bugs that other tools miss. It works at design time before code exists. It applies equally to Solidity, Rust, or any other language.

The technique is simple: assign a dimension to every quantity, check that every equation balances, and reject any formula where the dimensions don’t match. Trail of Bits is building tooling to automate this, a Claude plugin for dimensional checking and potential Slither-based linting for automatic inference and propagation of units across a codebase.

Start by annotating the most critical functions in your next contract.


Get The Blockchain Sector Newsletter, binge the YouTube channel and connect with me on Twitter

The Blockchain Sector newsletter goes out a few times a month when there is breaking news or interesting developments to discuss. All the content I produce is free, if you’d like to help please share this content on social media.

Thank you.

James Bachini

Disclaimer: Not a financial advisor, not financial advice. The content I create is to document my journey and for educational and entertainment purposes only. It is not under any circumstances investment advice. I am not an investment or trading professional and am learning myself while still making plenty of mistakes along the way. Any code published is experimental and not production ready to be used for financial transactions. Do your own research and do not play with funds you do not want to lose.


Posted

in

, , ,

by