On-Chain Financial Calculations
Prerequisite: Blockchain Fundamentals
Your on-chain vault reports a NAV of $12,847,293. The actual portfolio value, computed from fresh exchange prices, is $12,491,106. The difference — $356,187 — exists because the Chainlink ETH/USD feed your contract reads has not updated in 47 minutes, and ETH dropped 2.8% during that window. A user redeems shares at the stale NAV, extracting $356,187 more than their shares are worth. The vault’s remaining depositors absorb the loss.
Every financial calculation on-chain inherits two constraints that off-chain systems do not face: the EVM has no floating-point type, and price data must be imported from external systems with variable freshness. Getting either wrong produces silent errors denominated in dollars.
Fixed-Point Arithmetic
Solidity has no float, no double, no decimal. Every number is an integer. Financial math — division, percentage calculations, weighted averages — must be implemented with integer arithmetic and explicit scaling.
The standard approach is to scale values by a fixed factor. USDC uses 6 decimals (1 USDC = 1_000_000). Most ERC-20 tokens use 18 decimals (1 TOKEN = 1e18). Chainlink price feeds return prices with 8 decimals ($1.00 = 100_000_000).
The fundamental rule: multiply before you divide. Division truncates in Solidity — 7 / 2 = 3, not 3.5. If you divide first, you lose precision that multiplication cannot recover.
// Wrong: divide first, lose precision
uint256 rate = amount / totalSupply;
uint256 result = rate * price;
// Right: multiply first, preserve precision
uint256 result = (amount * price) / totalSupply;
The multiply-first pattern introduces overflow risk. Two uint256 values multiplied can exceed 2^256. Solidity 0.8+ reverts on overflow by default, which is safe but costs you the transaction. For intermediate products that may overflow, use mulDiv — a function that computes (a * b) / c without intermediate overflow by using a 512-bit intermediate product.
// OpenZeppelin Math.mulDiv
// Computes (a * b) / c with full 512-bit precision
uint256 shares = Math.mulDiv(
depositAmount,
totalShares,
totalAssets
);
| Operation | Naive implementation | Precision loss | Safe implementation |
|---|---|---|---|
| Percentage (3.5%) | amount * 35 / 1000 |
Truncation on small amounts | mulDiv(amount, 35, 1000) |
| Share price | totalAssets / totalShares |
Rounds to 0 when totalAssets < totalShares | mulDiv(totalAssets, 1e18, totalShares) |
| Weighted average | sum(weight_i * value_i) / sum(weight_i) |
Overflow on large portfolios | Accumulate with mulDiv per term |
Decimal mismatch between tokens is the most common source of calculation bugs. USDC (6 decimals) multiplied by a Chainlink price (8 decimals) produces a 14-decimal result. ETH (18 decimals) multiplied by the same price produces a 26-decimal result. Your contract must normalize before comparing or aggregating values with different decimal bases.
Rounding
Every integer division rounds. The question is: who absorbs the rounding error?
In a vault, rounding on deposit means the depositor receives slightly fewer shares than their deposit warrants. Rounding on withdrawal means the withdrawer receives slightly fewer assets. Both rounding directions protect the vault — the remaining depositors are never diluted.
// Deposit: round shares DOWN (depositor gets fewer shares)
uint256 shares = Math.mulDiv(
assets, totalShares, totalAssets, Math.Rounding.Floor
);
// Withdrawal: round assets DOWN (withdrawer gets fewer assets)
uint256 assets = Math.mulDiv(
shares, totalAssets, totalShares, Math.Rounding.Floor
);
ERC-4626 (the tokenized vault standard) codifies this pattern. The specification requires convertToShares to round down and convertToAssets to round down — both in favor of the vault, never in favor of the individual user. This prevents a class of attacks where an attacker deposits and withdraws repeatedly, extracting 1 wei of value per round trip. At millions of transactions, 1 wei per trip accumulates.
The rounding error per operation is at most 1 unit of the smallest denomination. For USDC with 6 decimals, that is $0.000001. For a token with 18 decimals, it is 1 wei — economically negligible. The vulnerability is not the size of the rounding error but its direction. Rounding in the user’s favor creates an extractable arbitrage.
Oracle Architecture
On-chain contracts cannot query external APIs. Price data enters the blockchain through oracles — off-chain systems that post prices to on-chain contracts. Your calculation is only as fresh and accurate as the oracle feed it reads.
Chainlink
Chainlink price feeds are the most widely used oracle infrastructure. Each feed is a smart contract that stores the latest price, updated by a decentralized network of node operators. Feeds update on two triggers: a deviation threshold (typically 0.5% for major pairs, 1% for smaller ones) or a heartbeat interval (typically 3,600 seconds for major pairs).
(, int256 price, , uint256 updatedAt, ) =
priceFeed.latestRoundData();
require(price > 0, "Invalid price");
require(
block.timestamp - updatedAt <= MAX_STALENESS,
"Stale price"
);
The heartbeat is not a guaranteed update interval. If the price has not deviated beyond the threshold, the feed may not update until the heartbeat fires. During low-volatility periods, a feed with a 3,600-second heartbeat can be up to an hour stale — within specification, but potentially dangerous for time-sensitive calculations.
| Feed | Deviation threshold | Heartbeat | Typical update frequency |
|---|---|---|---|
| ETH/USD | 0.5% | 3,600s | Every few minutes in normal markets |
| BTC/USD | 0.5% | 3,600s | Every few minutes in normal markets |
| USDC/USD | 0.1% | 86,400s | Infrequent — stablecoin is usually within 0.1% |
| Long-tail tokens | 1–2% | 86,400s | Sporadic |
Pyth Network
Pyth takes a different approach. Instead of posting a single price, Pyth publishes a price with a confidence interval — $3,241.50 ± $1.20. The confidence band reflects disagreement among data providers or thin liquidity conditions. Your contract can use the confidence to adjust risk:
PythStructs.Price memory pythPrice = pyth.getPrice(priceFeedId);
int64 price = pythPrice.price;
uint64 conf = pythPrice.conf;
// Reject prices with wide confidence bands
require(
conf * 100 / uint64(price) < MAX_CONFIDENCE_PCT,
"Price too uncertain"
);
Pyth updates are pull-based. Your transaction includes the price update as calldata, and the Pyth contract verifies the signatures. This gives you sub-second freshness — the price is as current as the data included in your transaction — but costs additional gas for the verification (approximately 60,000–80,000 gas per price update on EVM chains).
TWAP Oracles
A time-weighted average price (TWAP) oracle computes the average price over a window by reading cumulative price accumulators from an on-chain DEX. Uniswap V3’s observe() function returns the cumulative tick at any past timestamp. The TWAP between two timestamps is derived by dividing the tick difference by the time difference.
(int56[] memory tickCumulatives, ) = pool.observe(
secondsAgos // [1800, 0] for a 30-minute TWAP
);
int24 avgTick = int24(
(tickCumulatives[1] - tickCumulatives[0]) / 1800
);
uint256 twapPrice = OracleLibrary.getQuoteAtTick(
avgTick, 1e18, token0, token1
);
TWAP oracles are manipulation-resistant because moving the average requires sustaining an artificial price for the entire window. A 30-minute TWAP requires 30 minutes of capital deployment to manipulate — expensive for an attacker. The tradeoff is latency. A 30-minute TWAP lags spot price by up to 30 minutes. During rapid moves, the TWAP diverges from reality.
| Oracle type | Freshness | Manipulation cost | Gas cost per read | Use case |
|---|---|---|---|---|
| Chainlink | Minutes (heartbeat-bound) | High (decentralized network) | ~25,000 gas | Lending, vaults, pricing |
| Pyth | Sub-second (pull-based) | High (publisher network) | ~80,000 gas (with update) | Trading, real-time pricing |
| TWAP (30 min) | 30 minutes (by design) | Very high (sustained capital) | ~15,000 gas | Governance, slow-path pricing |
See Blockchain Fundamentals. Oracle manipulation is a trust problem. You are delegating the question “what is the price?” to an external system. The oracle’s security model determines your calculation’s security model.
Cross-Chain Value Propagation
A portfolio holds assets across multiple chains. The NAV computation requires prices from chain A but the vault contract lives on chain B. You need to move the computed value — not the assets — across chains, with proof that the computation was correct.
The direct approach: compute partial NAVs on each chain, send them to the vault’s home chain via a cross-chain messaging protocol, and aggregate. Each partial NAV message includes the oracle prices used, the block number at computation time, and the total value.
The latency depends on the messaging protocol. LayerZero delivers in 1–5 minutes. Chainlink CCIP in 5–20 minutes. During this transit window, prices can move. A multi-chain NAV that aggregates values computed minutes apart is inherently approximate — the prices used for chain A’s assets were current at a different moment than the prices used for chain B’s assets.
The tighter approach is to use a single oracle network that publishes prices for assets on all chains. Pyth and Chainlink both offer cross-chain feeds. Instead of computing partial NAVs on each source chain, your vault reads all prices from oracle feeds on its home chain and queries cross-chain balances via state proofs or periodic attestations. This eliminates the temporal mismatch between partial NAV computations but requires the oracle to cover every asset in your portfolio.
For regulatory reporting or attestation, on-chain NAV must be verifiable after the fact. Storing the oracle prices, block numbers, and intermediate calculation results in contract events creates an immutable audit trail. An auditor replays the computation against the stored inputs and verifies the output matches — the same property that makes Postgres hash chains useful for fiat ledger audit, extended to on-chain portfolio valuation.