# ERC-20の数量計算でNumberを使わない：BigInt・decimals・端数の扱い

ERC-20の数量をNumberへ変換すると桁が失われる理由を解説します。入力文字列とBigIntを使った最小単位への変換、decimals未取得、小数桁の超過、表示とJSON保存を実行例で確認します。

正規URL: https://3mikan.com/archives/6070

著者: みかん
公開: 2026-09-06T00:00:00.000Z
更新: 2026-09-06T00:00:00.000Z

「入力した数量と、コントラクトへ渡す数量が少し違う」。この問題を調べるとき、最初に確認したいのはRPCやウォレットではなく、**入力文字列を途中でJavaScriptの`Number`に変換していないか**です。

本記事では、トークン数量を「入力の文字列」「計算用の整数」「表示用の文字列」に分けます。使うのは架空の数量とローカルのNode.jsだけです。ウォレット接続、APIキー、送金は必要ありません。

掲載例はNode.js 22以降で実行できます。各節のコードを`example.mjs`へ保存し、同じ節で使う関数定義と呼び出しを合わせてから`node example.mjs`を実行してください。再利用できる関数は[token-amount.mjs](/examples/offline-web3/token-amount.mjs)から取得できます。

## decimalsは計算結果ではなく、単位を解釈する情報

ERC-20の`balanceOf`が返すのは`uint256`の整数です。人に見せる小数点の位置を決める情報が`decimals`であり、たとえば`decimals = 6`なら、整数の`1250000`を`1.25`として表示します。[^erc20]

```text
表示数量     decimals     最小単位の整数
1.25         6            1250000
1.25         18           1250000000000000000
```

同じ「1.25」でも、コントラクトへ渡す整数は異なります。OpenZeppelinの説明でも、`decimals`は表示上の解釈に使うもので、コントラクトの残高計算を浮動小数点へ変える設定ではありません。[^oz]

また、ERC-20原仕様では`decimals()`は任意のメタデータです。取得できなかった場合に「たぶん18」と埋める設計は避けます。表示する単位が確定していない状態と、残高がゼロの状態は別だからです。[^erc20]

実装上は、数量に`chainId`、トークンのコントラクトアドレス、確認済みの`decimals`を対応させます。少なくとも、この3つを決めずに表示値を最小単位へ換算しない設計にします。

## BigIntへ変える前にNumberを通すと、失った桁は戻らない

JavaScriptの`Number`で安全な整数として扱える範囲の上限は、`2^53 - 1`、つまり`9007199254740991`です。上限を超えた数がすべて不正確になるわけではありませんが、隣り合う整数を区別できなくなる場合があります。[^number]

次の入力を考えます。

```js
const input = '0.100000000000000001';
const wrong = BigInt(Number(input) * 10 ** 18);
console.log(wrong.toString());
// 100000000000000000
```

18桁の小数として解釈した正しい整数は`100000000000000001`です。しかし、この例では最後の1が失われます。実際にNode.js v22.16.0で上記の結果を確認しました。

`BigInt`を使っていても、**その前段階で精度が失われれば修復できません**。`parseFloat(input)`を使ってから掛け算する実装にも、同じ問題が残ります。

必要なのは、入力を最初から最後まで文字列として読み、整数部と小数部を連結して整数へ変換する方法です。

## 小数を丸めずに、最小単位の整数へ変換する

ここでは入力方針を明確に限定します。非負の10進文字列だけを受け付け、指数表記、桁区切り、前後の空白、余分な先頭ゼロは拒否します。小数部分が`decimals`を超えた場合も、自動で丸めずにエラーにします。

```js
const UINT256_MAX = (1n << 256n) - 1n;

function parseTokenAmount(text, decimals) {
  if (!Number.isInteger(decimals) || decimals < 0 || decimals > 255) {
    throw new RangeError('invalid decimals');
  }
  if (typeof text !== 'string' || text.length > 400) {
    throw new TypeError('invalid amount string');
  }

  const match = /^(0|[1-9][0-9]*)(?:\.([0-9]+))?$/.exec(text);
  if (!match || match[0] !== text) throw new TypeError('invalid decimal amount');

  const whole = match[1];
  const fraction = match[2] ?? '';
  if (fraction.length > decimals) {
    throw new RangeError('too many fractional digits');
  }

  const raw = BigInt(whole + fraction.padEnd(decimals, '0'));
  if (raw > UINT256_MAX) throw new RangeError('amount exceeds uint256');
  return raw;
}
```

