Skip to content

Commit 4be7c4c

Browse files
authored
chore: add tests around MSM invalid scalar handling (#23176)
## Summary Adds focused DSL tests for the `MultiScalarMul` ACIR opcode covering Grumpkin scalars that lie **outside the Grumpkin scalar field** (i.e. `>= bb::fq::modulus`), and writes up the resulting safety analysis. No production code changes — this is test coverage plus a small refactor to share the existing single-term MSM circuit helpers. ### What changed `barretenberg/cpp/src/barretenberg/dsl/acir_format/multi_scalar_mul.test.cpp`: - Extracted the single-term MSM circuit helpers (`push_point`, `push_scalar`, `make_msm`, `run_circuit`, CRS setup) out of `MultiScalarMulInfinityTests` into a shared base fixture `MsmSingleTermFixture<Builder>`. `MultiScalarMulInfinityTests` now derives from it; behaviour and test names are unchanged. - New suite `MultiScalarMulScalarBoundsTests` (run for both `UltraCircuitBuilder` and `MegaCircuitBuilder`), driving the opcode through the normal ACIR → circuit path with `predicate = 1`: | Test | Scalar value `v` | Expectation | |---|---|---| | `ScalarEqualToModulusFails` | `r` | circuit unsatisfiable | | `ScalarModulusPlusOneFails` | `r + 1` | circuit unsatisfiable | | `ScalarModulusMinusOneProves` | `r - 1` (largest in-field scalar) | proves (baseline) | | `MaxRepresentableScalarFails` | `2^254 - 1` (limbs in range, value `> r`) | circuit unsatisfiable | | `ScalarWithOversizedHiLimbFails` | `2^254` (`hi` limb `= 2^126`, 127 bits) | circuit unsatisfiable | | `AddingGrumpkinModulusDoesNotReproveSameOutput` | `5` vs `5 + r` | `5` proves the output; `5 + r` does not, even though `(5+r)·P = 5·P` | Here `r == bb::fq::modulus` is the Grumpkin scalar field modulus, which is also the order of the Grumpkin group (Grumpkin's scalar field is BN254's base field). For the rejected cases the test claims the mathematically-correct reduced result `(v mod r)·P`, so the rejection is purely from the scalar-validity constraints, not a mismatched output. ## Test commands and results ``` cd barretenberg/cpp cmake --preset default cd build && ninja dsl_tests ./bin/dsl_tests --gtest_filter='*MultiScalarMulScalarBoundsTests*:*MultiScalarMulInfinityTests*' ``` Result: **18/18 passed** (6 new tests × 2 builders, plus the 6 pre-existing infinity tests confirming the refactor is behaviour-preserving). For the rejected-scalar cases the circuit checker reports `circuit contains invalid witnesses: field_t::range_constraint` — the borrow-comparison range checks added by `cycle_scalar`'s `validate_scalar_is_in_field`. Running the full `*MultiScalarMul*` filter also surfaces 8 failures in `MultiScalarMul{None,Points,Scalars,Both}Constant.GenerateVKFromConstraints` — these are **pre-existing and unrelated**: they throw `bn254 g1 data not found at /aztec-packages-private/.bb-crs` because the BN254 G1 CRS is not downloaded in this environment (VK generation needs it; `CircuitChecker`-based tests don't). They fail identically without this change. ## Safety report: out-of-field Grumpkin scalars in the MSM opcode ### Background The `MultiScalarMul` opcode (`dsl/acir_format/multi_scalar_mul.cpp`) computes `sum(scalars[i] · points[i])` on Grumpkin. Each scalar is supplied as two field-element limbs — `scalar_lo` (low 128 bits) and `scalar_hi` (next 126 bits) — and reconstructed as `v = lo + hi · 2^128`. The limb encoding can represent any value in `[0, 2^254)`, which strictly contains `[0, r)` (`r ≈ 2^253.2`), so non-canonical scalars are expressible. `to_grumpkin_scalar` (`dsl/acir_format/witness_constant.cpp`) builds a `cycle_scalar` from the two limbs via the public `cycle_scalar(lo, hi)` constructor, which calls `validate_scalar_is_in_field()`. That adds in-circuit constraints (via `validate_split_in_field_unsafe`, a borrow-subtraction comparison) enforcing `v < r`. Separately, `cycle_group::batch_mul` range-constrains the limbs to `lo < 2^128` and `hi < 2^126` as part of the MSM algorithm — and `validate_split_in_field_unsafe` relies on exactly those range constraints being applied. ### Answers 1. **Is it impossible to prove a circuit which is passed an invalid Grumpkin scalar?** Yes. Any scalar `v >= r` makes the circuit unsatisfiable: `validate_scalar_is_in_field` is violated (its hi/lo borrow range checks cannot all hold), so no satisfying witness exists — neither an honest nor a dishonest prover can produce a valid proof. A value `>= 2^254` additionally violates the `hi < 2^126` limb range constraint applied by `batch_mul`. The check is sound because the limb range constraints it depends on are applied unconditionally inside the MSM. (When `predicate` is witness-false the scalar is first replaced by the constant `1`, and during VK generation the limbs are overwritten with a dummy in-field value — so neither path can smuggle an out-of-field scalar past the check.) 2. **Can adding the Grumpkin modulus to a scalar prove the same output?** No. Although `s·P = (s + r)·P` in the group, the circuit enforces a canonical scalar `< r`, so `s + r` (which is `>= r`) is rejected. There is no representable witness that both equals `s + r` and passes `validate_scalar_is_in_field`. Caller-side malleability of the scalar therefore cannot produce an alternate proof of an existing MSM output. `AddingGrumpkinModulusDoesNotReproveSameOutput` demonstrates both halves of this. 3. **Other edge cases / boundary values.** `r - 1` is the largest accepted scalar and proves correctly; `r`, `r + 1`, and `2^254 - 1` are all rejected — the last shows the limb range constraints alone aren't enough and the explicit `< r` check does the work; `2^254` is rejected by both the limb range constraint and the field check. `0` is accepted and yields the point at infinity (already covered by the existing `ResultIsInfinity` test). ### Conclusion Allowing invalid Grumpkin scalars through to barretenberg is **safe** in the soundness sense. If a caller (Noir, hand-written ACIR, or a buggy/malicious frontend) hands the MSM opcode a scalar outside `[0, r)`, the worst that happens is the circuit becomes unsatisfiable and proving fails — fail-closed. There is no soundness gap: an out-of-field scalar is not silently reduced into a different valid statement, and `s + r` cannot stand in for `s`. So canonicalising scalars does not have to be done entirely on the caller side; barretenberg's in-circuit `validate_scalar_is_in_field` is a real backstop. The only caveat is a liveness one — a frontend that emits a non-canonical scalar will produce circuits that cannot be proven, which is the desired behaviour. --- *Created by [claudebox](https://claudebox.work/v2/sessions/bebfc4a2ecc4b733) · group: `cli-startup-research`*
1 parent 26f56c8 commit 4be7c4c

1 file changed

Lines changed: 137 additions & 1 deletion

File tree

barretenberg/cpp/src/barretenberg/dsl/acir_format/multi_scalar_mul.test.cpp

Lines changed: 137 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -341,7 +341,9 @@ struct MsmScalar {
341341
}
342342
};
343343

