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:
- Both sides of an equation must have the same dimension.
- You cannot add or subtract quantities with different dimensions.
- 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:
| Quantity | Dimension | Example |
|---|---|---|
| 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:
| Expression | Dimension | Correct? |
|---|---|---|
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.
| Technique | What it catches | When to use | Cost |
|---|---|---|---|
| Dimensional analysis | Unit mismatches, formula errors, wrong conversions | Design phase, code review before deployment | Near zero. Paper and pen. |
| Fuzzing (Foundry, Echidna) | Edge cases, overflows, unexpected state transitions | After implementation needs running code | Medium. Requires writing invariants and running compute. |
| Formal verification (Certora, Halmos) | Proves properties hold for all inputs | Critical contracts with clear invariants | High. 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:
- Dimensional analysis at design time to validate formulas.
- Fuzzing during development to find implementation bugs.
- 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.