この正規表現は`m`フラグを使わないため、`$`は入力末尾にだけ一致します。JavaScriptで改行の直前にも一致するのは`m`を指定した場合です。`match[0] === text`は、将来パターンを変えても入力の一部分だけを受け入れない意図を明示しています。末尾改行は補正せず拒否します。[^regexp]

このコードは、汎用の小数ライブラリではなく、非負のERC-20数量を読むためのサンプルです。400文字という上限や先頭ゼロの禁止は、この記事で決めた入力ポリシーであり、ERC-20の要件ではありません。

```js
parseTokenAmount('1.25', 6);                  // 1250000n
parseTokenAmount('0.000001', 6);              // 1n
parseTokenAmount('0.100000000000000001', 18); // 100000000000000001n
parseTokenAmount('0.0000001', 6);             // RangeError
```

最後の入力は、6桁の最小単位より細かい数量です。勝手にゼロへ切り捨てたり、1単位へ切り上げたりしません。

なお、この実装は`1.2500000`も6桁指定では拒否します。末尾のゼロを削れば表現できますが、「入力の補正をしない」という方針を優先しています。末尾ゼロを許容するUIにするなら、検証前の正規化として明示的に追加してください。

## 表示の丸めと、計算に使う値を分離する

最小単位の整数を表示へ戻すときも、途中で`Number(raw)`へ変換しない設計にします。整数を10進文字列へ変換し、末尾から`decimals`桁の位置へ小数点を入れれば、元の数量を保てます。

次の`formatTokenAmount`はこの方針です。`UINT256_MAX`は前のサンプルで定義した値を使います。

```js
function formatTokenAmount(raw, decimals) {
  if (!Number.isInteger(decimals) || decimals < 0 || decimals > 255) {
    throw new RangeError('invalid decimals');
  }
  if (typeof raw !== 'bigint') throw new TypeError('raw must be bigint');
  if (raw < 0n || raw > UINT256_MAX) throw new RangeError('outside uint256');
  if (decimals === 0) return raw.toString();
  const digits = raw.toString().padStart(decimals + 1, '0');
  const whole = digits.slice(0, -decimals);
  const fraction = digits.slice(-decimals).replace(/0+$/, '');
  return fraction ? `${whole}.${fraction}` : whole;
}
```

```text
formatTokenAmount(1250000n, 6) → "1.25"
formatTokenAmount(1n, 6)       → "0.000001"
formatTokenAmount(0n, 18)     → "0"
```

画面上では「1.23」のように短く表示しても構いません。ただし、表示用に丸めた文字列を、そのまま比較や送信データの計算へ戻さないようにします。計算の正本は整数、表示はその派生値という関係を保ちます。

既存ライブラリを使う場合、ethers v6には10進文字列を`bigint`へ変換する`parseUnits`と、整数から表示文字列を作る`formatUnits`があります。採用時は、過剰な小数桁や許容する入力表記の扱いも、使うバージョンでテストします。[^ethers]

## BigIntでも端数の扱いは決める必要がある

`BigInt`は整数の計算用です。整数同士の除算では、割り切れない部分が保持されるわけではありません。[^bigint]

```js
5n / 2n;       // 2n
(1n * 3n) / 2n; // 1n
(1n / 2n) * 3n; // 0n
```

本例のように非負の値を扱う場合、除算の端数は切り捨てになります。一般にはゼロ方向への切り捨てです。

手数料や比率を計算するなら、「どの単位で」「どの段階に」「どちら向きの丸めを入れるか」を別途決めます。単にNumberをBigIntへ置き換えても、計算順序や丸めの仕様は自動では決まりません。上の2つの式は、実数として同じでも整数演算では結果が違います。

## JSONへ保存するときは、数量を文字列として定義する

