Skip to content

BigInt & | ^ << >> results are typed as int32/number: const x = a & b yields 0, Number(a & b) returns the BigInt #10418

Description

@proggeramlug

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 && ./out

Expected (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. First TypeError: Cannot convert a BigInt value to a number at split/keccakP. Once those sites are patched, Cannot mix BigInt and other types in SWUFpSqrtRatio.
  • @noble/hashes 2.2.0 src/_u64.ts:22-23 (fromBig; the same code is in 1.3.2 esm/_u64.js:6-7): Number((n >> _32n) & U32_MASK64) | 0 / Number(n & U32_MASK64) | 0, where U32_MASK64 = BigInt(2 ** 32 - 1) and _32n = BigInt(32) are module-level constants.
    • Standalone, fromBig(BigInt('0x428a2f98d728ae22')) returns {h: 0, l: 0} under Perry and 1116352408 -685199838 under Node.
    • split() builds the SHA-512 round constants, and blake2b/blake2s encode the length via u64.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 number at module load.
    • With the two masks patched to the literals 0xffffffffn / 32n in 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).
  • @noble/curves 1.2.0: esm/abstract/weierstrass.js:952 const _2n_pow_c1_1 = _2n << (c1 - _1n - _1n) followed by * _2n (throws at init). Also esm/abstract/curve.js:88 Number(n & mask) and weierstrass.js:825 Number(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 const or uses Number(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 &= b and x <<= b).
  • A module-level const x = A ^ B with A = BigInt(12) gives 0.

Three layers are involved:

  1. HIR typing (verified with --trace hir --focus f). For function f(a, b) { const x = a & b; return x } the trace shows Let { name: "x", ty: Number, init: Binary { op: BitAnd, LocalGet(0), LocalGet(1) } }. With a - b it shows ty: Any. The cause is crates/perry-hir/src/lower_types.rs:434-444: bitwise ops are typed Type::Number unless an operand is already inferred BigInt. Sub/Mul/Div/Mod/Exp fall back to Type::Any instead. crates/perry-hir/src/analysis/value_types.rs:1682-1687 (infer_binary_type) returns Type::Number for the bitwise ops without checking for BigInt. The Number(E) elision itself is in crates/perry-codegen/src/expr/bigint_set.rs:26-56 (number_coerce_operand_is_already_primitive_number returns true for any bitwise Binary). is_bigint_expr (crates/perry-codegen/src/type_analysis/numeric.rs:29) cannot prove BigInt-ness for params or for module consts initialized with BigInt(...), so js_number_coerce is skipped at bigint_set.rs:606-615. This is from reading the code; it matches the observed BigInt pass-through.
  2. Codegen int32 slots (verified with --trace llvm). In case 1, x gets an alloca i32. js_dynamic_bitand returns the correct BigInt, and that result is then truncated with ToInt32 into the i32 slot, giving 0. Every const whose initializer is a bitwise expression is seeded as an int32 local at crates/perry-codegen/src/collectors/i32_locals.rs:848 (!mutable && is_bitwise_expr(init) && !is_ushr_zero(init)). Similar "bitwise ⇒ i32" arms exist at i32_locals.rs:100-104 ("Pure bitwise — always signed i32 per JS spec") and crates/perry-codegen/src/collectors/integer_locals.rs:759-766. This layer ignores the HIR type: it also fires when HIR has ty: BigInt (a: bigint params).
  3. Call-site specialization (verified in IR; the deciding code was not located). In case 5, HIR types x as BigInt, yet the call is lowered to show$spec_i32(i32 fptosi(x)). fptosi of the NaN-boxed BigInt gives -2147483648.

Related closed issues, which are different shapes:

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    package-auditFound by the 2026 package audit: compiling real npm packages from source instead of native bindings

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions