Skip to content

Future-proof Scalar conversion to bits#3293

Draft
Antonio95 wants to merge 4 commits into
stagingfrom
scalar_conversion
Draft

Future-proof Scalar conversion to bits#3293
Antonio95 wants to merge 4 commits into
stagingfrom
scalar_conversion

Conversation

@Antonio95

@Antonio95 Antonio95 commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

(Edit: Converted to draft pending investigation on the possibility of bad-circuit construction)

A Scalar circuit value is stored as an underlying Field (i.e. base-field) element. No range checks (i.e. checks that the bit-decomposition of the Field corresponds to the canonical representation of a Scalar and not, say, to a larger value) occur upon injection. These checks do happen whenever Scalar::to_bits is called in-circuit (which is also triggered by many other circuit operations)

This is not an issue today because the Aleo-instructions language (used to construct transition circuits), record translation and record inclusion all result in circuits satisfying the following: every Scalar injected into the circuit is converted to_bits (and therefore range-checked) at some later point during synthesis. As an example, private and public transition inputs are wrapped in a Plaintext that gets converted to_bits when the circuit processes the input, i.e. hashes it (public) or encrypts it (private).

This PR future-proofs synthesis against failure to convert to bits (i.e. to range-check) all Scalars. See also the note at the end of the next section for potential independent uses this PR could have in the future.

Solution

  • Each time a Scalar is injected into a circuit, it is added to a tracking set.
  • Each time an in-circuit Scalar is converted to bits (which might in turn be caused by many higher-level operations), it is removed from the tracking set.
  • During synthesis of transition, translation and inclusion (but not: inclusion V0) circuits, right before ejection of the R1CS or assignment (eject_r1cs_and_reset / eject_assignment_and_reset), all Scalars not yet converted to bits are so (and i.p. are range-checked).

The reason for this flow as opposed to simply range-checking upon injection is that the latter would not be backwards-compatible (right now, many deployed circuits perform some operations between Scalar injection and whatever operation it is which triggers to_bits), but the implemented flow is (cf. next section).

Going into some more detail, it was not possible to have the tracking set simply contain the variable identifier of still-unconverted Scalars: because tracking occurs at a lower level than most of our Aleo-type-specific circuit machinery, it is not aware of the existence of a to_bits function it can call directly. For that reason, when tracking a new Scalar, an abstract function is stores alongside it, which is always to_bits in the case of interest. This abstract function which the lower-level knows nothing about is what is called in convert_unconverted_values before ejection.

Note: The *_unconverted_values machinery implemented can also be used in the future whenever we need to be able to a) track circuit values, b) untrack circuit values, and c) guarantee that a certain closure is called before the end of synthesis for all still-untracked values.

Backwards-compatibility

It has been checked that all mainnet, test and canary circuits in the regressions repository already convert all of their Scalars to bits. In particular, their synthesis remains unaffected after the implemented change, the original certificates also validate and no redeployments are needed.

This means the change does not need to be version guarded.

Tests

The main checks were carried out in a separate repo and consisted of the aforementioned regression checks that the updated synthesis doesn’t affect existing transition/translation circuits (or the inclusion one).

A new small test has been added: test_unconverted_scalar_rejection, which checks eject_r1cs_and_reset and eject_assignment_and_reset indeed reject circuits with still-unconverted Scalars. This fact was also temporarily tested while running the regression tests.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces end-of-synthesis safety checks to ensure circuit Scalar values are range-checked (via to_bits) even if higher-level circuit operations never trigger bit-conversion during synthesis, preventing future synthesis flows from accidentally producing unchecked scalars.

Changes:

  • Track injected circuit Scalars and automatically convert any still-unconverted ones to bits before circuit ejection in key synthesis flows.
  • Untrack Scalars when to_bits_le is invoked (i.e., after the in-circuit range check occurs).
  • Add tests/bench updates to validate rejection behavior and ensure updated synthesis flows succeed.

Reviewed changes