344-
template <typename Builder> class MultiScalarMulInfinityTests : public ::testing::Test {
344+
// Shared single-term MSM circuit helpers: build a one point/one scalar MSM constraint with predicate=1
345+
// from explicit witness values, and run the resulting circuit.
346+
template <typename Builder> class MsmSingleTermFixture : public ::testing::Test {
345347
protected:
346348
static void SetUpTestSuite() { bb::srs::init_file_crs_factory(bb::srs::bb_crs_path()); }
347349

@@ -402,6 +404,8 @@ template <typename Builder> class MultiScalarMulInfinityTests : public ::testing
402404
}
403405
};
404406

407+
template <typename Builder> class MultiScalarMulInfinityTests : public MsmSingleTermFixture<Builder> {};
408+
405409
TYPED_TEST_SUITE(MultiScalarMulInfinityTests, BuilderTypes);
406410

407411
// scalar=0 → result = ∞: valid proof with out_point_is_infinite=1.
@@ -449,3 +453,135 @@ TYPED_TEST(MultiScalarMulInfinityTests, ForgedFiniteFlagOnInfinityResultFails)
449453
EXPECT_TRUE(!ok || err.find("assert_eq") != std::string::npos)
450454
<< "Forged finite flag on infinity result should fail";
451455
}
456+
457+
// ============================================================
458+
// Scalar field-bounds tests
459+
// ============================================================
460+
//
461+
// The MSM opcode receives a Grumpkin scalar as two field limbs: lo (low 128 bits) and hi (next 126
462+
// bits), reconstructing v = lo + hi * 2^128. cycle_scalar's public constructor adds an in-circuit
463+
// check that v < r, where r == bb::fq::modulus is the Grumpkin scalar field modulus (and also the
464+
// order of the Grumpkin group, since Grumpkin's scalar field is BN254's base field). batch_mul
465+
// additionally range-constrains the limbs to lo < 2^128 and hi < 2^126. These tests pin behaviour
466+
// at and beyond the modulus boundary: an out-of-range scalar must make the circuit unsatisfiable,
467+
// and the group law's s ≡ s + r equivalence must not let a caller smuggle a non-canonical scalar
468+
// through to barretenberg.
469+
470+
namespace {
471+
// r = order of the Grumpkin group = bb::fq::modulus.
472+
const uint256_t grumpkin_scalar_modulus = bb::fq::modulus;
473+
474+
// Build an MsmScalar straight from a uint256_t value, splitting at the 128-bit limb boundary with no
475+
// modular reduction (so out-of-field values can be expressed).
476+
MsmScalar msm_scalar_from_u256(const uint256_t& v)
477+
{
478+
return { MsmFF(v.slice(0, 128)), MsmFF(v.slice(128, 256)) };
479+
}
480+
} // namespace
481+
482+
template <typename Builder> class MultiScalarMulScalarBoundsTests : public MsmSingleTermFixture<Builder> {};
483+
484+
TYPED_TEST_SUITE(MultiScalarMulScalarBoundsTests, BuilderTypes);
485+
486+
// scalar == r: rejected. The in-circuit "scalar < r" check fails. (r·P = O, so the caller gains
487+
// nothing by claiming the point at infinity as the result.)
488+
TYPED_TEST(MultiScalarMulScalarBoundsTests, ScalarEqualToModulusFails)
489+
{
490+
BB_DISABLE_ASSERTS();
491+
MsmGrumpkinPoint point = MsmGrumpkinPoint::random_element();
492+
auto [constraint, witness] = TestFixture::make_msm(
493+
MsmAcirPoint::from_native(point), msm_scalar_from_u256(grumpkin_scalar_modulus), MsmAcirPoint::infinity());
494+
495+
auto [ok, err] = TestFixture::run_circuit(constraint, witness);
496+
EXPECT_FALSE(ok) << "scalar == Grumpkin scalar modulus must not produce a satisfiable circuit";
497+
}
498+
499+
// scalar == r + 1: rejected, even though (r + 1)·P == 1·P == P. The in-circuit "scalar < r" check
500+
// fails despite both limbs being within their range constraints.
501+
TYPED_TEST(MultiScalarMulScalarBoundsTests, ScalarModulusPlusOneFails)
502+
{
503+
BB_DISABLE_ASSERTS();
504+
MsmGrumpkinPoint point = MsmGrumpkinPoint::random_element();
505+
auto [constraint, witness] = TestFixture::make_msm(MsmAcirPoint::from_native(point),
506+
msm_scalar_from_u256(grumpkin_scalar_modulus + uint256_t(1)),
507+
MsmAcirPoint::from_native(point));
508+
509+
auto [ok, err] = TestFixture::run_circuit(constraint, witness);
510+
EXPECT_FALSE(ok) << "scalar == Grumpkin scalar modulus + 1 must not produce a satisfiable circuit";
511+
}
512+
513+
// scalar == r - 1: the largest in-field scalar. This must prove fine.
514+
TYPED_TEST(MultiScalarMulScalarBoundsTests, ScalarModulusMinusOneProves)
515+
{
516+
BB_DISABLE_ASSERTS();
517+
MsmGrumpkinPoint point = MsmGrumpkinPoint::random_element();
518+
bb::fq scalar_native = bb::fq(grumpkin_scalar_modulus - uint256_t(1));
519+
MsmGrumpkinPoint result = point * scalar_native;
520+
ASSERT_FALSE(result.is_point_at_infinity());
521+
auto [constraint, witness] = TestFixture::make_msm(MsmAcirPoint::from_native(point),
522+
msm_scalar_from_u256(grumpkin_scalar_modulus - uint256_t(1)),
523+
MsmAcirPoint::from_native(result));
524+
525+
auto [ok, err] = TestFixture::run_circuit(constraint, witness);
526+
EXPECT_TRUE(ok) << "scalar == Grumpkin scalar modulus - 1 (largest in-field scalar) should prove. err: " << err;
527+
}
528+
529+
// scalar == 2^254 - 1: the largest value the (128 + 126)-bit limb encoding can represent. Both limbs
530+
// satisfy their range constraints, so the only thing rejecting it is the in-circuit "scalar < r"
531+
// check — exercising the "limbs in range but value out of field" path.
532+
TYPED_TEST(MultiScalarMulScalarBoundsTests, MaxRepresentableScalarFails)
533+
{
534+
BB_DISABLE_ASSERTS();
535+
MsmGrumpkinPoint point = MsmGrumpkinPoint::random_element();
536+
uint256_t max_representable = (uint256_t(1) << 254) - uint256_t(1);
537+
MsmGrumpkinPoint result = point * bb::fq(max_representable); // (2^254 - 1) mod r
538+
auto [constraint, witness] = TestFixture::make_msm(
539+
MsmAcirPoint::from_native(point), msm_scalar_from_u256(max_representable), MsmAcirPoint::from_native(result));
540+
541+
auto [ok, err] = TestFixture::run_circuit(constraint, witness);
542+
EXPECT_FALSE(ok) << "scalar == 2^254 - 1 (> Grumpkin modulus) must not produce a satisfiable circuit";
543+
}
544+
545+
// hi limb == 2^126 (one bit too wide), lo == 0, i.e. scalar value 2^254. Both the limb range
546+
// constraint (hi < 2^126) and the "scalar < r" check reject it.
547+
TYPED_TEST(MultiScalarMulScalarBoundsTests, ScalarWithOversizedHiLimbFails)
548+
{
549+
BB_DISABLE_ASSERTS();
550+
MsmGrumpkinPoint point = MsmGrumpkinPoint::random_element();
551+
uint256_t two_pow_254 = uint256_t(1) << 254;
552+
MsmGrumpkinPoint result = point * bb::fq(two_pow_254);
553+
MsmScalar scalar{ MsmFF(0), MsmFF(uint256_t(1) << 126) }; // hi has 127 bits
554+
auto [constraint, witness] =
555+
TestFixture::make_msm(MsmAcirPoint::from_native(point), scalar, MsmAcirPoint::from_native(result));
556+
557+
auto [ok, err] = TestFixture::run_circuit(constraint, witness);
558+
EXPECT_FALSE(ok) << "scalar hi limb of 127 bits (value 2^254) must not produce a satisfiable circuit";
559+
}
560+
561+
// Group-law equivalence does not transfer: s·P == (s + r)·P, but the circuit accepts only the
562+
// canonical scalar s. Adding the Grumpkin modulus to a scalar cannot reprove the same output.
563+
TYPED_TEST(MultiScalarMulScalarBoundsTests, AddingGrumpkinModulusDoesNotReproveSameOutput)
564+
{
565+
BB_DISABLE_ASSERTS();
566+
MsmGrumpkinPoint point = MsmGrumpkinPoint::random_element();
567+
bb::fq scalar_native = bb::fq(5);
568+
MsmGrumpkinPoint result = point * scalar_native;
569+
ASSERT_FALSE(result.is_point_at_infinity());
570+
571+
// Sanity: the canonical scalar proves the result.
572+
{
573+
auto [constraint, witness] = TestFixture::make_msm(
574+
MsmAcirPoint::from_native(point), MsmScalar::from_native(scalar_native), MsmAcirPoint::from_native(result));
575+
auto [ok, err] = TestFixture::run_circuit(constraint, witness);
576+
EXPECT_TRUE(ok) << "canonical scalar should prove the MSM result. err: " << err;
577+
}
578+
579+
// The non-canonical scalar s + r yields the same point mathematically, but the circuit rejects it.
580+
{
581+
auto [constraint, witness] = TestFixture::make_msm(MsmAcirPoint::from_native(point),
582+
msm_scalar_from_u256(uint256_t(5) + grumpkin_scalar_modulus),
583+
MsmAcirPoint::from_native(result));
584+
auto [ok, err] = TestFixture::run_circuit(constraint, witness);
585+
EXPECT_FALSE(ok) << "scalar s + r must not reprove the output of scalar s";
586+
}
587+
}

0 commit comments

Comments
 (0)