Числа в Web3: wei, gwei, ether и bigint

Почему в Web3 нельзя хранить деньги во float, что такое wei/gwei/ether и как ими безопасно оперировать.

wei — минимальная неделимая единица эфира. 1 ether = 10^18 wei. Все суммы on-chain — целые числа в минимальных единицах, без дробей.

Это урок, который спасает от потери денег. В блокчейне нет вещественных чисел: всё хранится в целых минимальных единицах. Попытка считать балансы во float рано или поздно даёт ошибку округления — и в деньгах это катастрофа.

Почему не float

Числа с плавающей точкой (тип number в JS) не представляют десятичные дроби точно. Классика:

console.log(0.1 + 0.2);            // 0.30000000000000004
console.log(0.1 + 0.2 === 0.3);    // false

Вывод:

0.30000000000000004
false

Для денег это недопустимо. Поэтому суммы держат в целых wei и оперируют типом bigint, который точен для сколь угодно больших целых.

Единицы

ЕдиницаВ weiГде встречается
wei1внутреннее хранение
gwei1 000 000 000 (10^9)цена газа
ether1 000 000 000 000 000 000 (10^18)суммы для людей

parseEther и formatEther

ethers даёт две функции-моста: parseEther переводит строку «1.5» в wei (bigint), formatEther — обратно wei в человекочитаемую строку:

import { parseEther, formatEther } from "ethers";

const wei = parseEther("1.5");        // 1500000000000000000n (bigint)
const eth = formatEther(wei);         // "1.5"  (строка!)
// важно: parseEther принимает СТРОКУ, а не number, чтобы не потерять точность

Арифметика на bigint — чистая логика

Покажем «ручную» конвертацию wei↔ether на bigint, чтобы понять, что делает ethers внутри. Делим на 10^18, дробную часть собираем отдельно:

const DECIMALS = 18n;
const ONE = 10n ** DECIMALS;

function formatEth(wei) {
  const whole = wei / ONE;            // целая часть
  const frac = wei % ONE;             // остаток в wei
  // дробную часть дополняем нулями слева до 18 знаков и убираем хвостовые нули
  let fracStr = frac.toString().padStart(18, "0").replace(/0+$/, "");
  return fracStr ? whole + "." + fracStr : whole.toString();
}

console.log(formatEth(1500000000000000000n)); // 1.5
console.log(formatEth(1000000000000000000n)); // 1
console.log(formatEth(1234500000000000000n)); // 1.2345
console.log(formatEth(1n));                    // 0.000000000000000001

Вывод:

1.5
1
1.2345
0.000000000000000001

Как работает под капотом

Внутри ethers parseEther("1.5") не использует float: он разбирает строку, отделяет целую и дробную части, дополняет дробную нулями до 18 знаков и склеивает в одно большое целое — ровно как мы сделали вручную, только наоборот. Именно поэтому вход — строка: число 1.5 уже было бы неточным float ещё до парсинга.

Частые ошибки

  • parseEther(1.5) с числом. Передавайте строку "1.5"; число потеряет точность.
  • Складывать bigint с number. 1n + 1 бросит ошибку; приводите типы явно.
  • Хранить баланс во float и потом парсить. Ошибка округления уже произошла; держите суммы в bigint/строке.

Итоги

  • On-chain все суммы — целые wei; считайте на bigint, не на float.
  • parseEther/formatEther — мост между wei и человекочитаемым видом; вход parseEther — строка.
  • 1 ether = 10^18 wei, 1 gwei = 10^9 wei.
Проверьте себя
1. Сколько wei в одном ether?
A10^9
B10^18
C10^6
D1000
2. Почему parseEther принимает строку, а не number?
AТак короче
BЧтобы избежать потери точности float ещё до парсинга
CИз-за TypeScript
DЧтобы работало быстрее
3. На каком типе безопасно считать суммы в wei?
Anumber (float)
Bbigint
Cstring без преобразований
DFloat64Array