ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

ethers大数转换实战:BigNumber与BigInt精度陷阱全解析

ethers大数转换实战:BigNumber与BigInt精度陷阱全解析 如果你写过 DApp 前端、跑过链上数据脚本或者只是跟着教程调过一次合约交互大概率已经被 ethers 的 BigNumber 和数字转换折磨过一遍。上周我还在技术群里看到有人把formatEther的返回值直接当成 number 拿去乘除结果整个手续费计算全偏了。这篇文章我打算把 ethers bignumber 和数字转换这个主题一次讲透为什么链上必须用大数、v5 和 v6 到底差在哪、各种数据类型之间怎么安全互转以及我自己在项目里踩过的溢出、负数、序列化和除法截断的坑。全文代码同时覆盖 ethers v5 和 v6 两种写法适合做合约前端、写批量查询脚本、或者准备从 v5 迁移到 v6 的开发者看完可以直接抄作业。1. 精度失控现场为什么链上数值不能塞进 JS Number1.1 一个容易忽略的边界安全整数以太坊的最小单位是 wei1 ether 10^18 wei。你随便读一个余额返回值都是以 wei 为单位的整数数值轻轻松松超过 1e18。而 JavaScript 的数字类型是 IEEE 754 双精度浮点数它只能保证-(2^53 - 1)到2^53 - 1之间的整数精确可用也就是Number.MAX_SAFE_INTEGER 9007199254740991。这个值和 1e18 之间的距离就是几乎所有链上精度事故的根源。console.log(Number.MAX_SAFE_INTEGER); // 9007199254740991 console.log(Number.MAX_SAFE_INTEGER 1); // 9007199254740992刚好能表示 console.log(Number.MAX_SAFE_INTEGER 2); // 9007199254740992已经丢了 1 console.log(Math.pow(2, 53) Math.pow(2, 53) 1); // true第三行输出很多人第一次看到都会愣住9007199254740991 2的结果居然等于9007199254740992。原因很简单超过安全整数范围之后double 能表示的整数不再是连续的而是每隔一段距离才有一个精确值中间的数字全部会被舍入到邻近的可表示值。这个距离会随着数值变大而变大到了 10^18 这个量级差值已经不是 1、2而是成百上千。1.2 链上场景为什么逃不开余额、转账金额、gasPrice 和 gasLimit 的乘积、代币总量这些全是超过安全线的高位整数。下面这个例子最能说明问题// 1000 ETH 的 wei 数 const balance 1000000000000000000000; // 你以为加减 1 wei 没问题实际上系统完全感知不到 console.log(balance balance 1); // true这个变量等于 1000 ETH 对应的 wei 值看起来是个整数但在 double 的表示里这个量级的最小间隔远大于 1。所以你加 1 wei、减 1 weiJavaScript 根本察觉不到变化。你在一笔交易里少算了 1 wei签名出来的数据和期望值不符合约校验失败gas 白付。这不是段子我在真实项目里见过因为没有转大数、直接把 wei 当 number 累加导致的金额误差。有人会想那我用toFixed或者字符串相加行不行不行。字符串只能做展示链上运算必须落在 256 位整数语义上该进位进位、该截断截断不能靠浮点近似。于是 ethers v5 引入了基于 bn.js 的BigNumber类v6 直接改用 ES2020 的BigInt原生类型。理解这一点后面所有转换操作就都有了解释。2. 版本分水岭v5 的 BigNumber 类与 v6 的原生 BigInt2.1 v5 的 BigNumber 类v5 里你见到的ethers.BigNumber是一个独立的类底层用 bn.js 实现不可变大整数。它的操作风格是方法链add、sub、mul、div、mod都会返回新的 BigNumber原对象不变。const { ethers } require(ethers); const amount ethers.BigNumber.from(1000000000000000000); // 1 ether const two amount.add(amount); // 2 ether const half amount.div(2); // 0.5 ether注意是整数截断 console.log(two.toString()); // 2000000000000000000 console.log(half.toHexString()); // 0x06f05b59d3b20000这里有个新手经常忽略的细节add、div这类方法不会修改amount本身而是返回一个全新的 BigNumber。如果你写成amount.add(1)然后继续用amount会发现数值根本没变。另外v5 的 BigNumber 还有一个硬性限制——它不支持负数BigNumber.from(-1)会直接抛错减法结果变成负数也会报 underflow 之类的错误。后面我会再展开讲。2.2 v6 的原生 BigIntv6 把 BigNumber 类整个删掉了大数直接用 JavaScript 原生 bigint。bigint 是基本类型不是对象四则运算直接用 - * /没有那么多的方法可记。import { ethers } from ethers; const amount 1000000000000000000n; // 1 ether const two amount amount; const half amount / 2n; console.log(two.toString()); // 2000000000000000000 console.log(0x half.toString(16)); // 0x6f05b59d3b20000注意 v5 的路径是ethers.utils.formatEther/ethers.utils.parseEtherv6 直接变成ethers.formatEther/ethers.parseEther因为 v6 把utils命名空间拆掉了。网上老教程一大半是 v5 写法直接复制到 v6 项目里会报ethers.utils is undefined之类的错。这是新手最容易踩的版本坑。2.3 版本对比表和判断方法对比项ethers v5ethers v6大数类型BigNumber 类基于 bn.js原生 bigint创建方式ethers.BigNumber.from(x)BigInt(x)或直接写1n加法bn.add(other)bn other减法bn.sub(other)bn - other乘法bn.mul(other)bn * other除法bn.div(other)bn / other比较bn.eq(other)/bn.gt(other)//转字符串bn.toString()bn.toString()转数字bn.toNumber()Number(bn)转十六进制bn.toHexString()0x bn.toString(16)支持负数不支持支持JSON 序列化输出 BigNumber 对象bigint 不能直接 JSON.stringify怎么快速判断你项目里用的是哪个版本一行代码const { ethers } require(ethers); console.log(ethers.version); // v5 输出 5.x.xv6 输出 6.x.x console.log(typeof ethers.BigNumber); // v5: functionv6: undefinedv6 里BigNumber只是类型别名运行时根本不存在。迁移的大原则就是v5 的方法链变成 v6 的数学运算符v5 的.eq/.gt/.lt变成 / / v5 的utils.parseEther变成ethers.parseEther。新项目直接上 v6别再用 v5 写新代码维护老项目再看 v5 语法。3. 转换矩阵全解字符串、数字、十六进制、字节数组之间的互转BigNumber / BigInt 只是一个中间表示你最终要面对的无非是四种形态十进制字符串、JS 数字、hex 字符串、字节数组。把这张转换矩阵记熟绝大多数转换问题都能一眼看穿。3.1 各种来源怎么进大数来源v5 写法v6 写法十进制字符串BigNumber.from(123)BigInt(123)或ethers.toBigInt(123)十六进制字符串BigNumber.from(0x7b)BigInt(0x7b)或ethers.toBigInt(0x7b)JS number安全整数BigNumber.from(123)BigInt(123)JS number超过安全线抛错 / 结果不可靠BigInt(x)会继承精度损失慎用字节数组BigNumber.from(array)ethers.toBigInt(array)BigIntBigNumber.from(1n)5.6直接使用BigNumberbn.toBigInt()5.6—// v5 ethers.BigNumber.from(1000000); // 十进制字符串 ethers.BigNumber.from(0x0f4240); // 十六进制字符串0x 必须带 ethers.BigNumber.from(1000000); // 安全整数 ethers.BigNumber.from(1000000n); // 5.6 可以直接收 bigint ethers.BigNumber.from(new Uint8Array([1, 2, 3])); // 字节数组 // v6 BigInt(1000000); // 十进制字符串 BigInt(0x0f4240); // 十六进制字符串 ethers.toBigInt(0x0f4240); // ethers 统一入口 ethers.toBigInt([1, 2, 3]); // 字节数组这里要特别提醒三个雷区第一BigInt解析十六进制时必须带0x前缀。你写BigInt(0x0f4240)没问题但写BigInt(0f4240)会被当成十进制字符串以f不是数字直接抛SyntaxError。第二大数构造函数只接受整数。BigInt(123.45)、BigNumber.from(123.45)都会抛错带小数的人类可读金额必须走parseUnits/parseEther不能走这里。第三当 number 来源本身已经不精确时转出来的大数也是错的。比如BigInt(12345678901234567890)这个 number 字面量在解析时已经被舍入过了你再转 bigint拿到的也不是你以为的那个整数。所以凡是链上读出来的原始值、或者用户输入的金额文本都尽量保持字符串形态不要先经过一次 number 再转换。3.2 从大数转出去目标v5 BigNumberv6 BigInt十进制字符串bn.toString()bn.toString()十六进制字符串bn.toHexString()0x bn.toString(16)JS numberbn.toNumber()先检查范围Number(bn)先检查范围字节数组ethers.utils.arrayify(bn.toHexString())ethers.getBytes(bn)JSON 文本bn.toString()bn.toString()// v6 const v 255n; v.toString(); // 255 0x v.toString(16); // 0xff Number(v); // 255注意范围 ethers.getBytes(v); // Uint8Array [255] ethers.toBeHex(v, 32); // 32 字节补零的 hex适合 bytes32 场景 // v5 const bn ethers.BigNumber.from(255); bn.toString(); // 255 bn.toHexString(); // 0xff bn.toNumber(); // 255 ethers.utils.arrayify(bn.toHexString()); // Uint8Array [255]toNumber/Number是最容易出事的一步因为你没法从语法上判断当前值是否安全。一个好习惯是转换前先显式校验function toSafeNumber(v) { if (typeof v bigint v BigInt(Number.MAX_SAFE_INTEGER)) { throw new Error(数值超过 2^53请用字符串处理); } return Number(v); }toBeHex(v, 32)用来生成 bytes32 格式的 hex 字符串在构造某些合约调用或者解析日志 topic 时很有用。v5 没有同名方法但可以用ethers.utils.hexZeroPad(bn.toHexString(), 32)达到同样效果。3.3 可复用的安全转换函数下面这个工具箱是我自己在项目里一直在用的 v6 版本各种入口类型都处理了包括负数判断、小数拦截和 hex 归一化import { ethers } from ethers; function toBigIntSafe(v) { if (typeof v bigint) return v; if (typeof v number) { if (!Number.isSafeInteger(v)) throw new Error(number 超出安全整数范围); return BigInt(v); } if (typeof v string) { const s v.trim(); if (/^-?[0-9]$/.test(s)) return BigInt(s); // 十进制整数 if (/^-?0x[0-9a-fA-F]$/.test(s)) return BigInt(s); // 十六进制 if (/^[0-9](\.[0-9])?$/.test(s)) { throw new Error(小数请用 parseUnits / parseEther); } throw new Error(无法识别的字符串: ${s}); } if (Array.isArray(v) || v instanceof Uint8Array) return ethers.toBigInt(v); throw new Error(不支持的类型: ${typeof v}); } function toHexEven(b) { if (b 0n) throw new Error(hex 转换前请先处理符号); let h b.toString(16); if (h.length % 2) h 0 h; // 补齐偶数位行为和 v5 的 toHexString 一致 return 0x h; }这个函数的逻辑很简单先把所有输入归一化到 bigint再统一输出。正因为转换路径都集中在一个地方出了问题也只需要改一个函数而不是满项目搜Number(。4. parseUnits / formatUnits小数与整数换算的标准姿势链上合约只认整数。以太坊的 ether 到 wei 是 10^18USDT、USDC 这类代币是 6 位小数很多游戏代币可能是 8 位。用户在输入框里写的0.05是人类可读文本真正传给合约的是50000000000000000这种整数这两者之间必须通过 parseUnits / formatUnits 这一对函数来换算而不是自己乘 10 的多少次方。4.1 parseUnits文本进链// v6 写法 ethers.parseEther(0.05); // 50000000000000000n ethers.parseUnits(1.5, 6); // 1500000n ethers.parseUnits(100, gwei); // 100000000000n // v5 写法 ethers.utils.parseEther(0.05); // BigNumber对应 5e16 ethers.utils.parseUnits(1.5, 6); // BigNumber对应 1500000parseUnits(1.5, 6)的意思是把 1.5 按 6 位小数换算成链上整数1.5 * 10^6 1500000。传入的单位可以是数字位数也可以是单位名字符串比如gwei、ether、wei。两种单位换算关系可以记成一张表单位小数位数1 单位对应的 weiwei01gwei910^9ether1810^18一定要用字符串传入。有人喜欢先parseFloat(input)再传进去这么做除了引入浮点误差之外没有任何好处。单价 0.1 ETH 在二进制浮点里本来就不是精确值你乘 10^18 之后误差会被放大到几十个 wei账本上就是笔糊涂账。如果用户输入的小数位超过代币支持的 decimalsparseUnits会直接报错比如parseUnits(1.2345678, 6)会提示 fractional component exceeds decimals前端可以先做同样的位数校验再提交。4.2 formatUnits链上数值回文本// v6 ethers.formatEther(50000000000000000); // 0.05 ethers.formatUnits(1500000, 6); // 1.5 ethers.formatUnits(100000000000, gwei); // 100.0 // v5 ethers.utils.formatEther(50000000000000000); // 0.05 ethers.utils.formatUnits(1500000, 6); // 1.5formatUnits的返回值是字符串不是 number。这一点很关键很多前端开发者习惯拿到结果就交给图表库或者继续做数值运算如果直接Number(formatUnits(...))再算虽然显示层面通常没事但一旦数值继续参与乘法、除法精度又会开始流失。我自己的习惯是渲染交给字符串运算交给 bigint只允许在结果边界做一次格式化。如果你只想显示 4 位小数别急着用Number(...).toFixed(4)字符串截断更安全const text ethers.formatEther(balanceWei); // 123.4567890123456789 const [whole, frac ] text.split(.); const display frac.length 4 ? ${whole}.${frac.slice(0, 4)} : text;4.3 代币 decimals 与费率计算投资组合里有 18 位小数的也有 6 位、8 位小数的硬编码 decimals 等于给自己埋雷。正确的做法是每次从合约读取const decimals Number(await token.decimals()); // 返回 bigint转成 number const amountIn ethers.parseUnits(userInput, decimals);费率计算也是同样的逻辑。比如手续费率是 3%你要先乘再除避免中间结果变成小数const amount ethers.parseEther(100); // 100 ether const fee amount * 300n / 10000n; // 精确向下取整 const feeCeil (amount * 300n 9999n) / 10000n; // 向上取整gas 成本估算也推荐全程 bigintgasPrice是一个 bigintgasLimit也是 bigint直接乘最后统一formatEther比先转 number 再乘容易出错得多。const feeData await provider.getFeeData(); const cost feeData.gasPrice * 21000n; console.log(ethers.formatEther(cost)); // 比如 0.000462...5. 踩坑清单与自查路径5.1 toNumber 一用就废Number(bigint)超过安全范围时不会报错只会悄悄给你一个失真值。v5 的toNumber()不同版本行为也不完全一致有的会抛溢出错误有的会静默丢精度所以最稳妥的做法是转换前先判断值的大小。判断标准很简单大于Number.MAX_SAFE_INTEGER就说明这个值不适合作为 number 使用。const supply await token.totalSupply(); // 可能是天文数字 console.log(supply.toString()); // 正常打印完整值 const n Number(supply); // 丢精度且不会有任何提示我见过一个统计脚本把全链代币总量Number()之后再累加结果几个大币种的总量加出来比实际小了几千万排查了一下午才定位到是精度问题。5.2 负数与 v5 的边界v5 的 BigNumber 只处理非负整数。BigNumber.from(-1)直接抛错BigNumber.from(1).sub(2)也会因为结果变成负数而报 underflow。这在大多数链上场景够用因为链上金额本来就是无符号的但如果你在做差价计算、收益率统计这类需要负数的业务v5 就很别扭。v6 用 BigInt 就没有这个问题1n - 2n直接得到-1n。如果你必须用 v5又需要负数我的建议是把符号单独拎出来存数值部分用字符串表示计算时先判断正负号再决定加还是减。新项目真的别再用 v5 了。5.3 科学计数法与小数串大整数用 JS number 保存后打印出来会变成科学计数法比如1.5e21。这个时候你要是把它String()之后扔给BigInt或parseEther直接报错。BigInt(1.5e21); // SyntaxError BigInt(0.0001); // SyntaxError ethers.parseEther(1e-7); // 多数版本不支持科学计数法根源还是那句输入金额、链上返回的原始值全程保持字符串或 bigint不要在中间插入 number 环节。用户输入什么你就传什么顶多做 trim 和位数校验。5.4 除法截断bigint 的除法是向零截断的v5 的div也一样。5n / 2n 2n不会给你 2.5。这在做比例、费率、均价计算时非常容易出现隐性误差。// v6 950n / 100n; // 9n不是 9.5 // v5 ethers.BigNumber.from(950).div(100); // 9解决方案是调整运算顺序先乘后除。算出精度不够再补位数比如先乘 10000 再除以基数最后再格式化。如果确实需要四舍五入手动加一个进位量再除。5.5 JSON 序列化bigint 没法直接JSON.stringify会抛出TypeError: Do not know how to serialize a BigInt。v5 的 BigNumber 虽然不抛错但序列化出来是一个{ type: BigNumber, hex: 0x... }对象后端收到根本不是你想的数字。JSON.stringify({ total: 1234n }); // TypeError统一的做法是提供一个 replacerconst toJson (val) JSON.stringify(val, (_, v) typeof v bigint ? v.toString() : v );反过来API 返回的金额字段尽量设计成字符串不要设计成 number否则 JSON 解析那一步就会把精度吃掉。5.6 一条自查路径如果你遇到链上金额对不上我建议按这个顺序排打印原始值。合约返回的每个值都先toString()看一眼确认从源头开始就没失真。确认 decimals。这个币到底是 18 位、6 位还是 8 位去链上查decimals()别猜。核对数量级。1 ether 解析出来应该是 18 位数字1 gwei 解析出来是 9 位数字肉眼扫一眼就能发现是不是多乘少除了。搜代码里的Number()、parseFloat、* 1、 。这些操作任何一个碰到 bigint 都可能无声无息地把它变成 float。确认 ethers 版本。v5 的代码贴到 v6 项目里跑报错千奇百怪但根因就一个。最后用parseUnits/formatUnits作为唯一换算入口不要在业务代码里自己写Math.pow(10, 18)。最后分享一个我自己的调试习惯在 hardhat 脚本或者 Node 服务里跑批量查询时我会写一个logValue(label, value)函数统一打印${label}: ${value.toString()}。ethers v6 里 bigint 直接 console.log 会带 n 后缀字符串化之后干净得多也方便复制粘贴到区块浏览器里核对。遇到金额对不上先看日志里原始 wei 值再倒推是哪一步Number()污染了它。我遇到的情况里九成精度问题都能用这招定位剩下的一成是版本混用。大数这种东西用对了地方就是一层安全网用错了就是一堆抓不住的幽灵。
返回列表