Skip to main content
Sigvex

Unsafe Field Downcast in Noir Circuits

Flags Noir functions that convert a Field input to a smaller integer type without a range check that prevents silent truncation.

Unsafe Field Downcast in Noir Circuits

Overview

A Noir Field element can hold any value below the prime modulus — roughly 2^254. The fixed-width integer types (u8, u16, u32, u64, and so on) hold far less. When a circuit accepts a Field and produces a smaller integer from it, the conversion has to decide what happens to the high bits. Without an explicit range check, those bits are dropped: the value wraps to fit, and the result no longer corresponds to the input the prover supplied.

This detector identifies functions that take a Field parameter and return a smaller integer type while emitting no assertion that bounds the input. The concern is not the syntax of the cast itself but the absence of a constraint forcing the input into the target range before it is narrowed. If the prover can supply a Field larger than the integer type allows, the downcast silently truncates and the function’s output diverges from what its signature implies.

Why This Is an Issue

Truncation turns an out-of-range value into an in-range one without any signal that it happened. A circuit that converts a Field amount to a u64 and uses the result in a balance update will accept a Field of 2^64 + 5 and treat it as 5. The proof still verifies — it just verifies the wrong arithmetic. An attacker who controls the Field input can engineer a high value that wraps to a convenient low one, bypassing checks that were written against the narrowed type.

The danger is that the narrowed value looks legitimate everywhere downstream. By the time the truncated integer reaches a comparison or an array index, the original out-of-range Field is gone. Range discipline has to happen at the point of conversion, because nothing afterward can recover the lost information.

How to Resolve

Assert the input’s bound before narrowing it. The range check must reject any Field that does not fit the target integer type.

// Before: Field is narrowed with no bound on its value
fn to_byte(x: Field) -> u8 {
    x as u8 // a Field of 256 silently becomes 0
}

// After: the input is constrained to the target range first
fn to_byte(x: Field) -> u8 {
    assert(x.lt(256)); // reject any value that would truncate
    x as u8
}

For wider targets, scale the bound to the type: assert x < 2^16 before a u16 cast, x < 2^32 before a u32 cast, and so on. Where Noir’s standard library offers a checked conversion helper, prefer it over a bare as cast.

Detection Methodology

The Noir source is parsed into a typed representation recording each function’s parameters and their types, its return type, and its assertions. The detector selects functions whose return type is one of the fixed-width integer types and whose parameter list includes at least one Field. For each such function it inspects the assertions for any expression that performs a comparison or cast — the textual signatures of a range check. When no assertion provides bounding, the function is reported: a Field input is being narrowed to a smaller type with nothing forcing it into range first.

Limitations

  • Recognition of range checks is pattern-based on comparison and cast expressions. A bound expressed through an unusual construct, or enforced in a separate helper function, may not be detected and can produce a false positive.
  • The detector reasons about the function’s declared input and return types; a Field that is provably small by an upstream caller is still reported, since the analysis is per-function.
  • Conversely, a comparison assertion that is present but does not actually bound the downcast input may suppress a finding that warrants attention.

References

Sample Sigvex Output

{
  "detector": "unsafe-field-downcast",
  "severity": "medium",
  "confidence": 0.70,
  "title": "Function `to_byte` converts Field to `u8` without range check",
  "signal": "to_byte",
  "line": 1,
  "description": "Function `to_byte` accepts Field parameter(s) but returns `u8`. Field elements can hold values up to the prime modulus (~2^254), but `u8` can only represent values up to 255. Without an explicit range check, the prover could provide an out-of-range Field value that silently truncates, leading to unexpected behavior.",
  "recommendation": "Add a range check before the downcast. For example: `assert(x < 256);` before casting to `u8`."
}