Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions std/math/bits/conversion.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package bits

import (
"errors"
"math/big"

"github.com/consensys/gnark/frontend"
)
Expand Down Expand Up @@ -53,6 +54,7 @@ type baseConversionConfig struct {
UnconstrainedInputs bool

omitModulusCheck bool
upperBound *big.Int
}

// BaseConversionOption configures the behaviour of scalar decomposition.
Expand Down Expand Up @@ -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.
Expand Down
26 changes: 24 additions & 2 deletions std/math/bits/conversion_binary.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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.
Expand Down Expand Up @@ -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")
Expand Down
45 changes: 45 additions & 0 deletions std/math/bits/conversion_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}),
)
}