Never Lose a Wei: Mastering EVM Numbers in TypeScript

MathTypescriptEVMBigIntPrecisionSolidity

Practical tips for handling Ethereum numbers in TypeScript: avoid common BigInt pitfalls, handle token decimals safely, and keep your smart contract interactions precise.

Table of Contents

    Working with Ethereum and other chain assets often involves high-precision numbers, and small mistakes can lead to serious bugs, or worse, lost funds. Below are some guidelines and patterns we recommend following when handling numeric values, especially when interacting with smart contracts or constructing transactions.

    🟩 TL;DR: If the blockchain works in uint256, your code should work in bigint 🟩

    Rationale: Why We Use bigint Everywhere

    Smart contracts (especially on Ethereum and EVM-compatible chains) operate on fixed-width integers, most commonly uint256. A uint256 is an unsigned 256-bit integer, capable of storing values from 0 up to 2^256 - 1. This represents an extremely large range, far beyond what JavaScript's native number type can safely represent.

    When interacting with these contracts, whether parsing return values, constructing calldata, or calculating gas and slippage, you are almost always dealing with values in this format.

    TypeScript's bigint is the closest native representation of uint256. By using bigint for all calculations, you get the following benefits:

    • ✅ Full compatibility with smart contract values
    • ✅ No loss of precision (unlike number or float)
    • ✅ Consistent and predictable behavior with large values (e.g. token balances in wei)

    Keeping all calculations in bigint allows you to mirror how Solidity does math on-chain. This helps avoid bugs, rounding errors, and unexpected behavior, especially when dealing with slippage, fee calculations, or token transfers.

    Different number systems in Typescript

    In Typescript, we will typically encounter three main numeric types: number, bigint, and BigNumber. While they may seem similar at first glance, they differ significantly, especially when it comes to storage and precision:

    • number in JavaScript is a 64-bit double precision floating-point value (IEEE 754). It can only safely store integers up to 2^53 - 1 (~9 quadrillion). Anything beyond that risks silent precision loss.

    • bigint is a built-in JavaScript primitive introduced in ES2020 that can represent arbitrarily large integers, limited only by available memory. Unlike number, it doesn’t use floating-point representation, so it can precisely store and operate on extremely large integers, including values well beyond the safe range of number.

    • BigNumber(from libraries like ethers.js) is not a native number type. It’s a wrapper class for string-based math, mainly used in environments where bigint is unavailable or when interoperating with JSON. BigNumber is safe, but slower, and must be explicitly converted for bigint-based math.

    • Solidity’s uint256 is a fixed-width, 256-bit unsigned integer. It maps naturally to TypeScript's bigint, but not to number. Using number will almost always break with large values.

    General Rules of Thumb

    BigInt first, BigInt always

    Convert values to bigint as early as possible and do all calculations in bigint. Avoid using number unless you're working with small, UI-only values where precision doesn't matter. Only convert to string when serializing, logging, or outputting values. This helps avoid subtle bugs and keeps things compatible with smart contracts.

    // ✅ Good: convert to bigint early and stay bigint
    const amountBigInt = BigInt('123456789123456789');
    const doubledCorrect = amountBigInt * 2n;
    console.log(doubledCorrect.toString());
    // 246913578246913578 — accurate
    
    // ❌ Bad: using number early causes precision loss
    const amountStr = '123456789123456789'; // 18-decimal token amount
    const amountNumber = Number(amountStr); // loses precision!
    const doubled = amountNumber * 2;
    console.log(doubled);
    // 246913578246913570 — wrong! should be exact but isn't
    

    Avoid mixing BigInt with ethers.BigNumber unless you have a good reason. Convert explicitly using .toBigInt() or BigNumber.from(bigint.toString()).

    Multiply before you divide

    Order matters - When doing multi-step calculations, always multiply before dividing. Division truncates precision, so scaling up first helps preserve accuracy. For example, use (amount _ rate) / base instead of (amount / base) _ rate.

    // ✅ Correct: multiply first, then divide
    const amount = 123456789123456789n;
    const feeBps = 250n; // 2.5% in basis points
    const feeCorrect = (amount * feeBps) / 10000n;
    console.log(feeCorrect.toString());
    // 3086419728086419n
    
    // ❌ Wrong: divide first, then multiply
    const feeWrong = (amount / 10000n) * feeBps;
    console.log(feeWrong.toString()); // 3086419728086250n
    console.log('Off by:', (feeCorrect - feeWrong).toString());
    // Off by: 169 wei!
    

    Percentages and decimals ≠ floats

    Never rely on float math when handling things like slippage, fees, or percentages.

    For something like 0.5%, convert it to basis points (bps) by multiplying by 100nand then using a slippage scale of 10_000n for mathematical operations entirely on that integer. This gives you an accurate 50n to use in calculations without any floating-point errors.

    Basis points are a unit equal to 0.01 percent, or one one-hundredth of a percent. They are used in finance, crypto, and math to represent small percentage changes precisely, especially for things like fees, interest rates, and slippage.

    Here is a comprehensive example script showcasing the correct function one should use, vs a raw calculation (even with bigints!) where division is applied before multiplication:

    /**
     * Basis point scale used to represent percentages with 2 decimal digits of precision.
     * 1% = 100 basis points → 10000 = 100.00%
     */
    const SLIPPAGE_BPS_SCALE = 10_000n;
    
    /**
     * Validates slippage tolerance value and throws error if invalid.
     *
     * @param slippageTolerance - Slippage percentage (e.g., 0.5 for 0.5%)
     * @throws Error if value is negative or ≥ 100
     */
    const validateSlippageTolerance = (slippageTolerance: number): void => {
      if (slippageTolerance < 0 || slippageTolerance >= 100) {
        throw new Error(`Invalid slippage tolerance: ${slippageTolerance}`);
      }
    };
    
    /**
     * Calculates the minimum acceptable output amount after applying slippage tolerance.
     *
     * @param rawAmount - Full amount before slippage (e.g., from a quote)
     * @param slippageTolerance - Slippage % with up to 2 decimal places (e.g., 0.5)
     * @returns Adjusted minimum amount as bigint
     */
    function calculateSlippageAdjustedMinAmountToBigInt(
      rawAmount: bigint,
      slippageTolerance: number
    ): bigint {
      if (slippageTolerance === 0) {
        return rawAmount;
      }
      validateSlippageTolerance(slippageTolerance);
      const slippageBasisPoints = BigInt(Math.trunc(slippageTolerance * 100)); // Convert to basis points
      const slippageMultiplier = SLIPPAGE_BPS_SCALE - slippageBasisPoints;
      const multiplied = rawAmount * slippageMultiplier;
      const adjustedAmount = multiplied / SLIPPAGE_BPS_SCALE;
      return adjustedAmount;
    }
    
    // -------------------------------- Example usage --------------------------------
    const amount = 123_456_789_123_456_789n;
    const slippage = 0.5;
    const minAmountOut = calculateSlippageAdjustedMinAmountToBigInt(amount, slippage);
    console.log('✅ Correct (via function):', minAmountOut.toString());
    //✅ Correct (via function): 122839505177839505
    
    const slippageBps = 50n; // 0.5% = 50 basis points
    const wrongMinAmount = (amount / 10_000n) * (10_000n - slippageBps);
    console.log('❌ Wrong:', wrongMinAmount.toString());
    // ❌ Wrong: 122839505177832750
    
    console.log(
      '❌ Division before multiplication error (wei):',
      (minAmountOut - wrongMinAmount).toString()
    );
    // ❌ Division before multiplication error (wei): 6755
    
    // -------------------------------- Example usage 2 --------------------------------
    const amount2 = 123_456_789_123_456_789n;
    const slippage2 = 2.7;
    const minAmountOutCorrect = calculateSlippageAdjustedMinAmountToBigInt(amount2, slippage2);
    console.log('✅ Correct (bigint):', minAmountOutCorrect.toString());
    // ✅ Correct (bigint): 120123455817123455
    
    const floatMultiplier = 1 - slippage2 / 100; // 0.995
    const minAmountOutFloat = BigInt(Math.floor(Number(amount2) * floatMultiplier));
    console.log('❌ Wrong (float):', minAmountOutFloat.toString());
    // ❌ Wrong (float): 120123455817123440
    
    console.log('❌ Float math error (wei):', (minAmountOutCorrect - minAmountOutFloat).toString());
    //❌ Float math error (wei): 15
    

    Decimal Awareness

    Watch out for too many digits after the dot

    If you see a number like:

    0.000000000000000123 Take a step back.

    More than 18 digits after the decimal point is usually a red flag 🚩 and means something went wrong in the conversion or unit handling. EVM tokens use fixed decimal places and anything beyond that is likely incorrect.

    Rule of thumb: ETH = 18 decimals, SOL = 9, USDC/USDT = 6. always convert to the correct base unit before doing calculations.

    You must check the decimals of each token before you interact with it. Never assume a token on Ethereum has 18 decimals even if that’s usually the default case.

    Ex: 0.000001 USDC → use parseUnit("0.000001", "6") to get 1n. don’t just YOLO and hope for the best 🙅

    Always check the token decimals

    Each token has a defined decimal precision and you need to respect that when formatting, parsing, or serializing amounts. ETH has 18 decimals, SOL has 9, and USDC/USDT have 6. If you convert human-readable numbers into base units or vice versa, make sure you're applying the correct power of 10. Getting this wrong is a common source of bugs.

    Avoid using 10**decimals since its risky with big-numbers - always use battle tested utility functions.

    import { parseUnits } from "ethers";
    
    // ✅ Safe: parseUnits handles all precision correctly — no surprises
    const input = "0.123456789123456789"; // full 18-decimal ETH amount
    const safe = parseUnits(input, 18);
    console.log(safe.toString()); // 123456789123456789n — perfect
    
    // ⚠️ Risky: parseFloat will "work" — until it doesn’t
    const risky = BigInt(parseFloat(input) \* 10 \*\* 18);
    console.log(risky.toString()); // 123456789123456784n — off by 5 wei
    Another Example using gas:
    
    // Convert 5 gwei to wei
    import { parseUnits } from "ethers";
    const gasPrice = parseUnits("5", "gwei");
    

    How to Find a Token’s Decimals on Etherscan

    1. Go to etherscan.io (or an equivalent scanner)
    2. Paste the token contract address into the search bar
    3. Click on the Contract tab
    4. Scroll to “Read Contract” (or “Read as Proxy” if it's a proxy contract)
    5. Look for the decimals method and click to expand it
    6. The returned value is the token’s on-chain decimals (e.g. 6 for USDC, 18 for most ERC-20s - but not always! ⚠️)

    This gives you the most accurate value straight from the blockchain. Always trust this over docs or third-party sources.

    Serialization and Formatting

    Strip trailing zeros

    Once you're done with calculations and are converting values to strings for display or JSON, remove unnecessary trailing zeros. "100.000000" can just be "100". Clean output is easier to read and avoids confusion.

    const removeTrailingZeros = (value: string): string => {
      return value.replace(/\.0+$/, '');
    };
    
    const rawValue1 = '123.450000';
    const rawValue2 = '100.0000';
    const rawValue3 = '42.0';
    
    console.log(removeTrailingZeros(rawValue1)); // "123.45"
    console.log(removeTrailingZeros(rawValue2)); // "100"
    console.log(removeTrailingZeros(rawValue3)); // "42"
    

    Pad with intention

    When padding values for fixed-width hex strings or byte arrays, don’t hardcode lengths or magic numbers. Use named helpers or constants to keep everything predictable and consistent. Proper padding is critical when encoding calldata or ABI-compatible values.

    Smart contracts expect fixed-size values like bytes32 (32 bytes = 64 hex chars). Avoid hardcoding .padStart(64, "0") — use a helper to make padding predictable and readable.

    // Pads a hex string to a fixed byte length (e.g. 32 for bytes32)
    const padHexToBytes = (value: string, byteLength: number): string => {
      const hex = value.replace(/^0x/, "");
      return `0x${hex.padStart(byteLength _ 2, "0")}`;
    };
    
    // Example: pad to bytes32
    const raw = "abc123";
    const padded = padHexToBytes(raw, 32);
    console.log(padded);
    // → 0x0000000000000000000000000000000000000000000000000000000000abc123
    

    Always strip the 0x prefix before padding manually

    When preparing hex strings for fixed-width values (e.g. bytes32), the 0x prefix is not counted in the byte length — it’s just a display marker. Failing to remove it before .padStart(...) will result in incorrectly sized output.

    Example:

    // ✅ Correct: remove 0x before padding, add it back after
    const clean = value.replace(/^0x/, '');
    const padded = `0x${clean.padStart(64, '0')}`;
    
    // ❌ Incorrect: padding with 0x included
    const wrong = value.padStart(64, '0');
    // includes "0x", total length wrong
    

    Things to Avoid

    Don’t cast BigInt to number unless it’s safe

    If you do Number(bigint) on a large value, you'll silently lose precision. Only do this as a last step when you're certain the value is small enough to fit safely in a JS number. For anything financial or on-chain, keep it bigint.

    // ❌ Unsafe: precision lost silently when converting bigint to float
    const big = 123456789123456789123456789n;
    
    // Convert to float
    const unsafe = Number(big);
    console.log("⚠️ As number (scientific):", unsafe);
    // 1.2345678912345678e+26 — precision lost
    
    console.log("⚠️ As string:", unsafe.toString());
    // '1.2345678912345679e+26' — notice the rounding error ⚠️⚠️⚠️
    
    // Convert back to bigint (loss is permanent)
    const backToBigInt = BigInt(unsafe);
    console.log("❌ Converted back to bigint:", backToBigInt.toString());
    // 1234567891234567913377628160 — not equal to original!
    
    console.log((big - backToBigInt).toString())
    // off by 2214306027! ⚠️
    
    // Do math on unsafe float
    const multiplied = unsafe _ 10;
    console.log("🔁 Multiplied float:", multiplied);
    // e.g., 1.2345678912345679e+27
    
    const floatToBigIntAgain = BigInt(multiplied);
    console.log("❌ Multiplied then back to bigint:", floatToBigIntAgain.toString());
    // 1234567891234567913377628160 — error now scaled up!
    
    // Final comparison
    console.log("🧮 Original bigint:", big.toString());
    // 123456789123456789123456789
    console.log("❌ After float roundtrip:", backToBigInt.toString());
    // 123456789123456791337762816
    console.log("❌ After float _ 10 round:", floatToBigIntAgain.toString());
    // 1234567891234567913377628160
    

    Don’t trust float math or float-based libraries

    Avoid any math library that defaults to floating-point calculations! Floats might seem fine in dev or on small amounts, but they will break under large values or edge cases. Always prefer integer-safe libraries or write your own utilities using bigint.

    import { parseUnits } from "ethers";
    const floatStringInput = "0.123456789123456789";
    // ✅ Correct: use string input with parseUnits for exact bigint conversion
    const exactResult = BigInt(parseUnits(floatStringInput, 18));
    console.log("✅ parseUnits-style:", exactResult.toString());
    // 123456789123456789 — exact
    
    // ❌ Incorrect: converting string to float loses precision
    const floatResult = BigInt(Number(floatStringInput) _ 1e18); // simulate parseUnits with float
    console.log("❌ Float-based:", floatResult.toString());
    // 123456789123456784 — imprecise!
    
    // ------------------------------------------
    // 🧪 Bonus: demonstration of JavaScript float issues
    // ------------------------------------------
    const unsafeFloat = 0.123456789123456789;
    console.log("⚠️ Given float:", unsafeFloat);
    // Logs: 0.12345678912345678 — rounded, last digit already lost!
    
    // ❌ Still unsafe: converting float to string and passing to parseUnits
    const unsafeWithEthers = parseUnits(unsafeFloat.toString(), 18);
    console.log("⚠️ parseUnits(float.toString()):", unsafeWithEthers.toString());
    // 123456789123456780 — off by 9 wei!
    
    // ❌ More float math failure: multiply before converting to BigInt
    const multiplied = unsafeFloat _ 1e18;
    console.log("⚠️ Multiplied float:", multiplied);
    // 123456789123456780 — again, off by 9 wei!
    
    const bigintFromFloat = BigInt(multiplied);
    console.log("⚠️ BigInt from float:", bigintFromFloat);
    // 123456789123456784 — wrong result carried into bigint
    
    // ✅ Safe again: always use string to preserve precision
    const safeAgain = BigInt(parseUnits("0.123456789123456789", 18));
    console.log("✅ Safe again:", safeAgain.toString());
    // 123456789123456789 — exact
    

    Bonus Tips

    Use ethers.js’s parseUnits and formatUnits where appropriate

    These functions help convert between human-readable and base-unit amounts, but always check you're passing the correct decimal value. For example, USDC should use 6 decimals, not 18 like ETH. Don’t assume default behavior.

    Abstract repeated patterns into helpers Instead of writing ad hoc math everywhere, extract common operations like applying slippage, calculating percentages, or converting between units. This helps reduce bugs and makes the codebase easier to maintain.

    // 100% in basis points
    const SLIPPAGE_BPS_DENOMINATOR = 10_000n;
    
    /**
     * Pads a hex string to a fixed byte length (e.g. 32 for bytes32)
     * @param value The hex string to pad (without 0x)
     * @param byteLength The desired byte length (e.g. 32)
     * @returns The padded hex string with 0x prefix
     */
    function applySlippage(amount: bigint, slippageBps: bigint = 0n): bigint {
      const multiplier = SLIPPAGE_BPS_DENOMINATOR - slippageBps;
      return (amount * multiplier) / SLIPPAGE_BPS_DENOMINATOR;
    }
    
    const amount = 100n;
    
    const slippage1 = applySlippage(amount, 1n);
    const slippage0 = applySlippage(amount, 0n);
    const slippageMax = applySlippage(amount, 9999n);
    
    console.log(slippage1);
    // 99.999% of 100 = 99.999 → truncated to 99n
    console.log(slippage0);
    // No slippage → 100% of 100 = 100n
    console.log(slippageMax);
    // Keeps just 0.01% of 100 = 0.01 → truncated to 0n
    

    Use Ethereum checksums for addresses where needed

    When displaying or validating Ethereum addresses, use EIP-55 checksums to detect typos and prevent issues from lowercase-only or mixed-case errors. This is especially helpful when working with user inputs, logs, or UI copy-paste scenarios.

    import { getAddress, isAddress } from 'ethers';
    
    // Validate and convert to checksummed form
    const addr1 = '0xa0b86a33e6776c0c52b8b87e6b1e4f4b8a5e8c9d';
    
    if (isAddress(addr1)) {
      console.log(getAddress(addr1));
    }
    // → "0xa0b86a33E6776C0C52b8B87E6b1E4f4B8A5e8C9D"
    
    const addr2 = '0x1234567890ABCDEF1234567890ABCDEF12345678';
    if (isAddress(addr2)) {
      console.log(getAddress(addr2));
    }
    // → "0x1234567890AbcdEF1234567890aBcdef12345678"
    

    Use hex constants and compute values

    When working with known bit limits (like uint256 max), use the hex literal form and compute the value using BigInt(...) instead of hardcoding the decimal version - it's easier to verify and avoids typos.

    const UINT256_MAX_HEX = '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff';
    const UINT256_MAX = BigInt(UINT256_MAX_HEX);
    
    console.log(UINT256_MAX.toString());
    // → "115792089237316195423570985008687907853269984665640564039457584007913129639935"
    

    This is especially useful when:

    • Defining max token allowance values
    • Working with bitmask operations
    • Creating test cases for overflow/underflow

    If possible avoid hardcoding the integer version - it is unreadable and error-prone.

    Define and reuse well-known addresses

    For readability and consistency, define constants for known Ethereum addresses like the zero address, burn address, and system contracts.

    /**
     * Checks if two Ethereum addresses are equal (case-insensitive).
     * @param a First address
     * @param b Second address
     * @returns true if equal, false otherwise
     */
    function isSameAddress(a: string, b: string): boolean {
      return a.trim().toLowerCase() === b.trim().toLowerCase();
    }
    
    // Usage example:
    const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000';
    const DEAD_ADDRESS = '0x000000000000000000000000000000000000dEaD';
    
    console.log(isSameAddress(DEAD_ADDRESS, ZERO_ADDRESS)); // false
    
    © 2025 gbXBT