Repository navigation
BigInt & | ^ << >> results are typed as int32/number: const x = a & b yields 0, Number(a & b) returns the BigInt #10418
Description
Found by the package audit (compiling real npm packages from source instead of Perry's native bindings) on
Perry e6dcb62 (v0.5.1587), Linux x64. Binary bitwise operators (&, |, ^, <<, >>) are assumed to always produce an int32 Number, so when the operands are BigInts whose type is not statically known, the result is either truncated to int32 (0) when stored in a local, or Number(...) around it is dropped and the BigInt passes through unchanged.
Reproduction
main.ts:
const _2n = BigInt(2);
const M = BigInt(0xffffffff);
function andLocal(a, b) { const x = a & b; return x; }
function shlThenMul(k) { const x = _2n << k; return x * _2n; }
function numberOfAnd(n) { return Number(n & M); }
function typedEq(a: bigint, b: bigint) { const x = a << b; return x === 56n; }
function show(v) { return typeof v + ":" + String(v); }
function typedPass(a: bigint, b: bigint) { const x = a ^ b; return show(x); }
function inline(a, b) { return a & b; }
console.log("1 const x = a & b ->", andLocal(BigInt(7), BigInt(3)));
try { console.log("2 (_2n << k) * _2n ->", shlThenMul(BigInt(3))); } catch (e) { console.log("2 (_2n << k) * _2n -> threw:", e.message); }
console.log("3 typeof Number(n & M) ->", typeof numberOfAnd(BigInt(7)));
console.log("4 a: bigint, x === 56n ->", typedEq(BigInt(7), BigInt(3)));
console.log("5 a: bigint, show(x) ->", typedPass(BigInt(7), BigInt(3)));
console.log("6 return a & b ->", inline(BigInt(7), BigInt(3)));node main.ts
perry compile main.ts -o out && ./outExpected (Node 26.5.1)
1 const x = a & b -> 3n
2 (_2n << k) * _2n -> 32n
3 typeof Number(n & M) -> number
4 a: bigint, x === 56n -> true
5 a: bigint, show(x) -> bigint:4
6 return a & b -> 3n
Actual (Perry)
Same output with PERRY_NO_AUTO_OPTIMIZE=1 and in the default auto-optimize mode:
1 const x = a & b -> 0
2 (_2n << k) * _2n -> threw: Cannot mix BigInt and other types, use explicit conversions
3 typeof Number(n & M) -> bigint
4 a: bigint, x === 56n -> false
5 a: bigint, show(x) -> number:-2147483648
6 return a & b -> 3n
As a .js file (the annotations removed), line 5 prints number:0 instead. All other lines are the same.
Impact
- ethers 6.17.0 (compiled from its
src.ts) crashes during module init. FirstTypeError: Cannot convert a BigInt value to a number at split/keccakP. Once those sites are patched,Cannot mix BigInt and other typesinSWUFpSqrtRatio. - @noble/hashes 2.2.0
src/_u64.ts:22-23(fromBig; the same code is in 1.3.2esm/_u64.js:6-7):Number((n >> _32n) & U32_MASK64) | 0/Number(n & U32_MASK64) | 0, whereU32_MASK64 = BigInt(2 ** 32 - 1)and_32n = BigInt(32)are module-level constants.- Standalone,
fromBig(BigInt('0x428a2f98d728ae22'))returns{h: 0, l: 0}under Perry and1116352408 -685199838under Node. split()builds the SHA-512 round constants, and blake2b/blake2s encode the length viau64.fromBig(BigInt(this.length)).- Effect: sha384/sha512, blake2b, blake2s and argon2 produce wrong digests. sha3_256/keccak_256 throw
Cannot convert a BigInt value to a numberat module load. - With the two masks patched to the literals
0xffffffffn/32nin a copy of the package, all of these match Node, including sha256/384/512, blake2b/2s, blake3 on 4096 bytes, sha3_256, keccak_256 and argon2d/i/id. - Also affected: 1.3.2
esm/_sha2.js:9-10(setBigUint64).
- Standalone,
- @noble/curves 1.2.0:
esm/abstract/weierstrass.js:952const _2n_pow_c1_1 = _2n << (c1 - _1n - _1n)followed by* _2n(throws at init). Alsoesm/abstract/curve.js:88Number(n & mask)andweierstrass.js:825Number(q.y & _1n). - Every @noble/curves-based package (ethers, viem-style signers, secp256k1/ed25519 wrappers) fails at startup. Any BigInt bit manipulation that stores into a
constor usesNumber(a op b)is probably affected too.
Notes
Operator × operand-shape matrix. A generated program checked 11 operators × 12 operand shapes × 13 consumers against Node. a and b are BigInts obtained in the listed way, and E is a OP b. The failing cells are the same for all five of & | ^ << >>. ~a, +, -, *, ** and % match Node in every cell.
These consumers are always correct: typeof E, String(E), E === expected, (E) * _2n, and () => E. The failing ones are:
| operand shape | const x=E; typeof x / String(x) / x*_2n |
let x=E; String(x) |
const x=E; x===exp |
const x=E; f(x) |
Number(E) / const x=E; Number(x) |
|---|---|---|---|---|---|
literals 1003n OP 7n |
ok | ok | ok | ok | ok |
const a = 1003n |
ok | ok | ok | ok | ok |
let a = 1003n |
ok | ok | ok | number:-2147483648 | ok |
a: bigint param |
ok | ok | false | number:-2147483648 | bigint |
module const A = 1003n |
ok | ok | false | number:-2147483648 | bigint |
| untyped param | number / "0" / throws | "0" | false | number:0 | bigint / 0 |
const a = BigInt(1003) |
same as untyped param | "0" | false | number:0 | bigint / 0 |
module const A = BigInt(1003) (noble style) |
same | "0" | false | number:0 | bigint / 0 |
o.a, arr[0], (p + _0n) |
same | "0" | false | number:0 | bigint / 0 |
BigInt(1003) OP BigInt(7) inline |
same | "0" | ok | number:0 | ok / 0 |
Other results:
- Compound assignment works (
let x = a; x &= bandx <<= b). - A module-level
const x = A ^ BwithA = BigInt(12)gives0.
Three layers are involved:
- HIR typing (verified with
--trace hir --focus f). Forfunction f(a, b) { const x = a & b; return x }the trace showsLet { name: "x", ty: Number, init: Binary { op: BitAnd, LocalGet(0), LocalGet(1) } }. Witha - bit showsty: Any. The cause iscrates/perry-hir/src/lower_types.rs:434-444: bitwise ops are typedType::Numberunless an operand is already inferredBigInt.Sub/Mul/Div/Mod/Expfall back toType::Anyinstead.crates/perry-hir/src/analysis/value_types.rs:1682-1687(infer_binary_type) returnsType::Numberfor the bitwise ops without checking for BigInt. TheNumber(E)elision itself is incrates/perry-codegen/src/expr/bigint_set.rs:26-56(number_coerce_operand_is_already_primitive_numberreturnstruefor any bitwiseBinary).is_bigint_expr(crates/perry-codegen/src/type_analysis/numeric.rs:29) cannot prove BigInt-ness for params or for module consts initialized withBigInt(...), sojs_number_coerceis skipped atbigint_set.rs:606-615. This is from reading the code; it matches the observed BigInt pass-through. - Codegen int32 slots (verified with
--trace llvm). In case 1,xgets analloca i32.js_dynamic_bitandreturns the correct BigInt, and that result is then truncated with ToInt32 into the i32 slot, giving0. Everyconstwhose initializer is a bitwise expression is seeded as an int32 local atcrates/perry-codegen/src/collectors/i32_locals.rs:848(!mutable && is_bitwise_expr(init) && !is_ushr_zero(init)). Similar "bitwise ⇒ i32" arms exist ati32_locals.rs:100-104("Pure bitwise — always signed i32 per JS spec") andcrates/perry-codegen/src/collectors/integer_locals.rs:759-766. This layer ignores the HIR type: it also fires when HIR hasty: BigInt(a: bigintparams). - Call-site specialization (verified in IR; the deciding code was not located). In case 5, HIR types
xasBigInt, yet the call is lowered toshow$spec_i32(i32 fptosi(x)).fptosiof the NaN-boxed BigInt gives-2147483648.
Related closed issues, which are different shapes:
- 64-bit BigInt bitwise ops produce wrong results (XOR, AND-mask, mul-wrap) #39: 64-bit BigInt bitwise ops on BigInt-literal-typed values.
- BigInt derived from a loop counter throws
TypeError: Cannot mix BigInt and other typeswhen multiplied #6384: a BigInt derived from a loop counter threw "Cannot mix" when multiplied. - runtime: 'Cannot mix BigInt and other types' thrown during pi bundle init where node runs clean — pi wall #4 #6649: pi bundle init "Cannot mix"; the fix was globalizing module-init lets.
No open issue found. Searched: "BigInt bitwise", "bigint shift", "BigInt &", "Cannot mix BigInt", "bigint i32 slot".
The audit filed the @noble/hashes wrong digests as a separate finding. They are merged into this issue because they have the same root cause: patching fromBig's masks to literals fixes every affected hash.
Activity
proggeramlug commentedon Sep 17, 2026
Fixed on
mainby PR #10540, landed in merge train #10559 (v0.5.1592) — the train closes its source PRs instead of merging them, so theFixes #10418keyword in #10540 never evaluated. Changelog fragmentchangelog.d/10540-*.mdis onmain.perf: bitwise ops on unproven operands skip fmod and the ToInt32 tower (
perf: bitwise ops on unproven operands skip fmod and the ToInt32 tower (