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}), + ) +}