Copilot reviewed 18 out of 18 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
synthesizer/src/vm/tests/test_v14/mod.rs Adds a deployment-oriented regression test involving scalar usage.
synthesizer/process/src/trace/translation/assignment.rs Calls convert_unconverted_values() before assignment ejection for translation circuits.
synthesizer/process/src/trace/inclusion/assignment.rs Calls convert_unconverted_values() before assignment ejection for inclusion circuits.
synthesizer/process/src/trace/inclusion/assignment_v0.rs Calls convert_unconverted_values() before assignment ejection for inclusion V0 circuits.
synthesizer/process/src/stack/execute.rs Ensures pending tracked values are converted before ejecting circuit assignment.
synthesizer/process/src/stack/call/standard.rs Ensures pending tracked values are converted before ejecting circuit R1CS for standard calls.
synthesizer/process/src/stack/call/dynamic.rs Ensures pending tracked values are converted before ejecting circuit R1CS for dynamic calls.
synthesizer/benches/kary_merkle_tree.rs Converts pending tracked values prior to bench circuit assignment ejection.
ledger/puzzle/epoch/src/synthesis/program/to_r1cs.rs Converts pending tracked values prior to R1CS ejection in epoch program synthesis.
circuit/types/scalar/src/lib.rs Tracks newly injected scalars and registers a deferred to_bits conversion closure.
circuit/types/scalar/src/helpers/to_bits.rs Untracks scalars once to_bits_le has range-checked them.
circuit/environment/src/testnet_circuit.rs Enforces empty “unconverted values” set on ejection; clears it on reset.
circuit/environment/src/helpers/linear_combination.rs Widens LinearCombination::to_terms visibility to pub(crate) for tracking.
circuit/environment/src/helpers/assignment.rs Adds a test ensuring ejection rejects circuits with unconverted scalars.
circuit/environment/src/environment.rs Implements the unconverted-value tracking set + conversion/check/clear APIs.
circuit/environment/src/circuit.rs Enforces empty “unconverted values” set on ejection; clears it on reset.
circuit/environment/src/canary_circuit.rs Enforces empty “unconverted values” set on ejection; clears it on reset.
.circleci/config.yml Adds a branch-name condition to run expensive merge-workflow jobs.

if set.is_empty() {
Ok(())
} else {
Err(format!("{} unconverted value(s) remain(s) in the tracking set: {:#?}", set.len(), set.keys()))
Comment on lines +25 to +32
thread_local! {
/// Stores certain injected values which have not been converted to bits yet, alongside a
/// closure that converts each of them to bits.
// This gives higher-level crates the ability to track any values which must be converted to
// bits before the end of synthesis (e.g. Scalars, which are otherwise never range-checked) and
// convert them if they have not been.
static UNCONVERTED_VALUES: RefCell<BTreeMap<u64, Box<dyn FnOnce()>>> = RefCell::new(BTreeMap::new());
}
Comment on lines +426 to +448
match (eject_assignment, inject_unconverted_scalar) {
// Ejection must fail when an injected scalar was never converted to bits.
(true, true) => {
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(AleoV0::eject_assignment_and_reset));
assert!(result.is_err());
AleoV0::reset();
}
(false, true) => {
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(AleoV0::eject_r1cs_and_reset));
assert!(result.is_err());
AleoV0::reset();
}
// Ejection succeeds when every injected scalar was converted to bits.
(true, false) => {
let _assignment = AleoV0::eject_assignment_and_reset();
}
(false, false) => {
let _r1cs = AleoV0::eject_r1cs_and_reset();
}
}

AleoV0::reset();
}
Comment on lines +91 to +93
// Convert all tracked, still-unconverted values to bits.
A::convert_unconverted_values();

Comment thread .circleci/config.yml
Comment on lines 1289 to +1293
or pipeline.git.branch == "canary"
or pipeline.git.branch == "testnet"
or pipeline.git.branch == "mainnet"
or pipeline.git.branch == "ensure_finalize_scopes_match"
or pipeline.git.branch == "scalar_conversion"
}

// Convert all tracked, still-unconverted values to bits.
A::convert_unconverted_values();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This runs after the is_satisfied() check above, so any constraint added by the conversion here would not be covered by that check and would only fail at prove time. Should the conversion happen before is_satisfied() so a newly added range check is still gated by it?

@Antonio95 Antonio95 marked this pull request as draft June 10, 2026 08:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants