Summary
FinalizeTypes::matches_struct validates a cast to an external struct using a single Stack, and reuses that stack both to look up the struct definition and to resolve the operand register types. When it is called with the external program's stack (which is correct for the struct definition), it then resolves the cast's operand member accesses against that same external stack — so a member access on an external-struct register calls get_external_stack(<external program>) while already on that program's stack, and fails:
Attempted to get the main program '<external program>' as an external program.
The analogous RegisterTypes::matches_struct (functions/transitions) takes two stacks and does not have this problem — which is why the bug is finalize-only.
Version
v4.8.1 (commit b7f0859)
Reproducer (pure Aleo)
Add both programs to a Process (dependency order: lib.aleo first).
lib.aleo
program lib.aleo;
struct S:
hi as u128;
lo as u128;
function noop:
constructor:
assert.eq edition 0u16;
consumer.aleo
import lib.aleo;
program consumer.aleo;
function run:
input r0 as lib.aleo/S.private;
async run r0 into r1;
output r1 as consumer.aleo/run.future;
finalize run:
input r0 as lib.aleo/S.public;
cast r0.hi r0.lo into r1 as lib.aleo/S; // <-- member-access operands into an external-struct cast
assert.eq r1.lo r0.lo;
constructor:
assert.eq edition 0u16;
process.add_program(&consumer) fails during finalize type-checking with the error above. The same construction in a transition body (function, not finalize) validates fine, e.g. cast r0.hi r1.lo into r2 as lib.aleo/S.
Root cause
The two struct-cast type-checkers diverge (paths in synthesizer/process/src/stack):
-
Transition — register_types/matches.rs::matches_struct takes two stacks:
pub fn matches_struct(&self, operands_stack: &Stack<N>, stack: &Stack<N>, operands, struct_) -> Result<()>
Call site register_types/initialize.rs:550:
self.matches_struct(stack, &*external_stack, instruction.operands(), struct_)?;
Operand types are resolved against operands_stack (the caller), the struct definition against stack (external). Correct.
-
Finalize — finalize_types/matches.rs::matches_struct takes only one stack:
pub fn matches_struct(&self, stack: &Stack<N>, operands, struct_) -> Result<()>
Call site finalize_types/initialize.rs:999:
self.matches_struct(&*external_stack, instruction.operands(), struct_)?;
Inside, operand register types are resolved with self.get_type(stack, register) where stack is the external stack. For a member-access operand, get_type reaches the ExternalStruct(..) member branch (finalize_types/mod.rs:186):
let external_stack = stack.get_external_stack(locator.program_id())?;
Here stack already is that external program's stack, so program_id == self.program.id() and get_external_stack bails (helpers/stack_trait.rs:265).
So this is an inconsistency between the two parallel type-checkers: RegisterTypes::matches_struct was given a separate stack for operand-type resolution vs. struct-definition lookup, but FinalizeTypes::matches_struct was not.
Suggested fix
Give FinalizeTypes::matches_struct the same two-stack signature as RegisterTypes::matches_struct (an operands_stack for get_type on the operands, and the external stack for the struct definition), and update the finalize_types/initialize.rs:999 call site to pass both. Cast semantics for external structs in finalize should match transitions.
Context
Found via a Leo miscompile (ProvableHQ/leo#29586). Leo currently works around it in code generation by spilling member-access operands into plain registers before an external-struct cast in a finalize, but the underlying defect is here in snarkVM.
Summary
FinalizeTypes::matches_structvalidates acastto an external struct using a singleStack, and reuses that stack both to look up the struct definition and to resolve the operand register types. When it is called with the external program's stack (which is correct for the struct definition), it then resolves the cast's operand member accesses against that same external stack — so a member access on an external-struct register callsget_external_stack(<external program>)while already on that program's stack, and fails:The analogous
RegisterTypes::matches_struct(functions/transitions) takes two stacks and does not have this problem — which is why the bug is finalize-only.Version
v4.8.1(commitb7f0859)Reproducer (pure Aleo)
Add both programs to a
Process(dependency order:lib.aleofirst).lib.aleoconsumer.aleoprocess.add_program(&consumer)fails during finalize type-checking with the error above. The same construction in a transition body (function, notfinalize) validates fine, e.g.cast r0.hi r1.lo into r2 as lib.aleo/S.Root cause
The two struct-cast type-checkers diverge (paths in
synthesizer/process/src/stack):Transition —
register_types/matches.rs::matches_structtakes two stacks:Call site
register_types/initialize.rs:550:Operand types are resolved against
operands_stack(the caller), the struct definition againststack(external). Correct.Finalize —
finalize_types/matches.rs::matches_structtakes only one stack:Call site
finalize_types/initialize.rs:999:Inside, operand register types are resolved with
self.get_type(stack, register)wherestackis the external stack. For a member-access operand,get_typereaches theExternalStruct(..)member branch (finalize_types/mod.rs:186):Here
stackalready is that external program's stack, soprogram_id == self.program.id()andget_external_stackbails (helpers/stack_trait.rs:265).So this is an inconsistency between the two parallel type-checkers:
RegisterTypes::matches_structwas given a separate stack for operand-type resolution vs. struct-definition lookup, butFinalizeTypes::matches_structwas not.Suggested fix
Give
FinalizeTypes::matches_structthe same two-stack signature asRegisterTypes::matches_struct(anoperands_stackforget_typeon the operands, and the externalstackfor the struct definition), and update thefinalize_types/initialize.rs:999call site to pass both. Cast semantics for external structs in finalize should match transitions.Context
Found via a Leo miscompile (ProvableHQ/leo#29586). Leo currently works around it in code generation by spilling member-access operands into plain registers before an external-struct cast in a finalize, but the underlying defect is here in snarkVM.