Skip to content

Fix check_deployment disagreement due to non-deterministic signer - #3346

Open
Antonio95 wants to merge 11 commits into
stagingfrom
fix/dynamic_target_resolution_closure
Open

Fix check_deployment disagreement due to non-deterministic signer#3346
Antonio95 wants to merge 11 commits into
stagingfrom
fix/dynamic_target_resolution_closure

Conversation

@Antonio95

Copy link
Copy Markdown
Contributor

This PR fixes a problematic interaction between an issue in resolve_dynamic_target and non-deterministic sampling of a signer in some CallStack modes.

Issue

The function resolve_dynamic_target, called in synthesizer/process/src/stack/call/dynamic.rs::{execute, evaluate}, returns a Result<Option<ResolvedTarget>>. In the two CallStack modes it refers to as "dummy", i.e.
Synthesize and CheckDeployment, it returns Ok<None> in some cases and Err in others (specifically, Err is returned when the target can be resolved but it points to a closure). The correct thing to do would be to return Ok<None> in all cases for those two modes, since in those no dynamic calls are actually followed through and executed (one is synthesising a single function's circuit, and closures cannot be called via call.dynamic). This is indeed the way resolve_dynamic_target was documented - but not implemented. The documentation has been updated to match actual behaviour.

As can be seen in execute, the target returned by resolve_dynamic_target is never used in the dummy modes. However, execute always applies ? to the Result<<Option<ResolvedTarget>>>, which means execute fails (in a controlled manner) when the register values as they exist during synthesis happen to cause the target to be an existing closure. In particular, verify_deployment rejects it.

So far this would mainly be a usability issue, where technically valid programs could not be deployed. However, one element is not sampled deterministically when starting circuit Synthesize and CheckDeployment modes: the signer, which is sampled from the OS's rng instead of the deterministic seeded_rng used for function inputs, etc. This makes easy to construct simple programs where vm.deploy and vm.check_deployment succeed or fail randomly depending on the OS's unpredictable rng, leading to a disagreement between validators: simply have the target of a call.dynamic instruction depend on the signer (for instance, it's last bit), being an existing closure in some cases and a non-existent target (or a function) in others. Cf. the test https://github.com/ProvableHQ/snarkVM/blob/fix/dynamic_target_resolution_closure/synthesizer/src/vm/tests/test_v19/closure_dynamic_targets.rs#L21.

Fix

Two options present themselves:

  1. Sample the signer from seeded_rng instead of rng. This means if one validator rejects the deployment, all do. This does not need to be version guarded, since in principle it is switching from "machine-dependent random, sometimes correct, sometimes not" to "deterministic, correct".
  2. Modify resolve_dynamic_target to always return Ok<None> in Synthesize and CheckDeployment modes, as mentioned above. This was done in this PR (ccfe407) but has since been reverted.

Point 2 would more robust and future-proof, as it also guards against other sources of randomness we may have missed (none have been found after an AI-aided search) or which may be introduced in the future. However, the call to resolve_dynamic_target lives in an internal part of the stack we do not want to be consensus-version-dependent. For that reason, we implement point 1 only (which is a positive change in itself). As a result, pathological Aleo programs such as the one proposed may either be rejected by all validators, or accepted by all, i.e. the risk of forking is avoided. The fact that such a program (which technically has valid Aleo instructions) could be rejected at random is a minor usability issue given its pathological semantics (a dynamic call can never execute pointing to a closure, so a program definition which allows that in some register state is, at best, questionable).

A way to implement point 2 and circumvent the ConsensusVersion threading issue would be to add a boolean flag to CallStack::Synthesize and CallStack::CheckDepoyment to mark which version of resolve_dynamic_target to use (fixed one in the since reverted ccfe407). @raychu86 let me know if you prefer this option (to the current state, i.e. not implementing point 2).

Tests

One small test has been added which tests the narrow type of problematic programs: test_v19::test_conditional_dynamic_call_target_deployment. Details therein.

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 addresses a consensus-risky nondeterminism in deployment checking by making the “dummy” signer used during CheckDeployment circuit synthesis deterministic (derived from a seeded RNG), preventing validator disagreements when call.dynamic targets depend on self.signer.

Changes:

  • Make the burner private key (and thus self.signer) deterministic during deployment verification by sampling it from the deployment-ID-seeded RNG.
  • Update resolve_dynamic_target documentation to reflect current behavior in Synthesize / CheckDeployment modes.
  • Add a V19 regression test covering dynamic target resolution involving closures, and update several related comments for clarity.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
synthesizer/process/src/stack/deploy.rs Samples burner private key from a deployment-ID-seeded RNG to make CheckDeployment deterministic.
synthesizer/process/src/stack/call/dynamic.rs Updates documentation for resolve_dynamic_target return behavior (esp. closure cases).
synthesizer/src/vm/tests/test_v19/closure_dynamic_targets.rs Adds regression test for deterministic verify_deployment behavior with closure-vs-function dynamic targets.
synthesizer/src/vm/tests/test_v19/mod.rs Registers the new V19 test module.
synthesizer/src/vm/tests/test_v18/mod.rs Comment wording/line-wrapping cleanup.
synthesizer/process/src/stack/helpers/synthesize.rs Comment clarifying is_root = true semantics during synthesis.
synthesizer/process/benches/check_deployment.rs Comment clarifying is_root = true semantics in benchmark setup.
synthesizer/process/src/tests/test_credits.rs Comment clarifying is_root is set (not sampled).
circuit/program/src/request/verify.rs Comment clarifying is_root = true semantics in circuit-side request verification tests.

Comment on lines +18 to +19
// Deploys a program whose function points the target of a call.dynamic instruction to a closure or a
// function depending on the last bit of self.signer. The test checks several runs of test-
Comment on lines 159 to +163
for (function, (_, (verifying_key, _))) in
deployment.program().functions().values().zip_eq(deployment.function_verifying_keys())
{
// Initialize a burner private key.
let burner_private_key = PrivateKey::new(rng)?;
let burner_private_key = PrivateKey::new(&mut seeded_rng)?;
Comment on lines +59 to +83
for i in 0..N_EXPERIMENTS {
let deployment_attempt = process.deploy::<CurrentAleo, _>(&parse_program(i), rng);

match deployment_attempt {
Ok(deployment) => {
println!(" - Deployment computation {i} succeeded with ID {}", deployment.to_deployment_id().unwrap());

// Ensure that different runs of verify_deployment agree on the result - even if it is a rejection.
let verification_successful =
process.verify_deployment::<CurrentAleo, _>(ConsensusVersion::V19, &deployment, rng).is_ok();
for _ in 1..N_EXPERIMENTS {
assert_eq!(
verification_successful,
process.verify_deployment::<CurrentAleo, _>(ConsensusVersion::V19, &deployment, rng).is_ok(),
"Verifier disagreement during deployment verification",
);
}
println!(" All verifiers {}", if verification_successful { "accepted" } else { "rejected" });
}
Err(error) => {
println!(" - Deployment computation {i} failed: {error}");
}
}
}
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The PR description and the documentation of the test makes this very clear. The only guaranteed property being tested is that all verifiers agree on the decision - not that at least one of them accepts (which would in fact defeat the purpose of this PR other verifiers did not).

@raychu86

raychu86 commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

A way to implement point 2 and circumvent the ConsensusVersion threading issue would be to add a boolean flag to CallStack::Synthesize and CallStack::CheckDepoyment to mark which version of resolve_dynamic_target to use (fixed one in the since reverted ccfe407). @raychu86 let me know if you prefer this option (to the current state, i.e. not implementing point 2).

I'd like to keep the CallStack minimally altered if we can, unless the problem is serious enough. This issue seems a bit more of an inconvenience for the developer.

I recall at some point there was a similar false-negative (incorrectly rejecting) case for standard deployments as well. Does Fix 2 address that as well?

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