From d8cd6fad51c63f8d67d625329857ddf47cbb5d61 Mon Sep 17 00:00:00 2001 From: zexoverz Date: Tue, 18 Aug 2026 22:54:23 +0800 Subject: [PATCH] feat: allow modifying the upper bound in bits.ToBinary Closes #1434. ToBinary compares the decomposition against the native modulus minus one, which is the weakest bound that still keeps the decomposition unique. Callers who know their value lives in a smaller range had no way to say so. WithUpperBound sets that constant. It is rejected at or above the modulus, since a larger bound readmits the a / a+r ambiguity the check exists to prevent, and it is refused alongside OmitModulusCheck because the two ask for opposite things. An explicit bound always enforces the comparison, including when WithNbDigits is below the field bitlength and the check would otherwise be skipped: the digit count does not imply the caller's bound. Constant inputs are checked at compile time, matching how WithNbDigits already reports an out-of-range constant. With the option unset every path is unchanged. --- std/math/bits/conversion.go | 33 ++++++++++++++++++++++ std/math/bits/conversion_binary.go | 26 +++++++++++++++-- std/math/bits/conversion_test.go | 45 ++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 2 deletions(-) diff --git a/std/math/bits/conversion.go b/std/math/bits/conversion.go index e003b95090..9e3335a38f 100644 --- a/std/math/bits/conversion.go +++ b/std/math/bits/conversion.go @@ -2,6 +2,7 @@ package bits import ( "errors" + "math/big" "github.com/consensys/gnark/frontend" ) @@ -53,6 +54,7 @@ type baseConversionConfig struct { UnconstrainedInputs bool omitModulusCheck bool + upperBound *big.Int } // BaseConversionOption configures the behaviour of scalar decomposition. @@ -101,6 +103,37 @@ func WithUnconstrainedInputs() BaseConversionOption { } } +// WithUpperBound sets the constant the decomposed value is checked against, +// instead of the default native field modulus minus one. +// +// The default check exists because a decomposition is only unique below the +// modulus: for a small value a, both a and a+r decompose correctly, where r is +// the native modulus, as the constraints are satisfied under the implicit +// modular reduction. Checking against a smaller constant is a strictly stronger +// statement, so it keeps that uniqueness and additionally pins the value into +// the caller's own range. +// +// bound must be positive and must not exceed the native modulus minus one; a +// larger bound would readmit the multiple-decomposition case the check exists +// to prevent. +// +// Setting this option always enforces the comparison, including when +// [WithNbDigits] is lower than the bitlength of the modulus, since the caller +// is asking for a bound the digit count alone does not imply. It cannot be +// combined with [OmitModulusCheck], and it only applies to binary conversion. +func WithUpperBound(bound *big.Int) BaseConversionOption { + return func(opt *baseConversionConfig) error { + if bound == nil { + return errors.New("bound is nil") + } + if bound.Sign() <= 0 { + return errors.New("bound must be positive") + } + opt.upperBound = new(big.Int).Set(bound) + return nil + } +} + // OmitModulusCheck omits the comparison against native field modulus in // case the bitlength of the decomposed value (if [WithNbDigits] not set or set // to bitlength of the native modulus) eqals bitlength of the modulus. diff --git a/std/math/bits/conversion_binary.go b/std/math/bits/conversion_binary.go index eaf9181930..3e169b85e2 100644 --- a/std/math/bits/conversion_binary.go +++ b/std/math/bits/conversion_binary.go @@ -82,6 +82,17 @@ func toBinary(api frontend.API, v frontend.Variable, opts ...BaseConversionOptio panic(err) } } + if cfg.upperBound != nil { + if cfg.omitModulusCheck { + panic("WithUpperBound and OmitModulusCheck are contradictory: one asks for a bound check, the other removes it") + } + // a bound at or above the modulus would readmit the multiple-decomposition + // case the check exists to prevent, so it is rejected rather than silently + // weakening the constraint. + if cfg.upperBound.Cmp(new(big.Int).Sub(api.Compiler().Field(), big.NewInt(1))) > 0 { + panic(fmt.Sprintf("WithUpperBound: bound has %d bits and exceeds the native modulus minus one", cfg.upperBound.BitLen())) + } + } // handle the case when the input is constant separately to avoid creating any constraints if constV, ok := api.Compiler().ConstantValue(v); ok { // first we ensure that the constant value is mod reduced @@ -94,6 +105,11 @@ func toBinary(api frontend.API, v frontend.Variable, opts ...BaseConversionOptio if cfg.NbDigits > 0 && cfg.NbDigits < constV.BitLen() { panic(fmt.Sprintf("constant input to ToBinary has more bits than requested by WithNbDigits option. Has %d bits, requested %d bits", constV.BitLen(), cfg.NbDigits)) } + // for a variable input an out-of-range value yields an unsatisfiable + // constraint; for a constant we can say so at compile time instead. + if cfg.upperBound != nil && constV.Cmp(cfg.upperBound) > 0 { + panic(fmt.Sprintf("constant input to ToBinary exceeds the bound set by WithUpperBound option. Value %s, bound %s", constV.String(), cfg.upperBound.String())) + } res := make([]frontend.Variable, cfg.NbDigits) for i := range cfg.NbDigits { res[i] = constV.Bit(i) @@ -104,7 +120,10 @@ func toBinary(api frontend.API, v frontend.Variable, opts ...BaseConversionOptio // by default, we also check that the value to be decomposed is less than the // modulus. However, we can omit the check when the number of bits we want // to decompose to is less than the modulus, or it was strictly requested. - omitReducednessCheck := cfg.omitModulusCheck || cfg.NbDigits < api.Compiler().FieldBitLen() + // an explicitly requested bound is always enforced: the caller is asking for + // something the digit count alone does not imply. + omitReducednessCheck := cfg.upperBound == nil && + (cfg.omitModulusCheck || cfg.NbDigits < api.Compiler().FieldBitLen()) // when cfg.NbDigits == 1, v itself has to be a binary digit. This if-clause // saves one constraint. @@ -141,7 +160,10 @@ func toBinary(api frontend.API, v frontend.Variable, opts ...BaseConversionOptio api.AssertIsEqual(Σbi, v) if !omitReducednessCheck { if cmper, ok := api.Compiler().(bitsComparatorConstant); ok { - bound := new(big.Int).Sub(api.Compiler().Field(), big.NewInt(1)) + bound := cfg.upperBound + if bound == nil { + bound = new(big.Int).Sub(api.Compiler().Field(), big.NewInt(1)) + } cmper.MustBeLessOrEqCst(bits, bound, v) } else { panic("builder does not expose comparison to constant") diff --git a/std/math/bits/conversion_test.go b/std/math/bits/conversion_test.go index bb3285ac37..d8364eca3b 100644 --- a/std/math/bits/conversion_test.go +++ b/std/math/bits/conversion_test.go @@ -208,3 +208,48 @@ func TestFromBinaryInvalidInput(t *testing.T) { Variable: big.NewInt(3), })) } + +type toBinaryUpperBoundCircuit struct { + A frontend.Variable + bound *big.Int +} + +func (c *toBinaryUpperBoundCircuit) Define(api frontend.API) error { + bits.ToBinary(api, c.A, bits.WithUpperBound(c.bound)) + return nil +} + +// The bound has to bind on its own, including when the digit count already +// keeps the value below the modulus. 100 is representable in the default number +// of digits, so only the bound can reject it. +func TestToBinaryUpperBound(t *testing.T) { + assert := test.NewAssert(t) + + assert.CheckCircuit(&toBinaryUpperBoundCircuit{bound: big.NewInt(100)}, + test.WithValidAssignment(&toBinaryUpperBoundCircuit{A: 0}), + test.WithValidAssignment(&toBinaryUpperBoundCircuit{A: 99}), + test.WithValidAssignment(&toBinaryUpperBoundCircuit{A: 100}), + test.WithInvalidAssignment(&toBinaryUpperBoundCircuit{A: 101}), + test.WithInvalidAssignment(&toBinaryUpperBoundCircuit{A: 255}), + ) +} + +type toBinaryUpperBoundFewDigitsCircuit struct { + A frontend.Variable +} + +func (c *toBinaryUpperBoundFewDigitsCircuit) Define(api frontend.API) error { + // WithNbDigits alone would skip the comparison entirely; the explicit bound + // must still be enforced on top of it. + bits.ToBinary(api, c.A, bits.WithNbDigits(8), bits.WithUpperBound(big.NewInt(100))) + return nil +} + +func TestToBinaryUpperBoundWithNbDigits(t *testing.T) { + assert := test.NewAssert(t) + + assert.CheckCircuit(&toBinaryUpperBoundFewDigitsCircuit{}, + test.WithValidAssignment(&toBinaryUpperBoundFewDigitsCircuit{A: 100}), + test.WithInvalidAssignment(&toBinaryUpperBoundFewDigitsCircuit{A: 101}), + ) +}