Fix stack handling in casts involving external structs - #3336
Conversation
There was a problem hiding this comment.
Pull request overview
This PR fixes finalize-scope type checking for cast instructions involving external structs by ensuring operand type resolution uses the instruction’s stack while struct layout resolution uses the struct-definition stack, aligning FinalizeTypes::matches_struct behavior with the already-correct RegisterTypes::matches_struct.
Changes:
- Update
FinalizeTypes::matches_structto accept two stacks (instruction vs. definition) and use them appropriately during struct-cast checking. - Clarify stack roles in the parallel
RegisterTypes::matches_structimplementation and related type-equivalence plumbing. - Add comprehensive V18 VM tests covering external-struct casts across structs, arrays, and records.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| synthesizer/src/vm/tests/test_v18/mod.rs | Adds the new V18 test module for external-struct stack/type-check scenarios. |
| synthesizer/src/vm/tests/test_v18/external_struct_stacks.rs | Introduces extensive VM tests for external-struct casts (function + finalize) including nested member accesses and container types. |
| synthesizer/program/src/traits/stack_and_registers.rs | Adds clarifying parameter comments for stack-aware type equivalence. |
| synthesizer/process/src/stack/register_types/matches.rs | Renames/clarifies stack parameters and ensures operand vs. definition stack usage is explicit for struct matching. |
| synthesizer/process/src/stack/finalize_types/matches.rs | Fixes finalize struct matching by splitting instruction-stack vs. definition-stack responsibilities. |
| synthesizer/process/src/stack/finalize_types/initialize.rs | Updates struct-cast call sites to pass both stacks into the new finalize matches_struct signature. |
Comments suppressed due to low confidence (1)
synthesizer/src/vm/tests/test_v18/external_struct_stacks.rs:409
- Typo/inconsistency in the comment: "finalise/FinaliseType" should be "finalize/FinalizeType".
// Checks that various instances of casts to arrays involving external structs and their members, as
// well as structs having arrays as members, work as expected. This is tested in both the function
// (i.e. private, RegisterType) setting as well as the public (i.e. finalise, FinaliseType) one.
| // Checks that various instances of casts to external structs or from using external-structs members | ||
| // function as expected. This is tested in both the function (i.e. private, RegisterType) setting as | ||
| // well as the public (i.e. finalise, FinaliseType) one. |
|
|
||
| output r3 as struct_c.private; | ||
|
|
||
| // Case 2 / function: Similar to case 1 but with a cast involving a external-struct member read (r1.val) into an external-struct. |
|
|
||
| output r3 as u8.private; | ||
|
|
||
| // Case 8 / function: Tests a casts to external struct one of whose operands involves a two-level struct access. |
| // Tests for stack fetching relevant to the record-existence check. | ||
| mod record_existence_stacks; | ||
|
|
||
| // Tests on stack fetching for external-struct type checks |
| ", | ||
| )?; | ||
|
|
||
| // This is a distinct program from program_b.aleo, but both declare a struct with the same name |
There was a problem hiding this comment.
Maybe add a rejection case where same-named structs have different member layouts; the current definitions are equivalent, so a stack mix-up could still pass. A constructor or view case would also cover the other scopes sharing FinalizeTypes.
|
|
||
| let vm = sample_vm_at_height(CurrentNetwork::CONSENSUS_HEIGHT(ConsensusVersion::V18)?, rng); | ||
|
|
||
| let transaction = vm.deploy(&deployer_private_key, &program_a, None, 0, None, rng)?; |
There was a problem hiding this comment.
Could most cases use Process::add_program instead of full deployment/proof/execution flows? The bug occurs during stack type checking; keeping one end-to-end case and making the rest focused tests would reduce CI time and duplication.
|
|
||
| cast r0 into r1 as program_b.aleo/struct_b; | ||
| cast r0 r1 into r2 as struct_c; | ||
|
|
There was a problem hiding this comment.
Trailing spaces on this blank line can be removed.
This reverts commit 141bbd4.
|
After the internal discussions on consensus gating, I think I have a solution which is strictly consensus-preserving (i.e. the new code at < V19 runs exactly like the old code) backwards compatibility while not threading the consensus version to any of the delicate internal places we wanted to avoid. The idea is:
Small caveat: The new legacy check has only been added to the verifier side, to As part of these latest changes, I have done the following:
I have not addressed the original review comments yet, will do that shortly. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (5)
synthesizer/src/vm/verify.rs:398
- For
consensus_version < V19, the legacy gate is intended to mirror the pre-V19 (buggy) cast-to-external-struct check, butcheck_scopecurrently callsfinalize_types.check_command(...)on every command first.check_commandwill run the post-fix external-struct cast validation (viaFinalizeTypes::check_instruction/matches_struct(stack, external_stack, ...)) and can therefore reject a deployment that the actual pre-V19 logic would have accepted, which is a consensus risk. Consider adding a legacy-only path that accumulates register types while skipping (or using the legacy single-stack version of) the external-struct cast operand check.
for command in commands {
// This also helps accumulate the types of non-input registers.
finalize_types.check_command(stack, positions, command)?;
synthesizer/process/src/stack/finalize_types/initialize.rs:239
FinalizeTypes::check_inputandcheck_commandwere made public to support the legacy verification code path. This expands the public API surface ofsnarkvm_synthesizer_processwith methods that look like internal helpers and may be hard to keep stable. Consider keeping these helperspub(crate)and exposing a single purpose-built API for the legacy pre-V19 check instead.
/// Ensure the given input register is well-formed.
pub fn check_input(
&mut self,
stack: &Stack<N>,
register: &Register<N>,
finalize_type: &FinalizeType<N>,
) -> Result<()> {
// Ensure the register type is defined in the program.
match finalize_type {
FinalizeType::Plaintext(plaintext_type) => RegisterTypes::check_plaintext_type(stack, plaintext_type)?,
FinalizeType::Future(locator) => {
ensure!(
stack.program().contains_import(locator.program_id()),
"Program '{locator}' is not imported by '{}'.",
stack.program().id()
)
}
FinalizeType::DynamicFuture => {} // Do nothing.
};
// Insert the input register.
self.add_input(register.clone(), finalize_type.clone())?;
// Ensure the register type and the input type are equivalent.
if !finalize_types_equivalent(stack, finalize_type, stack, &self.get_type(stack, register)?)? {
bail!("Input '{register}' does not match the expected input register type.")
}
Ok(())
}
/// Ensures the given command is well-formed.
#[inline]
pub fn check_command(
&mut self,
stack: &Stack<N>,
positions: &HashMap<Identifier<N>, usize>,
command: &Command<N>,
) -> Result<()> {
synthesizer/src/vm/tests/test_v19/external_struct_stacks.rs:20
- Spelling in the test header comment uses "finalise" / "FinaliseType" but the code and types are spelled "finalize" /
FinalizeType. Keeping terminology consistent makes these tests easier to search and cross-reference with the implementation.
// Checks that various instances of casts to external structs or from using external-structs members
// function as expected. This is tested in both the function (i.e. private, RegisterType) setting as
// well as the public (i.e. finalise, FinaliseType) one.
synthesizer/src/vm/verify.rs:250
- The legacy-consensus comment uses "finalise-type" (British spelling) while the rest of the codebase uses "finalize". This makes the gating documentation harder to grep and can be confusing alongside the
FinalizeTypestype name.
This issue also appears on line 395 of the same file.
// If the `CONSENSUS_VERSION` is less than `V19`, ensure that
// - the program does not break the pre-V19 version of `matches_struct` in casts to external structs within finalise-type scopes.
.circleci/config.yml:1327
- Adding a feature-branch name to the
merge-workflowcondition can unintentionally trigger very heavy CI jobs (ledger partitions, prerelease suites, etc.) on non-merge branches, increasing CI cost and queue time. If this was only for temporary debugging, it should be removed before merging.
when: pipeline.git.branch == "staging"
or pipeline.git.branch == "canary"
or pipeline.git.branch == "testnet"
or pipeline.git.branch == "mainnet"
or pipeline.git.branch == "fix/external_struct_stacks"
Closes #3330, see details therein.
This PR is in draft mode until a decision is made on consensus gating.
The issue boiled down to the fact that
FinalizeTypes::matches_struct(here: https://github.com/ProvableHQ/snarkVM/blob/staging/synthesizer/process/src/stack/finalize_types/matches.rs#L20) only received one stack when actually two separate ones could be involved (one for the target struct and one for the operand(s') struct(s)). TheStack(id)type needs information about the program it refers to in order to be uniquely determined.This problem had already been detected and fixed for the function (i.e. private) scope, corresponding to
RegisterTypes::matches_struct(here: https://github.com/ProvableHQ/snarkVM/blob/staging/synthesizer/process/src/stack/register_types/matches.rs#L20).This PR fixes the issue by porting those simple changes to
FinalizeTypes::matches_struct. It also adds many intentionally designed test cases involving relatively complex casts (insynthesizer/src/vm/tests/test_v18/external_struct_stacks.rs). Aside from the pure struct case, that file includes test functions focused on arrays and records containing external structs. No issues have been found than inFinalizeTypes::matches_struct, but this part of the language is difficult to test exhaustively so we should keep an eye out in the future.Aside from the fix, the PR includes several non-functional changes to a couple of functions involved in the check to improve code clarity (e.g. renaming the stacks received by
matches_struct).