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:
- 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.
- 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).
- 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.
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, orNumber(...)around it is dropped and the BigInt passes through unchanged.Reproduction
main.ts:node main.ts perry compile main.ts -o out && ./outExpected (Node 26.5.1)
Actual (Perry)
Same output with
PERRY_NO_AUTO_OPTIMIZE=1and in the default auto-optimize mode:As a
.jsfile (the annotations removed), line 5 printsnumber:0instead. All other lines are the same.Impact
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.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.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)).Cannot convert a BigInt value to a numberat module load.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.esm/_sha2.js:9-10(setBigUint64).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).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.
aandbare BigInts obtained in the listed way, andEisa 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:const x=E; typeof x/String(x)/x*_2nlet x=E; String(x)const x=E; x===expconst x=E; f(x)Number(E)/const x=E; Number(x)1003n OP 7nconst a = 1003nlet a = 1003na: bigintparamconst A = 1003nconst a = BigInt(1003)const A = BigInt(1003)(noble style)o.a,arr[0],(p + _0n)BigInt(1003) OP BigInt(7)inlineOther results:
let x = a; x &= bandx <<= b).const x = A ^ BwithA = BigInt(12)gives0.Three layers are involved:
--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.--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).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:
TypeError: Cannot mix BigInt and other typeswhen multiplied #6384: a BigInt derived from a loop counter threw "Cannot mix" when multiplied.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.