変換処理を指定せずに`BigInt`を`JSON.stringify`へ渡すと、`TypeError`になります。[^json]

```js
JSON.stringify({ amount: 1n }); // TypeError

const saved = JSON.stringify({
  amountRaw: '100000000000000001',
  decimals: 18
});
```

保存スキーマでは`amountRaw`を10進文字列と定義します。読み戻す側でも、このフィールドだけを文字列として検証し、整数へ変換します。すべての数字らしい文字列を一括でBigIntへ変える必要はありません。

実際のアプリでは、ここへチェーンIDとトークンアドレスも追加します。表示済みの「1.25」だけでは、どの単位の数量を保存したのか復元できないためです。

## まとめ

数量処理で守るべき境界は、**入力は文字列、計算は最小単位の整数、表示は別の文字列**です。

`decimals`未確認のまま換算しない、Numberを経由しない、小数桁の超過を勝手に丸めない、BigIntの除算規則を決める。この4点を、0、最小単位、上限、桁超過のテストで固定すると、RPCへ接続する前に数量バグを減らせます。

### 検証メモ

例はNode.js v22.16.0とv24.14.0で確認しました。整数を表示文字列へ変換しても、同じ`decimals`で読み戻せば元の整数に戻ること、桁超過や不正入力を拒否することを確かめています。実トークンの取得・送信は行っていません。

関連記事：[ABIから関数識別子・入力データ・イベントログを読む方法](/archives/6005)

[^erc20]: [ERC-20: Token Standard — decimals / balanceOf](https://eips.ethereum.org/EIPS/eip-20)。参照日：2026-09-05。
[^oz]: [OpenZeppelin Contracts 5.x — ERC-20 / A Note on decimals](https://docs.openzeppelin.com/contracts/5.x/erc20)。参照日：2026-09-05。
[^number]: [ECMAScript — Number.MAX_SAFE_INTEGER](https://tc39.es/ecma262/multipage/numbers-and-dates.html#sec-number.max_safe_integer)。参照日：2026-09-05。
[^bigint]: [ECMAScript — BigInt numeric type](https://tc39.es/ecma262/multipage/ecmascript-data-types-and-values.html#sec-numeric-types-bigint)。参照日：2026-09-05。
[^json]: [ECMAScript — SerializeJSONProperty](https://tc39.es/ecma262/multipage/structured-data.html#sec-serializejsonproperty)。参照日：2026-09-05。
[^ethers]: [ethers v6 — Unit Conversion](https://docs.ethers.org/v6/api/utils/#about-units)。仕様参照のみ。本稿の実行検証はethersに依存しません。参照日：2026-09-05。
[^regexp]: [ECMAScript 2025：正規表現の入力境界](https://tc39.es/ecma262/2025/multipage/text-processing.html#sec-assertion)。参照日：2026-09-06。

## 確認した一次情報

- [ERC-20: Token Standard — decimals / balanceOf](<https://eips.ethereum.org/EIPS/eip-20>): 確認日 2026-09-05
- [OpenZeppelin Contracts 5.x — ERC-20 / A Note on decimals](<https://docs.openzeppelin.com/contracts/5.x/erc20>): 確認日 2026-09-05
- [ECMAScript — Number.MAX\_SAFE\_INTEGER](<https://tc39.es/ecma262/multipage/numbers-and-dates.html#sec-number.max_safe_integer>): 確認日 2026-09-05
- [ECMAScript — BigInt numeric type](<https://tc39.es/ecma262/multipage/ecmascript-data-types-and-values.html#sec-numeric-types-bigint>): 確認日 2026-09-05
- [ECMAScript — SerializeJSONProperty](<https://tc39.es/ecma262/multipage/structured-data.html#sec-serializejsonproperty>): 確認日 2026-09-05
- [ethers v6 — Unit Conversion](<https://docs.ethers.org/v6/api/utils/#about-units>): 確認日 2026-09-05
- [ECMAScript 2025：正規表現の入力境界](<https://tc39.es/ecma262/2025/multipage/text-processing.html#sec-assertion>): 確認日 2026-09-06
