diff --git a/console/network/src/lib.rs b/console/network/src/lib.rs index 76efcca56f..afd17f0a5a 100644 --- a/console/network/src/lib.rs +++ b/console/network/src/lib.rs @@ -234,6 +234,10 @@ pub trait Network: const MAX_INSTRUCTIONS: usize = u16::MAX as usize; /// The maximum number of commands in finalize. const MAX_COMMANDS: usize = u16::MAX as usize; + /// The maximum number of `call` commands in a finalize body. Matched to + /// `Transaction::MAX_TRANSITIONS` so view-call arity in a finalize is bounded analogously + /// to the static-call bound on transition graphs. + const MAX_CALLS: usize = 32; /// The maximum number of write commands in finalize. const MAX_WRITES: [(ConsensusVersion, u16); 2] = [(ConsensusVersion::V1, 16), (ConsensusVersion::V14, 32)]; /// The maximum number of `position` commands in finalize. diff --git a/synthesizer/process/src/cost.rs b/synthesizer/process/src/cost.rs index 6cbd89795b..81386be200 100644 --- a/synthesizer/process/src/cost.rs +++ b/synthesizer/process/src/cost.rs @@ -543,7 +543,26 @@ pub fn cost_per_command( Command::Instruction(Instruction::AssertEq(_)) => Ok(500), Command::Instruction(Instruction::AssertNeq(_)) => Ok(500), Command::Instruction(Instruction::Async(_)) => bail!("'async' is not supported in finalize"), - Command::Instruction(Instruction::Call(_)) => bail!("'call' is not supported in finalize"), + Command::Instruction(Instruction::Call(call)) => { + // From a finalize body, `call` is permitted only when the target resolves to a + // view function (validated at `Stack::new`). Roll up the called view's worst-case + // body cost into the caller's finalize cost, mirroring how function-to-function + // call costs already aggregate. Same-program targets reuse the current stack; + // cross-program targets resolve through the external stack. + // + // Recursion bound: `view_cost_for_single_view` re-enters `cost_per_command` on the + // view's body, but views reject `is_call()` at construction (`ViewCore::add_command`) + // and again at deploy via `FinalizeTypes::from_view`. So this recursion is at most + // one level deep — a Call in a finalize body, never a Call inside a view body. + use snarkvm_synthesizer_program::CallOperator; + match call.operator() { + CallOperator::Locator(locator) => { + let external_stack = stack.get_external_stack(locator.program_id())?; + view_cost_for_single_view(&*external_stack, locator.resource(), consensus_fee_version) + } + CallOperator::Resource(name) => view_cost_for_single_view(stack, name, consensus_fee_version), + } + } Command::Instruction(Instruction::CallDynamic(_)) => { bail!("'{}' is not supported in finalize", CallDynamic::::opcode()) } @@ -1025,9 +1044,8 @@ fn view_cost_for_single_view( consensus_fee_version: ConsensusFeeVersion, ) -> Result { let view = stack.program().get_view_ref(view_name)?; - - // View types are not cached on the stack today; recompute them here for the cost walk. - let view_types = FinalizeTypes::from_view(stack, view)?; + // Use the cached view types (computed once at `Stack::new`). + let view_types = stack.get_view_types(view_name)?; let mut view_cost = 0u64; for command in view.commands() { diff --git a/synthesizer/process/src/finalize.rs b/synthesizer/process/src/finalize.rs index 455467b6ef..130835774b 100644 --- a/synthesizer/process/src/finalize.rs +++ b/synthesizer/process/src/finalize.rs @@ -769,13 +769,15 @@ fn initialize_finalize_state( // A helper function to finalize all commands except `await`, updating the finalize operations and the counter. // // Generic over the store so the view evaluator (which passes either the canonical -// `FinalizeStore` or a read-only historic adapter) can reuse this dispatch. +// `FinalizeStore` or a read-only historic adapter) can reuse this dispatch. The stack must be +// the concrete `Stack` so we can resolve `Call`-to-view targets and read their cached +// `FinalizeTypes` (the in-block call path needs concrete access). #[inline] pub(crate) fn finalize_command_except_await( program_id: Option<(ProgramID, u16)>, resource: Option>, - store: &impl FinalizeStoreTrait, - stack: &impl StackTrait, + store: &dyn FinalizeStoreTrait, + stack: &Stack, registers: &mut FinalizeRegisters, positions: &HashMap, usize>, command: &Command, diff --git a/synthesizer/process/src/lib.rs b/synthesizer/process/src/lib.rs index 228ae4711f..b717e767d8 100644 --- a/synthesizer/process/src/lib.rs +++ b/synthesizer/process/src/lib.rs @@ -36,7 +36,6 @@ mod deploy; mod evaluate; mod execute; mod finalize; -#[cfg(feature = "history")] mod view; #[cfg(feature = "history")] pub use view::evaluate_view_at_height; diff --git a/synthesizer/process/src/stack/finalize_types/initialize.rs b/synthesizer/process/src/stack/finalize_types/initialize.rs index 25458b3270..7db0bfe5a2 100644 --- a/synthesizer/process/src/stack/finalize_types/initialize.rs +++ b/synthesizer/process/src/stack/finalize_types/initialize.rs @@ -821,8 +821,13 @@ impl FinalizeTypes { operand_types.push(RegisterType::from(self.get_type_from_operand(stack, operand)?)); } - // Compute the destination register types. - let destination_types = instruction.output_types(stack, &operand_types)?; + // Compute the destination register types. `CallDynamic` is rejected by + // `Finalize::add_command` and so is unreachable here; we bail explicitly to keep the + // assumption checked in code rather than relying solely on the upstream guard. + let destination_types = match instruction { + Instruction::CallDynamic(_) => bail!("'call.dynamic' is not allowed in finalize"), + _ => instruction.output_types(stack, &operand_types)?, + }; // Insert the destination register. for (destination, destination_type) in @@ -873,7 +878,50 @@ impl FinalizeTypes { bail!("Instruction 'async' is not allowed in 'finalize' or 'constructor'."); } Opcode::Call(_) => { - bail!("Instruction 'call' is not allowed in 'finalize' or 'constructor'."); + // `call` is permitted in finalize only when the target resolves to a view + // function. (Constructors and views themselves reject `call` at construction + // time via their `add_command` guards, so the only commands reaching here are + // from finalize bodies. `Instruction::CallDynamic` is rejected at construction + // by `Finalize::add_command`, so the `_` arm below is unreachable in practice.) + let call = match instruction { + Instruction::Call(call) => call, + _ => bail!("Instruction '{instruction}' is not a 'call' operation."), + }; + // The self-locator and import-existence checks here intentionally mirror the + // transition-context `Opcode::Call` arm in + // `register_types/initialize.rs::check_instruction_opcode`. The two arms + // diverge on what they allow as a target (views here vs. functions/closures + // there), but the locator-resolution preamble must remain in sync — keep + // both sites updated together when changing imports/locator semantics. + // + // Hold the external stack (if any) in this binding so the borrowed + // `target_program` reference stays valid for the view check below. + let external_stack; + let (target_program, target_name) = match call.operator() { + snarkvm_synthesizer_program::CallOperator::Locator(locator) => { + // Cross-program: resolve the external stack and use its program. + if stack.program_id() == locator.program_id() { + bail!("Locator '{locator}' does not reference an external view."); + } + if !stack.program().imports().keys().contains(locator.program_id()) { + bail!( + "External program '{}' is not imported by '{}'.", + locator.program_id(), + stack.program_id() + ); + } + external_stack = stack.get_external_stack(locator.program_id())?; + (external_stack.program(), *locator.resource()) + } + snarkvm_synthesizer_program::CallOperator::Resource(name) => (stack.program(), *name), + }; + if target_program.get_view_ref(&target_name).is_err() { + bail!( + "Instruction 'call' in finalize must target a view; '{}/{}' is not a view.", + target_program.id(), + target_name + ); + } } Opcode::Cast(opcode) => match opcode { "cast" => { diff --git a/synthesizer/process/src/stack/helpers/check_upgrade.rs b/synthesizer/process/src/stack/helpers/check_upgrade.rs index 5d9e3a94f3..07938c5a69 100644 --- a/synthesizer/process/src/stack/helpers/check_upgrade.rs +++ b/synthesizer/process/src/stack/helpers/check_upgrade.rs @@ -34,6 +34,7 @@ impl Stack { /// | closure | ❌ | ❌ | ✅ | /// | function | ❌ | ✅ (logic) | ✅ | /// | finalize | ❌ | ✅ (logic) | ✅ | + /// | view | ❌ | ✅ (logic) | ✅ | /// |-------------------|--------|--------------|-------| /// /// There is one important caveat in that output register indices **MUST** remain the same. @@ -128,6 +129,21 @@ impl Stack { } } } + // Ensure that each old view exists in the new program with the same interface. View + // bodies are mutable (same policy as function/finalize logic) — only the externally + // visible input and output types must remain stable for downstream callers. + for old_view in old_program.views().values() { + let old_view_name = old_view.name(); + let new_view = new_program.get_view_ref(old_view_name)?; + ensure!( + old_view.input_types() == new_view.input_types(), + "Cannot upgrade '{program_id}' because the input types of the view '{old_view_name}' do not match" + ); + ensure!( + old_view.output_types() == new_view.output_types(), + "Cannot upgrade '{program_id}' because the output types of the view '{old_view_name}' do not match" + ); + } Ok(()) } } diff --git a/synthesizer/process/src/stack/helpers/initialize.rs b/synthesizer/process/src/stack/helpers/initialize.rs index 4d6722701d..fc1fbf2eeb 100644 --- a/synthesizer/process/src/stack/helpers/initialize.rs +++ b/synthesizer/process/src/stack/helpers/initialize.rs @@ -52,6 +52,7 @@ impl Stack { constructor_types: Default::default(), register_types: Default::default(), finalize_types: Default::default(), + view_types: Default::default(), universal_srs: process.universal_srs().clone(), proving_keys: Default::default(), verifying_keys: Default::default(), diff --git a/synthesizer/process/src/stack/helpers/stack_trait.rs b/synthesizer/process/src/stack/helpers/stack_trait.rs index 05662c4f23..4a1b52a378 100644 --- a/synthesizer/process/src/stack/helpers/stack_trait.rs +++ b/synthesizer/process/src/stack/helpers/stack_trait.rs @@ -456,6 +456,16 @@ impl StackTrait for Stack { // Sample the record with that nonce. self.sample_record(burner_address, record_name, record_nonce, rng) } + + fn evaluate_view( + &self, + state: FinalizeGlobalState, + store: &dyn FinalizeStoreTrait, + view_name: &Identifier, + inputs: Vec>, + ) -> Result>> { + crate::view::evaluate_view_inner(state, store, self, view_name, inputs) + } } impl Stack { diff --git a/synthesizer/process/src/stack/mod.rs b/synthesizer/process/src/stack/mod.rs index 7d1d4772ef..4fdda7b5db 100644 --- a/synthesizer/process/src/stack/mod.rs +++ b/synthesizer/process/src/stack/mod.rs @@ -74,6 +74,8 @@ use snarkvm_synthesizer_error::*; use snarkvm_synthesizer_program::{ CallOperator, Closure, + FinalizeGlobalState, + FinalizeStoreTrait, Function, Instruction, Operand, @@ -257,6 +259,8 @@ pub struct Stack { register_types: Arc, RegisterTypes>>>, /// The mapping of finalize names to their register types. finalize_types: Arc, FinalizeTypes>>>, + /// The mapping of view function names to their register types. + view_types: Arc, FinalizeTypes>>>, /// The universal SRS. universal_srs: UniversalSRS, /// The mapping of function name or record name to proving key. @@ -319,15 +323,17 @@ impl Stack { /// Initializes and checks the register state and well-formedness of the stack, even if it has already been initialized. pub fn initialize_and_check(&self, process: &Process) -> Result<()> { - // Acquire the locks for the constructor, register, and finalize types. + // Acquire the locks for the constructor, register, finalize, and view types. let mut constructor_types = self.constructor_types.write(); let mut register_types = self.register_types.write(); let mut finalize_types = self.finalize_types.write(); + let mut view_types = self.view_types.write(); - // Clear the existing constructor, closure, and function types. + // Clear the existing constructor, closure, function, and view types. constructor_types.take(); register_types.clear(); finalize_types.clear(); + view_types.clear(); // Add all the imports into the stack. for import in self.program.imports().keys() { @@ -379,17 +385,21 @@ impl Stack { } } - // Type-check every view function. The result is not cached on the stack here; - // it is recomputed by the view evaluator. This is acceptable for the prototype - // and ensures that ill-typed views are rejected at deploy time. + // Type-check every view function and cache the result. The cached types are read by + // both the external view path (`evaluate_view_at_height`) and the in-block call path + // when finalize calls a view, so we avoid recomputing them on every invocation. for view in self.program.views().values() { - let _ = FinalizeTypes::from_view(self, view)?; + let name = view.name(); + ensure!(!view_types.contains_key(name), "View '{name}' already exists"); + let types = FinalizeTypes::from_view(self, view)?; + view_types.insert(*name, types); } // Drop the locks since the types have been initialized. drop(constructor_types); drop(register_types); drop(finalize_types); + drop(view_types); // Check that the functions are valid. for function in self.program.functions().values() { @@ -430,6 +440,15 @@ impl Stack { } } + /// Returns the register types for the given view function name. + #[inline] + pub fn get_view_types(&self, name: &Identifier) -> Result> { + match self.view_types.read().get(name) { + Some(view_types) => Ok(view_types.clone()), + None => bail!("View types for '{name}' do not exist"), + } + } + /// Inserts the proving key if the program ID is 'credits.aleo'. fn try_insert_credits_function_proving_key(&self, function_name: &Identifier) -> Result<()> { // If the program is 'credits.aleo' and it does not exist yet, load the proving key directly. diff --git a/synthesizer/process/src/stack/register_types/initialize.rs b/synthesizer/process/src/stack/register_types/initialize.rs index 32d349f73f..3ba712fcce 100644 --- a/synthesizer/process/src/stack/register_types/initialize.rs +++ b/synthesizer/process/src/stack/register_types/initialize.rs @@ -433,6 +433,13 @@ impl RegisterTypes { ); } Opcode::Call(_) => { + // The self-locator and import-existence checks here intentionally mirror the + // finalize-context `Opcode::Call` arm in + // `finalize_types/initialize.rs::check_instruction_opcode`. The two arms + // diverge on what they allow as a target (functions/closures here vs. views + // there), but the locator-resolution preamble must remain in sync — keep + // both sites updated together when changing imports/locator semantics. + // // Validate the call operation. match instruction { Instruction::Call(call) => { diff --git a/synthesizer/process/src/view.rs b/synthesizer/process/src/view.rs index eb6df9986f..e595f3aac6 100644 --- a/synthesizer/process/src/view.rs +++ b/synthesizer/process/src/view.rs @@ -13,11 +13,16 @@ // See the License for the specific language governing permissions and // limitations under the License. -use crate::{FinalizeRegisters, FinalizeTypes, Process, Stack}; +#[cfg(feature = "history")] +use crate::Process; +use crate::{FinalizeRegisters, Stack}; +#[cfg(feature = "history")] +use console::program::ProgramID; use console::{ network::prelude::*, - program::{Identifier, ProgramID, Value}, + program::{Identifier, Value}, }; +#[cfg(feature = "history")] use snarkvm_ledger_store::{FinalizeStorage, FinalizeStore}; use snarkvm_synthesizer_program::{ FinalizeGlobalState, @@ -27,6 +32,7 @@ use snarkvm_synthesizer_program::{ StackTrait, }; +#[cfg(feature = "history")] impl Process { /// Evaluates a view function against historic finalize-store state at the given block /// height. Routes mapping reads through the finalize store's historical update map (per-key @@ -72,6 +78,7 @@ impl Process { /// `height`; program structure is not. Known gap — see `VM::evaluate_view_at_height`. /// /// Available only with `--features history`. +#[cfg(feature = "history")] pub fn evaluate_view_at_height>( state: FinalizeGlobalState, store: &FinalizeStore, @@ -88,11 +95,13 @@ pub fn evaluate_view_at_height>( /// store's historical update map at a fixed `height`. Writes bail — they are unreachable on /// the view path (views reject `set` / `remove` at construction), but bailing here /// preserves that invariant if the adapter is ever passed to other code. +#[cfg(feature = "history")] struct HistoricFinalizeStore<'a, N: Network, P: FinalizeStorage> { store: &'a FinalizeStore, height: u32, } +#[cfg(feature = "history")] impl> FinalizeStoreTrait for HistoricFinalizeStore<'_, N, P> { fn contains_mapping_confirmed( &self, @@ -167,9 +176,9 @@ impl> FinalizeStoreTrait for HistoricFinali /// Inner evaluation of a view. Generic over the store; the public path /// ([`evaluate_view_at_height`]) wraps the underlying `FinalizeStore` in a /// [`HistoricFinalizeStore`] adapter that pins reads to a fixed height. -fn evaluate_view_inner( +pub(crate) fn evaluate_view_inner( state: FinalizeGlobalState, - store: &impl FinalizeStoreTrait, + store: &dyn FinalizeStoreTrait, stack: &Stack, view_name: &Identifier, inputs: Vec>, @@ -177,8 +186,8 @@ fn evaluate_view_inner( // Resolve the view function in the stack's program. let view = stack.program().get_view_ref(view_name)?; - // Compute the register types for the view body. - let types = FinalizeTypes::from_view(stack, view)?; + // Use the cached view types (computed once at `Stack::new`). + let types = stack.get_view_types(view_name)?; // Views are read-only and externally-callable: no transition is associated. Pass `None` // for `transition_id` and `nonce` — the only consumer (rand.chacha) is rejected by @@ -250,9 +259,14 @@ fn evaluate_view_inner( )?; } // Defensive: views reject all write-producing commands at construction, so no finalize - // operations should ever be emitted. Catches any future regression that allows a write - // through the type-check path. - debug_assert!(finalize_operations.is_empty(), "view produced finalize operations: {finalize_operations:?}"); + // operations should ever be emitted. Fail closed in release builds too — a regression that + // allows a write through the type-check path must not silently leak side effects from the + // view path, where some callers (e.g. RPC views) discard `finalize_operations` entirely. + ensure!( + finalize_operations.is_empty(), + "view '{}' produced finalize operations: {finalize_operations:?}", + view.name() + ); // Load the outputs. let mut outputs = Vec::with_capacity(view.outputs().len()); @@ -262,7 +276,10 @@ fn evaluate_view_inner( Ok(outputs) } -#[cfg(test)] +// All existing view tests exercise the external `evaluate_view_at_height` path, which is +// gated on `--features history`. Tests for the new in-block call path live at the v15 VM-tests +// level (where deploying a program with a finalize-calling-view function is straightforward). +#[cfg(all(test, feature = "history"))] mod tests { use super::*; use crate::Process; diff --git a/synthesizer/program/src/finalize/mod.rs b/synthesizer/program/src/finalize/mod.rs index 0d95641430..eaa663843b 100644 --- a/synthesizer/program/src/finalize/mod.rs +++ b/synthesizer/program/src/finalize/mod.rs @@ -40,6 +40,8 @@ pub struct FinalizeCore { commands: Vec>, /// The number of write commands. num_writes: u16, + /// The number of `call` commands (view calls). + num_calls: u16, /// A mapping from `Position`s to their index in `commands`. positions: HashMap, usize>, } @@ -47,7 +49,14 @@ pub struct FinalizeCore { impl FinalizeCore { /// Initializes a new finalize with the given name. pub fn new(name: Identifier) -> Self { - Self { name, inputs: IndexSet::new(), commands: Vec::new(), num_writes: 0, positions: HashMap::new() } + Self { + name, + inputs: IndexSet::new(), + commands: Vec::new(), + num_writes: 0, + num_calls: 0, + positions: HashMap::new(), + } } /// Returns the name of the associated function. @@ -167,8 +176,22 @@ impl FinalizeCore { // Ensure the command is not an async instruction. ensure!(!command.is_async(), "Forbidden operation: Finalize cannot invoke an 'async' instruction"); - // Ensure the command is not a call instruction. - ensure!(!command.is_call(), "Forbidden operation: Finalize cannot invoke a 'call'"); + // Allow `call` only when the target resolves to a `view` function (enforced later by the + // type-check at `Stack::new`). `call.dynamic` remains forbidden because we have not yet + // designed a way to track dynamic spend / gas usage for runtime-resolved targets. + ensure!(!command.is_dynamic_call(), "Forbidden operation: Finalize cannot invoke a 'call.dynamic'"); + // Bound the number of view-calls per finalize body. This is a structural cap mirrored + // on `Transaction::MAX_TRANSITIONS` — without it, a finalize could chain up to + // `MAX_COMMANDS` calls, each into a view whose own body has up to `MAX_COMMANDS` + // commands, giving `O(MAX_COMMANDS^2)` worst-case work. `TRANSACTION_SPEND_LIMIT` still + // bounds it economically, but this gives a tight structural bound on top. + if command.is_call() { + ensure!( + (self.num_calls as usize) < N::MAX_CALLS, + "Cannot add more than {} 'call' commands in a finalize body", + N::MAX_CALLS + ); + } // Ensure the command does not operate on a record (cast-to-record or `get.record.dynamic`). ensure!(!command.is_instruction_for_record(), "Forbidden operation: Finalize cannot operate on records"); @@ -199,6 +222,11 @@ impl FinalizeCore { // Increment the number of write commands. self.num_writes += 1; } + // Track the number of view-calls. `is_dynamic_call` is already rejected above, so this + // counts only static `Call`s. + if command.is_call() { + self.num_calls += 1; + } // Insert the command. self.commands.push(command); diff --git a/synthesizer/program/src/lib.rs b/synthesizer/program/src/lib.rs index c227a37ffe..3db50b0438 100644 --- a/synthesizer/program/src/lib.rs +++ b/synthesizer/program/src/lib.rs @@ -849,9 +849,9 @@ impl ProgramCore { ensure!(!Self::is_reserved_opcode(&view_name.to_string()), "'{view_name}' is a reserved opcode."); ensure!(!Self::is_reserved_keyword(&view_name), "'{view_name}' is a reserved keyword."); - // Views must have at least one command and at least one output. - ensure!(!view.commands().is_empty(), "Cannot evaluate a view function without commands"); - ensure!(!view.outputs().is_empty(), "A view function must declare at least one output"); + // Views permit zero commands (passthrough / no-op) and zero outputs (guard views: the + // body asserts and the caller observes success via tx acceptance, failure via tx + // rejection; mirrors Solidity's zero-return `view` functions). ensure!(view.inputs().len() <= N::MAX_INPUTS, "View exceeds maximum number of inputs"); ensure!(view.outputs().len() <= N::MAX_OUTPUTS, "View exceeds maximum number of outputs"); @@ -1396,6 +1396,7 @@ impl ProgramCore { /// This includes: /// 1. `commit.*.raw` opcodes (raw commit variants). /// 2. `view` blocks (new on-disk component variant 6). + /// 3. `call` instructions inside `finalize` bodies (pre-V15 finalize forbade `call`). /// /// This is enforced to be `false` for programs before `ConsensusVersion::V15`. #[inline] @@ -1420,7 +1421,20 @@ impl ProgramCore { .chain(cfg_iter!(self.constructor).flat_map(|constructor| constructor.commands())) .any(|command| matches!(command, Command::Instruction(instruction) if has_op(*instruction.opcode()))); - function_contains || closure_contains || command_contains || !self.views.is_empty() + // Detect `call` instructions inside finalize bodies. Pre-V15 finalize forbade `call` + // entirely, so any deployed program with one is necessarily V15+ syntax. + // + // Intentionally matches only `Instruction::Call` — `CallDynamic` in a finalize body is + // rejected at parse time by `Finalize::add_command`, so it can never reach a deployed + // program. If `call.dynamic` is ever permitted in finalize, this clause must be widened + // (and `Finalize::add_command`, `cost::cost_per_command`, and the finalize + // type-checker's `Opcode::Call` arm need re-review at the same time). + let finalize_has_call = cfg_iter!(self.functions()) + .filter_map(|(_, function)| function.finalize_logic()) + .flat_map(|finalize| finalize.commands()) + .any(|command| matches!(command, Command::Instruction(Instruction::Call(_)))); + + function_contains || closure_contains || command_contains || finalize_has_call || !self.views.is_empty() } /// Returns `true` if a program contains any string type. diff --git a/synthesizer/program/src/logic/command/contains/dynamic.rs b/synthesizer/program/src/logic/command/contains/dynamic.rs index 60175e0616..81f7e0c571 100644 --- a/synthesizer/program/src/logic/command/contains/dynamic.rs +++ b/synthesizer/program/src/logic/command/contains/dynamic.rs @@ -79,7 +79,7 @@ impl ContainsDynamic { pub fn finalize( &self, stack: &impl StackTrait, - store: &impl FinalizeStoreTrait, + store: &dyn FinalizeStoreTrait, registers: &mut impl RegistersTrait, ) -> Result<()> { // Get the program name. diff --git a/synthesizer/program/src/logic/command/contains/standard.rs b/synthesizer/program/src/logic/command/contains/standard.rs index 94f0fe33ff..ba2c61606f 100644 --- a/synthesizer/program/src/logic/command/contains/standard.rs +++ b/synthesizer/program/src/logic/command/contains/standard.rs @@ -75,7 +75,7 @@ impl Contains { pub fn finalize( &self, stack: &impl StackTrait, - store: &impl FinalizeStoreTrait, + store: &dyn FinalizeStoreTrait, registers: &mut impl RegistersTrait, ) -> Result<()> { // Determine the program ID and mapping name. diff --git a/synthesizer/program/src/logic/command/get/dynamic.rs b/synthesizer/program/src/logic/command/get/dynamic.rs index 9ec5bd7093..9038f11332 100644 --- a/synthesizer/program/src/logic/command/get/dynamic.rs +++ b/synthesizer/program/src/logic/command/get/dynamic.rs @@ -87,7 +87,7 @@ impl GetDynamic { pub fn finalize( &self, stack: &impl StackTrait, - store: &impl FinalizeStoreTrait, + store: &dyn FinalizeStoreTrait, registers: &mut impl RegistersTrait, ) -> Result<()> { // Get the program name. diff --git a/synthesizer/program/src/logic/command/get/standard.rs b/synthesizer/program/src/logic/command/get/standard.rs index 7f7e896ae0..5d29c44f64 100644 --- a/synthesizer/program/src/logic/command/get/standard.rs +++ b/synthesizer/program/src/logic/command/get/standard.rs @@ -75,7 +75,7 @@ impl Get { pub fn finalize( &self, stack: &impl StackTrait, - store: &impl FinalizeStoreTrait, + store: &dyn FinalizeStoreTrait, registers: &mut impl RegistersTrait, ) -> Result<()> { // Determine the program ID and mapping name. diff --git a/synthesizer/program/src/logic/command/get_or_use/dynamic.rs b/synthesizer/program/src/logic/command/get_or_use/dynamic.rs index 1b9b5eb387..36c2c9450a 100644 --- a/synthesizer/program/src/logic/command/get_or_use/dynamic.rs +++ b/synthesizer/program/src/logic/command/get_or_use/dynamic.rs @@ -94,7 +94,7 @@ impl GetOrUseDynamic { pub fn finalize( &self, stack: &impl StackTrait, - store: &impl FinalizeStoreTrait, + store: &dyn FinalizeStoreTrait, registers: &mut impl RegistersTrait, ) -> Result<()> { // Get the program name. diff --git a/synthesizer/program/src/logic/command/get_or_use/standard.rs b/synthesizer/program/src/logic/command/get_or_use/standard.rs index 663441f77e..2a214cbc19 100644 --- a/synthesizer/program/src/logic/command/get_or_use/standard.rs +++ b/synthesizer/program/src/logic/command/get_or_use/standard.rs @@ -82,7 +82,7 @@ impl GetOrUse { pub fn finalize( &self, stack: &impl StackTrait, - store: &impl FinalizeStoreTrait, + store: &dyn FinalizeStoreTrait, registers: &mut impl RegistersTrait, ) -> Result<()> { // Determine the program ID and mapping name. diff --git a/synthesizer/program/src/logic/command/mod.rs b/synthesizer/program/src/logic/command/mod.rs index 29e60a68f0..3cb6622ec0 100644 --- a/synthesizer/program/src/logic/command/mod.rs +++ b/synthesizer/program/src/logic/command/mod.rs @@ -106,6 +106,11 @@ impl Command { matches!(self, Command::Instruction(Instruction::Call(_) | Instruction::CallDynamic(_))) } + /// Returns `true` if the command is specifically a dynamic call instruction. + pub fn is_dynamic_call(&self) -> bool { + matches!(self, Command::Instruction(Instruction::CallDynamic(_))) + } + /// Returns `true` if the command is a cast-to-record instruction. Covers all three /// record cast variants: static `record`, `external_record`, and `dynamic.record`. pub fn is_cast_to_record(&self) -> bool { @@ -204,12 +209,12 @@ impl Command { pub fn finalize( &self, stack: &impl StackTrait, - store: &impl FinalizeStoreTrait, + store: &dyn FinalizeStoreTrait, registers: &mut impl FinalizeRegistersState, ) -> Result>, FinalizeError> { match self { // Finalize the instruction, and return no finalize operation. - Command::Instruction(instruction) => instruction.finalize(stack, registers).map(|_| None), + Command::Instruction(instruction) => instruction.finalize(stack, Some(store), registers).map(|_| None), // `await` commands are processed by the caller of this method. Command::Await(_) => Err(FinalizeError::Anyhow(anyhow!("`await` commands cannot be finalized directly."))), // Finalize the 'contains' command, and return no finalize operation. diff --git a/synthesizer/program/src/logic/command/remove.rs b/synthesizer/program/src/logic/command/remove.rs index 9e8bcbf136..caa14e369e 100644 --- a/synthesizer/program/src/logic/command/remove.rs +++ b/synthesizer/program/src/logic/command/remove.rs @@ -63,7 +63,7 @@ impl Remove { pub fn finalize( &self, stack: &impl StackTrait, - store: &impl FinalizeStoreTrait, + store: &dyn FinalizeStoreTrait, registers: &mut impl RegistersTrait, ) -> Result>> { // Ensure the mapping exists. diff --git a/synthesizer/program/src/logic/command/set.rs b/synthesizer/program/src/logic/command/set.rs index 76d5c1c348..bddf4221a5 100644 --- a/synthesizer/program/src/logic/command/set.rs +++ b/synthesizer/program/src/logic/command/set.rs @@ -72,7 +72,7 @@ impl Set { pub fn finalize( &self, stack: &impl StackTrait, - store: &impl FinalizeStoreTrait, + store: &dyn FinalizeStoreTrait, registers: &mut impl RegistersTrait, ) -> Result> { // Ensure the mapping exists. diff --git a/synthesizer/program/src/logic/instruction/mod.rs b/synthesizer/program/src/logic/instruction/mod.rs index 581e345419..572789978b 100644 --- a/synthesizer/program/src/logic/instruction/mod.rs +++ b/synthesizer/program/src/logic/instruction/mod.rs @@ -25,7 +25,7 @@ pub use operation::*; mod bytes; mod parse; -use crate::{RegistersCircuit, RegistersSigner, RegistersTrait, StackTrait, instruction}; +use crate::{FinalizeRegistersState, FinalizeStoreTrait, RegistersCircuit, RegistersSigner, StackTrait, instruction}; use console::{ network::Network, prelude::{ @@ -623,9 +623,10 @@ impl Instruction { pub fn finalize( &self, stack: &impl StackTrait, - registers: &mut impl RegistersTrait, + store: Option<&dyn FinalizeStoreTrait>, + registers: &mut impl FinalizeRegistersState, ) -> Result<(), FinalizeError> { - instruction!(self, |instruction| instruction.finalize(stack, registers).map_err(Into::into)) + instruction!(self, |instruction| instruction.finalize(stack, store, registers).map_err(Into::into)) } /// Returns the output type from the given input types. diff --git a/synthesizer/program/src/logic/instruction/operation/assert.rs b/synthesizer/program/src/logic/instruction/operation/assert.rs index 55c11f9fb1..d233cf79f1 100644 --- a/synthesizer/program/src/logic/instruction/operation/assert.rs +++ b/synthesizer/program/src/logic/instruction/operation/assert.rs @@ -13,7 +13,16 @@ // See the License for the specific language governing permissions and // limitations under the License. -use crate::{Opcode, Operand, RegistersCircuit, RegistersTrait, StackTrait, register_types_equivalent}; +use crate::{ + FinalizeRegistersState, + FinalizeStoreTrait, + Opcode, + Operand, + RegistersCircuit, + RegistersTrait, + StackTrait, + register_types_equivalent, +}; use console::{ network::prelude::*, program::{Register, RegisterType}, @@ -151,7 +160,8 @@ impl AssertInstruction { pub fn finalize( &self, stack: &impl StackTrait, - registers: &mut impl RegistersTrait, + _store: Option<&dyn FinalizeStoreTrait>, + registers: &mut impl FinalizeRegistersState, ) -> Result<(), FinalizeError> { self.evaluate(stack, registers)?; Ok(()) diff --git a/synthesizer/program/src/logic/instruction/operation/async_.rs b/synthesizer/program/src/logic/instruction/operation/async_.rs index f9d9e956ed..7bd3c6ca08 100644 --- a/synthesizer/program/src/logic/instruction/operation/async_.rs +++ b/synthesizer/program/src/logic/instruction/operation/async_.rs @@ -13,7 +13,17 @@ // See the License for the specific language governing permissions and // limitations under the License. -use crate::{Opcode, Operand, RegistersCircuit, RegistersTrait, Result, StackTrait, types_equivalent}; +use crate::{ + FinalizeRegistersState, + FinalizeStoreTrait, + Opcode, + Operand, + RegistersCircuit, + RegistersTrait, + Result, + StackTrait, + types_equivalent, +}; use console::{ network::prelude::*, @@ -134,7 +144,12 @@ impl Async { /// Finalizes the instruction. #[inline] - pub fn finalize(&self, _stack: &impl StackTrait, _registers: &mut impl RegistersTrait) -> Result<()> { + pub fn finalize( + &self, + _stack: &impl StackTrait, + _store: Option<&dyn FinalizeStoreTrait>, + _registers: &mut impl FinalizeRegistersState, + ) -> Result<()> { bail!("Forbidden operation: Finalize cannot invoke 'async'.") } diff --git a/synthesizer/program/src/logic/instruction/operation/call/dynamic.rs b/synthesizer/program/src/logic/instruction/operation/call/dynamic.rs index 1b80e9f6e2..99c951f46a 100644 --- a/synthesizer/program/src/logic/instruction/operation/call/dynamic.rs +++ b/synthesizer/program/src/logic/instruction/operation/call/dynamic.rs @@ -16,7 +16,7 @@ use crate::{ Opcode, Operand, - traits::{RegistersCircuit, RegistersTrait, StackTrait}, + traits::{FinalizeRegistersState, FinalizeStoreTrait, RegistersCircuit, RegistersTrait, StackTrait}, }; use console::{ @@ -174,8 +174,9 @@ impl CallDynamic { #[inline] pub fn finalize( &self, - _stack: &(impl StackTrait + StackTrait), - _registers: &mut impl RegistersTrait, + _stack: &impl StackTrait, + _store: Option<&dyn FinalizeStoreTrait>, + _registers: &mut impl FinalizeRegistersState, ) -> Result<()> { bail!("Forbidden operation: Finalize cannot invoke a 'call.dynamic'.") } diff --git a/synthesizer/program/src/logic/instruction/operation/call/standard.rs b/synthesizer/program/src/logic/instruction/operation/call/standard.rs index c9e5be7768..ff63f6b449 100644 --- a/synthesizer/program/src/logic/instruction/operation/call/standard.rs +++ b/synthesizer/program/src/logic/instruction/operation/call/standard.rs @@ -13,10 +13,19 @@ // See the License for the specific language governing permissions and // limitations under the License. -use crate::{Opcode, Operand, RegistersCircuit, RegistersTrait, StackTrait}; +use crate::{ + FinalizeRegistersState, + FinalizeStoreTrait, + Opcode, + Operand, + RegistersCircuit, + RegistersTrait, + StackTrait, + register_types_equivalent, +}; use console::{ network::prelude::*, - program::{Identifier, Locator, Register, RegisterType}, + program::{FinalizeType, Identifier, Locator, Register, RegisterType, Value}, }; /// The operator references a function name or closure name. @@ -181,9 +190,55 @@ impl Call { } /// Finalizes the instruction. + /// + /// In a finalize context, the only legal `call` target is a view function (gated at + /// deploy time by `check_instruction_opcode`). Loads operand values from `caller_registers`, + /// dispatches the view body through `StackTrait::evaluate_view` against the appropriate + /// target stack (same-program for `CallOperator::Resource`, external for `Locator`), and + /// writes outputs back into the caller's destination registers. #[inline] - pub fn finalize(&self, _stack: &impl StackTrait, _registers: &mut impl RegistersTrait) -> Result<()> { - bail!("Forbidden operation: Finalize cannot invoke a 'call' directly. Use 'call' in 'Stack' instead.") + pub fn finalize( + &self, + stack: &impl StackTrait, + store: Option<&dyn FinalizeStoreTrait>, + caller_registers: &mut impl FinalizeRegistersState, + ) -> Result<()> { + // A finalize-context `call` actually executes a view body, which reads from the + // finalize store; reject the storeless dispatch path explicitly. + let store = store.ok_or_else(|| anyhow!("`call` from a finalize context requires a finalize store"))?; + + // Load inputs from the caller's registers (operands resolve against the caller's stack). + let inputs: Vec> = + self.operands.iter().map(|op| caller_registers.load(stack, op)).collect::>()?; + + // Inherit the caller's global finalize state for the view body. + let state = *caller_registers.state(); + + // Dispatch to the target stack (external for locator-typed targets, current stack for + // resource-typed targets). Views are leaves — their bodies reject `is_call` at + // construction — so this never recurses through `Command::finalize` back into another + // view call. + let outputs = match &self.operator { + CallOperator::Locator(locator) => { + let external_stack = stack.get_external_stack(locator.program_id())?; + external_stack.evaluate_view(state, store, locator.resource(), inputs)? + } + CallOperator::Resource(name) => stack.evaluate_view(state, store, name, inputs)?, + }; + + // Type-check at deploy time guarantees this match, but we sanity-check at runtime too. + ensure!( + self.destinations.len() == outputs.len(), + "View returned {} outputs but the call expects {}", + outputs.len(), + self.destinations.len(), + ); + + // Write the view's outputs into the caller's destination registers. + for (dest, value) in self.destinations.iter().zip_eq(outputs) { + caller_registers.store(stack, dest, value)?; + } + Ok(()) } /// Returns the output type from the given program and input types. @@ -211,8 +266,59 @@ impl Call { } }; + // If the operator is a view function, retrieve the view and compute the output types. + // Views are externally-callable and declared as `FinalizeType::Plaintext(_)` for both + // inputs and outputs (enforced by `ViewCore::add_input`/`add_output`); we therefore + // pattern-match `FinalizeType::Plaintext` directly when constructing the `RegisterType`. + if let Ok(view) = program.get_view_ref(name) { + if view.inputs().len() != self.operands.len() { + bail!("Expected {} inputs, found {}", view.inputs().len(), self.operands.len()) + } + if view.inputs().len() != input_types.len() { + bail!("Expected {} input types, found {}", view.inputs().len(), input_types.len()) + } + if view.outputs().len() != self.destinations.len() { + bail!("Expected {} outputs, found {}", view.outputs().len(), self.destinations.len()) + } + + // Per-operand type-equivalence check, against the view's declared input types. For a + // cross-program target, `qualify` rewrites local struct references to `ExternalStruct` + // locators so `register_types_equivalent` resolves them through the target stack. + for (index, (operand_type, input)) in input_types.iter().zip(view.inputs().iter()).enumerate() { + let plaintext_type = match input.finalize_type() { + FinalizeType::Plaintext(plaintext_type) => plaintext_type.clone(), + FinalizeType::Future(_) | FinalizeType::DynamicFuture => { + bail!("View '{name}' input '{index}' must be a plaintext type") + } + }; + let mut expected_type = RegisterType::Plaintext(plaintext_type); + if is_external { + expected_type = expected_type.qualify(*program.id()); + } + if !register_types_equivalent(stack, &expected_type, stack, operand_type)? { + bail!("Input '{index}' of view '{name}' expects '{expected_type}', found '{operand_type}'"); + } + } + + view.outputs() + .iter() + .map(|output| match output.finalize_type() { + FinalizeType::Plaintext(plaintext_type) => Ok(RegisterType::Plaintext(plaintext_type.clone())), + FinalizeType::Future(_) | FinalizeType::DynamicFuture => { + bail!("View '{name}' output must be a plaintext type") + } + }) + .map(|result| { + result.map( + |register_type| { + if is_external { register_type.qualify(*program.id()) } else { register_type } + }, + ) + }) + .collect::>>() + } // If the operator is a closure, retrieve the closure and compute the output types. - if let Ok(closure) = program.get_closure(name) { + else if let Ok(closure) = program.get_closure(name) { // Ensure the number of operands matches the number of input statements. if closure.inputs().len() != self.operands.len() { bail!("Expected {} inputs, found {}", closure.inputs().len(), self.operands.len()) diff --git a/synthesizer/program/src/logic/instruction/operation/cast.rs b/synthesizer/program/src/logic/instruction/operation/cast.rs index f833624036..f3ef31686b 100644 --- a/synthesizer/program/src/logic/instruction/operation/cast.rs +++ b/synthesizer/program/src/logic/instruction/operation/cast.rs @@ -13,7 +13,17 @@ // See the License for the specific language governing permissions and // limitations under the License. -use crate::{Opcode, Operand, RegistersCircuit, RegistersSigner, RegistersTrait, StackTrait, types_equivalent}; +use crate::{ + FinalizeRegistersState, + FinalizeStoreTrait, + Opcode, + Operand, + RegistersCircuit, + RegistersSigner, + RegistersTrait, + StackTrait, + types_equivalent, +}; use console::{ network::prelude::*, program::{ @@ -634,7 +644,12 @@ impl CastOperation { } /// Finalizes the instruction. - pub fn finalize(&self, stack: &impl StackTrait, registers: &mut impl RegistersTrait) -> Result<()> { + pub fn finalize( + &self, + stack: &impl StackTrait, + _store: Option<&dyn FinalizeStoreTrait>, + registers: &mut impl FinalizeRegistersState, + ) -> Result<()> { // If the variant is `cast.lossy`, then check that the `cast_type` is a `PlaintextType::Literal`. if VARIANT == CastVariant::CastLossy as u8 { ensure!( diff --git a/synthesizer/program/src/logic/instruction/operation/commit.rs b/synthesizer/program/src/logic/instruction/operation/commit.rs index 35f9b2d322..8a3e798cf3 100644 --- a/synthesizer/program/src/logic/instruction/operation/commit.rs +++ b/synthesizer/program/src/logic/instruction/operation/commit.rs @@ -13,7 +13,15 @@ // See the License for the specific language governing permissions and // limitations under the License. -use crate::{Opcode, Operand, RegistersCircuit, RegistersTrait, StackTrait}; +use crate::{ + FinalizeRegistersState, + FinalizeStoreTrait, + Opcode, + Operand, + RegistersCircuit, + RegistersTrait, + StackTrait, +}; use console::{ network::prelude::*, program::{Literal, LiteralType, Plaintext, PlaintextType, Register, RegisterType, Scalar, Value}, @@ -264,7 +272,12 @@ impl CommitInstruction { /// Finalizes the instruction. #[inline] - pub fn finalize(&self, stack: &impl StackTrait, registers: &mut impl RegistersTrait) -> Result<()> { + pub fn finalize( + &self, + stack: &impl StackTrait, + _store: Option<&dyn FinalizeStoreTrait>, + registers: &mut impl FinalizeRegistersState, + ) -> Result<()> { self.evaluate(stack, registers) } diff --git a/synthesizer/program/src/logic/instruction/operation/deserialize.rs b/synthesizer/program/src/logic/instruction/operation/deserialize.rs index c8b060bfca..d7b2260124 100644 --- a/synthesizer/program/src/logic/instruction/operation/deserialize.rs +++ b/synthesizer/program/src/logic/instruction/operation/deserialize.rs @@ -13,7 +13,15 @@ // See the License for the specific language governing permissions and // limitations under the License. -use crate::{Opcode, Operand, RegistersCircuit, RegistersTrait, StackTrait}; +use crate::{ + FinalizeRegistersState, + FinalizeStoreTrait, + Opcode, + Operand, + RegistersCircuit, + RegistersTrait, + StackTrait, +}; use console::{ network::prelude::*, program::{ @@ -723,7 +731,12 @@ impl DeserializeInstruction { /// Finalizes the instruction. #[inline] - pub fn finalize(&self, stack: &impl StackTrait, registers: &mut impl RegistersTrait) -> Result<()> { + pub fn finalize( + &self, + stack: &impl StackTrait, + _store: Option<&dyn FinalizeStoreTrait>, + registers: &mut impl FinalizeRegistersState, + ) -> Result<()> { self.evaluate(stack, registers) } diff --git a/synthesizer/program/src/logic/instruction/operation/ecdsa_verify.rs b/synthesizer/program/src/logic/instruction/operation/ecdsa_verify.rs index b639dd1029..40a7845c51 100644 --- a/synthesizer/program/src/logic/instruction/operation/ecdsa_verify.rs +++ b/synthesizer/program/src/logic/instruction/operation/ecdsa_verify.rs @@ -13,7 +13,15 @@ // See the License for the specific language governing permissions and // limitations under the License. -use crate::{Opcode, Operand, RegistersCircuit, RegistersTrait, StackTrait}; +use crate::{ + FinalizeRegistersState, + FinalizeStoreTrait, + Opcode, + Operand, + RegistersCircuit, + RegistersTrait, + StackTrait, +}; use console::{ algorithms::{ECDSASignature, Keccak256, Keccak384, Keccak512, Sha3_256, Sha3_384, Sha3_512}, network::prelude::*, @@ -328,7 +336,12 @@ impl ECDSAVerify { /// Finalizes the instruction. #[inline] - pub fn finalize(&self, stack: &impl StackTrait, registers: &mut impl RegistersTrait) -> Result<()> { + pub fn finalize( + &self, + stack: &impl StackTrait, + _store: Option<&dyn FinalizeStoreTrait>, + registers: &mut impl FinalizeRegistersState, + ) -> Result<()> { // Ensure the number of operands is correct. if self.operands.len() != 3 { bail!("Instruction '{}' expects 3 operands, found {} operands", Self::opcode(), self.operands.len()) diff --git a/synthesizer/program/src/logic/instruction/operation/get_record_dynamic.rs b/synthesizer/program/src/logic/instruction/operation/get_record_dynamic.rs index 1777997040..8df46740bf 100644 --- a/synthesizer/program/src/logic/instruction/operation/get_record_dynamic.rs +++ b/synthesizer/program/src/logic/instruction/operation/get_record_dynamic.rs @@ -13,7 +13,15 @@ // See the License for the specific language governing permissions and // limitations under the License. -use crate::{Opcode, Operand, RegistersCircuit, RegistersTrait, StackTrait}; +use crate::{ + FinalizeRegistersState, + FinalizeStoreTrait, + Opcode, + Operand, + RegistersCircuit, + RegistersTrait, + StackTrait, +}; use circuit::{Eject, Inject, Mode, traits::ToField}; use console::{ collections::merkle_tree::MerklePath, @@ -311,7 +319,12 @@ impl GetRecordDynamic { /// Finalizes the instruction. #[inline] - pub fn finalize(&self, _stack: &impl StackTrait, _registers: &mut impl RegistersTrait) -> Result<()> { + pub fn finalize( + &self, + _stack: &impl StackTrait, + _store: Option<&dyn FinalizeStoreTrait>, + _registers: &mut impl FinalizeRegistersState, + ) -> Result<()> { bail!("Forbidden operation: Finalize cannot invoke 'get.record.dynamic'.") } diff --git a/synthesizer/program/src/logic/instruction/operation/hash.rs b/synthesizer/program/src/logic/instruction/operation/hash.rs index 2d44b9e397..282215dade 100644 --- a/synthesizer/program/src/logic/instruction/operation/hash.rs +++ b/synthesizer/program/src/logic/instruction/operation/hash.rs @@ -13,7 +13,15 @@ // See the License for the specific language governing permissions and // limitations under the License. -use crate::{Opcode, Operand, RegistersCircuit, RegistersTrait, StackTrait}; +use crate::{ + FinalizeRegistersState, + FinalizeStoreTrait, + Opcode, + Operand, + RegistersCircuit, + RegistersTrait, + StackTrait, +}; use console::{ network::prelude::*, program::{Identifier, Literal, LiteralType, Locator, Plaintext, PlaintextType, Register, RegisterType, Value}, @@ -641,7 +649,12 @@ impl HashInstruction { /// Finalizes the instruction. #[inline] - pub fn finalize(&self, stack: &impl StackTrait, registers: &mut impl RegistersTrait) -> Result<()> { + pub fn finalize( + &self, + stack: &impl StackTrait, + _store: Option<&dyn FinalizeStoreTrait>, + registers: &mut impl FinalizeRegistersState, + ) -> Result<()> { self.evaluate(stack, registers) } diff --git a/synthesizer/program/src/logic/instruction/operation/is.rs b/synthesizer/program/src/logic/instruction/operation/is.rs index 10a5016b85..1bc6a60e3d 100644 --- a/synthesizer/program/src/logic/instruction/operation/is.rs +++ b/synthesizer/program/src/logic/instruction/operation/is.rs @@ -13,7 +13,16 @@ // See the License for the specific language governing permissions and // limitations under the License. -use crate::{Opcode, Operand, RegistersCircuit, RegistersTrait, StackTrait, register_types_equivalent}; +use crate::{ + FinalizeRegistersState, + FinalizeStoreTrait, + Opcode, + Operand, + RegistersCircuit, + RegistersTrait, + StackTrait, + register_types_equivalent, +}; use console::{ network::prelude::*, program::{Literal, LiteralType, Plaintext, PlaintextType, Register, RegisterType, Value}, @@ -130,7 +139,12 @@ impl IsInstruction { /// Finalizes the instruction. #[inline] - pub fn finalize(&self, stack: &impl StackTrait, registers: &mut impl RegistersTrait) -> Result<()> { + pub fn finalize( + &self, + stack: &impl StackTrait, + _store: Option<&dyn FinalizeStoreTrait>, + registers: &mut impl FinalizeRegistersState, + ) -> Result<()> { self.evaluate(stack, registers) } diff --git a/synthesizer/program/src/logic/instruction/operation/literals.rs b/synthesizer/program/src/logic/instruction/operation/literals.rs index d772c43179..b15f235df7 100644 --- a/synthesizer/program/src/logic/instruction/operation/literals.rs +++ b/synthesizer/program/src/logic/instruction/operation/literals.rs @@ -13,7 +13,16 @@ // See the License for the specific language governing permissions and // limitations under the License. -use crate::{Opcode, Operand, Operation, RegistersCircuit, RegistersTrait, StackTrait}; +use crate::{ + FinalizeRegistersState, + FinalizeStoreTrait, + Opcode, + Operand, + Operation, + RegistersCircuit, + RegistersTrait, + StackTrait, +}; use console::{ network::prelude::*, program::{Literal, LiteralType, PlaintextType, Register, RegisterType}, @@ -140,7 +149,12 @@ impl, LiteralType, NUM_OPERANDS>, const N /// Finalizes the instruction. #[inline] - pub fn finalize(&self, stack: &impl StackTrait, registers: &mut impl RegistersTrait) -> Result<()> { + pub fn finalize( + &self, + stack: &impl StackTrait, + _store: Option<&dyn FinalizeStoreTrait>, + registers: &mut impl FinalizeRegistersState, + ) -> Result<()> { self.evaluate(stack, registers) } diff --git a/synthesizer/program/src/logic/instruction/operation/serialize.rs b/synthesizer/program/src/logic/instruction/operation/serialize.rs index 32979f9a41..8739353b91 100644 --- a/synthesizer/program/src/logic/instruction/operation/serialize.rs +++ b/synthesizer/program/src/logic/instruction/operation/serialize.rs @@ -13,7 +13,15 @@ // See the License for the specific language governing permissions and // limitations under the License. -use crate::{Opcode, Operand, RegistersCircuit, RegistersTrait, StackTrait}; +use crate::{ + FinalizeRegistersState, + FinalizeStoreTrait, + Opcode, + Operand, + RegistersCircuit, + RegistersTrait, + StackTrait, +}; use console::{ network::prelude::*, program::{ArrayType, Identifier, LiteralType, Locator, Plaintext, PlaintextType, Register, RegisterType, Value}, @@ -272,7 +280,12 @@ impl SerializeInstruction { /// Finalizes the instruction. #[inline] - pub fn finalize(&self, stack: &impl StackTrait, registers: &mut impl RegistersTrait) -> Result<()> { + pub fn finalize( + &self, + stack: &impl StackTrait, + _store: Option<&dyn FinalizeStoreTrait>, + registers: &mut impl FinalizeRegistersState, + ) -> Result<()> { self.evaluate(stack, registers) } diff --git a/synthesizer/program/src/logic/instruction/operation/sign_verify.rs b/synthesizer/program/src/logic/instruction/operation/sign_verify.rs index 549a70dcd9..c42ebfd8cf 100644 --- a/synthesizer/program/src/logic/instruction/operation/sign_verify.rs +++ b/synthesizer/program/src/logic/instruction/operation/sign_verify.rs @@ -13,7 +13,15 @@ // See the License for the specific language governing permissions and // limitations under the License. -use crate::{Opcode, Operand, RegistersCircuit, RegistersTrait, StackTrait}; +use crate::{ + FinalizeRegistersState, + FinalizeStoreTrait, + Opcode, + Operand, + RegistersCircuit, + RegistersTrait, + StackTrait, +}; use circuit::prelude::ToFields as CircuitToFields; use console::{ network::prelude::*, @@ -154,7 +162,12 @@ impl SignatureVerification { /// Finalizes the instruction. #[inline] - pub fn finalize(&self, stack: &impl StackTrait, registers: &mut impl RegistersTrait) -> Result<()> { + pub fn finalize( + &self, + stack: &impl StackTrait, + _store: Option<&dyn FinalizeStoreTrait>, + registers: &mut impl FinalizeRegistersState, + ) -> Result<()> { self.evaluate(stack, registers) } diff --git a/synthesizer/program/src/logic/instruction/operation/snark_verify.rs b/synthesizer/program/src/logic/instruction/operation/snark_verify.rs index bce2f146a1..d3440dc97f 100644 --- a/synthesizer/program/src/logic/instruction/operation/snark_verify.rs +++ b/synthesizer/program/src/logic/instruction/operation/snark_verify.rs @@ -13,7 +13,15 @@ // See the License for the specific language governing permissions and // limitations under the License. -use crate::{Opcode, Operand, RegistersCircuit, RegistersTrait, StackTrait}; +use crate::{ + FinalizeRegistersState, + FinalizeStoreTrait, + Opcode, + Operand, + RegistersCircuit, + RegistersTrait, + StackTrait, +}; use console::{ network::prelude::*, program::{Literal, LiteralType, Plaintext, PlaintextType, Register, RegisterType, Value}, @@ -302,7 +310,12 @@ impl SnarkVerification { /// Finalizes the instruction. #[inline] - pub fn finalize(&self, stack: &impl StackTrait, registers: &mut impl RegistersTrait) -> Result<()> { + pub fn finalize( + &self, + stack: &impl StackTrait, + _store: Option<&dyn FinalizeStoreTrait>, + registers: &mut impl FinalizeRegistersState, + ) -> Result<()> { // Ensure the number of operands is correct. if self.operands.len() != 4 { bail!("Instruction '{}' expects 4 operands, found {} operands", Self::opcode(), self.operands.len()) diff --git a/synthesizer/program/src/traits/stack_and_registers.rs b/synthesizer/program/src/traits/stack_and_registers.rs index bced247256..370b73aa56 100644 --- a/synthesizer/program/src/traits/stack_and_registers.rs +++ b/synthesizer/program/src/traits/stack_and_registers.rs @@ -15,7 +15,7 @@ use std::sync::Arc; -use crate::{FinalizeGlobalState, Function, Operand, Program}; +use crate::{FinalizeGlobalState, FinalizeStoreTrait, Function, Operand, Program}; use console::{ account::Group, network::Network, @@ -180,6 +180,21 @@ pub trait StackTrait { index: Field, rng: &mut R, ) -> Result>>; + + /// Evaluates a view function on this stack against the given finalize-store state. + /// + /// The caller (`Call::finalize`) loads operand values from the caller's registers and + /// passes them as `inputs`; this method runs the view body and returns its outputs. It + /// is the cross-crate hook that lets `Call::finalize` (in `snarkvm-synthesizer-program`) + /// dispatch view-call evaluation into `snarkvm-synthesizer-process` without depending on + /// concrete `Stack` / `FinalizeRegisters` types. + fn evaluate_view( + &self, + state: FinalizeGlobalState, + store: &dyn FinalizeStoreTrait, + view_name: &Identifier, + inputs: Vec>, + ) -> Result>>; } /// Are the two types either the same, or both structurally equivalent `PlaintextType`s? diff --git a/synthesizer/program/src/view/bytes.rs b/synthesizer/program/src/view/bytes.rs index ec9c3855e8..2aa010cb8a 100644 --- a/synthesizer/program/src/view/bytes.rs +++ b/synthesizer/program/src/view/bytes.rs @@ -32,11 +32,8 @@ impl FromBytes for ViewCore { inputs.push(Input::read_le(&mut reader)?); } - // Read the commands. + // Read the commands. Zero commands are permitted (passthrough / no-op views). let num_commands = u16::read_le(&mut reader)?; - if num_commands.is_zero() { - return Err(error("Failed to deserialize view: needs at least one command".to_string())); - } if num_commands > u16::try_from(N::MAX_COMMANDS).map_err(error)? { return Err(error(format!("Failed to deserialize view: too many commands ({num_commands})"))); } @@ -45,11 +42,8 @@ impl FromBytes for ViewCore { commands.push(Command::read_le(&mut reader)?); } - // Read the outputs. + // Read the outputs. Zero outputs are permitted (guard views). let num_outputs = u16::read_le(&mut reader)?; - if num_outputs.is_zero() { - return Err(error("Failed to deserialize view: needs at least one output".to_string())); - } if num_outputs > u16::try_from(N::MAX_OUTPUTS).map_err(error)? { return Err(error(format!("Failed to deserialize view: too many outputs ({num_outputs})"))); } @@ -85,9 +79,9 @@ impl ToBytes for ViewCore { input.write_le(&mut writer)?; } - // Write the number of commands. + // Write the number of commands. Zero commands are permitted (passthrough / no-op views). let num_commands = self.commands.len(); - match 0 < num_commands && num_commands <= N::MAX_COMMANDS { + match num_commands <= N::MAX_COMMANDS { true => u16::try_from(num_commands).map_err(error)?.write_le(&mut writer)?, false => return Err(error(format!("Failed to write {num_commands} commands as bytes"))), } @@ -95,9 +89,9 @@ impl ToBytes for ViewCore { command.write_le(&mut writer)?; } - // Write the number of outputs. + // Write the number of outputs. Zero outputs are permitted (guard views). let num_outputs = self.outputs.len(); - match 0 < num_outputs && num_outputs <= N::MAX_OUTPUTS { + match num_outputs <= N::MAX_OUTPUTS { true => u16::try_from(num_outputs).map_err(error)?.write_le(&mut writer)?, false => return Err(error(format!("Failed to write {num_outputs} outputs as bytes"))), } diff --git a/synthesizer/program/src/view/parse.rs b/synthesizer/program/src/view/parse.rs index ae2261c69a..58cfee2e9e 100644 --- a/synthesizer/program/src/view/parse.rs +++ b/synthesizer/program/src/view/parse.rs @@ -32,12 +32,15 @@ impl Parser for ViewCore { // Parse the colon ':' keyword from the string. let (string, _) = tag(":")(string)?; - // Parse the inputs from the string. + // Parse the inputs, commands, and outputs from the string. All three are `many0` — + // views permit zero commands (passthrough / no-op shapes) and zero outputs (assertional + // / guard views; the Aleo analogue of Solidity `view` functions that don't return + // anything). The constraints that matter (no record-touching ops, no state writes, no + // `async`/`await`/`call`/`rand.chacha`) are enforced by `ViewCore::add_command`, not by + // the parser arity. let (string, inputs) = many0(Input::parse)(string)?; - // Parse the commands from the string. - let (string, commands) = many1(Command::::parse)(string)?; - // Parse the outputs from the string. - let (string, outputs) = many1(Output::parse)(string)?; + let (string, commands) = many0(Command::::parse)(string)?; + let (string, outputs) = many0(Output::parse)(string)?; map_res(take(0usize), move |_| { let mut view = Self::new(name); @@ -148,24 +151,6 @@ foo: r" view foo add 1u64 2u64 into r0; - output r0 as u64.public;" - ) - .is_err() - ); - // Missing output (a view must have at least one). - assert!( - ViewCore::::from_str( - r" -view foo: - add 1u64 2u64 into r0;" - ) - .is_err() - ); - // Missing commands (a view must have at least one). - assert!( - ViewCore::::from_str( - r" -view foo: output r0 as u64.public;" ) .is_err() @@ -182,4 +167,54 @@ view foo: .is_err() ); } + + #[test] + fn test_view_parse_no_outputs_guard() { + // A guard view: asserts a precondition and returns nothing. Callers observe success + // via tx acceptance and failure (assertion fails) via tx rejection. + let view = ViewCore::::parse( + r" +view require_zero: + input r0 as u64.public; + assert.eq r0 0u64;", + ) + .unwrap() + .1; + assert_eq!("require_zero", view.name().to_string()); + assert_eq!(1, view.inputs().len()); + assert_eq!(1, view.commands().len()); + assert_eq!(0, view.outputs().len()); + } + + #[test] + fn test_view_parse_no_commands_passthrough() { + // A passthrough view: no commands, output is the input register directly. + let view = ViewCore::::parse( + r" +view identity: + input r0 as u64.public; + output r0 as u64.public;", + ) + .unwrap() + .1; + assert_eq!("identity", view.name().to_string()); + assert_eq!(1, view.inputs().len()); + assert_eq!(0, view.commands().len()); + assert_eq!(1, view.outputs().len()); + } + + #[test] + fn test_view_parse_fully_empty() { + // A no-op view: no inputs, commands, or outputs. Permitted for symmetry with `function`. + let view = ViewCore::::parse( + r" +view noop:", + ) + .unwrap() + .1; + assert_eq!("noop", view.name().to_string()); + assert_eq!(0, view.inputs().len()); + assert_eq!(0, view.commands().len()); + assert_eq!(0, view.outputs().len()); + } } diff --git a/synthesizer/program/tests/instruction/assert.rs b/synthesizer/program/tests/instruction/assert.rs index e374194aab..55cc226818 100644 --- a/synthesizer/program/tests/instruction/assert.rs +++ b/synthesizer/program/tests/instruction/assert.rs @@ -162,7 +162,7 @@ fn check_assert( Plaintext::from(literal_a), ]) .unwrap(); - let result_c = operation.finalize(&stack, &mut registers); + let result_c = operation.finalize(&stack, None, &mut registers); // Ensure the result is correct. match VARIANT { @@ -237,7 +237,7 @@ fn check_assert( Plaintext::from(literal_b), ]) .unwrap(); - let result_c = operation.finalize(&stack, &mut registers); + let result_c = operation.finalize(&stack, None, &mut registers); // Ensure the result is correct. match VARIANT { diff --git a/synthesizer/program/tests/instruction/commit.rs b/synthesizer/program/tests/instruction/commit.rs index 2359b81844..72cc9a1535 100644 --- a/synthesizer/program/tests/instruction/commit.rs +++ b/synthesizer/program/tests/instruction/commit.rs @@ -151,7 +151,7 @@ fn check_commit( let mut finalize_registers = sample_finalize_registers(&stack, &function_name, &[Plaintext::from(literal_a), Plaintext::from(literal_b)]) .unwrap(); - let result_c = operation.finalize(&stack, &mut finalize_registers); + let result_c = operation.finalize(&stack, None, &mut finalize_registers); // Check that either all operations failed, or all operations succeeded. let all_failed = result_a.is_err() && result_b.is_err() && result_c.is_err(); diff --git a/synthesizer/program/tests/instruction/deserialize.rs b/synthesizer/program/tests/instruction/deserialize.rs index de1a91be18..666b0b31e0 100644 --- a/synthesizer/program/tests/instruction/deserialize.rs +++ b/synthesizer/program/tests/instruction/deserialize.rs @@ -152,7 +152,7 @@ fn check_deserialize( // Attempt to finalize the valid operand case. let mut finalize_registers = sample_finalize_registers(&stack, &function_name, &[bit_array]).unwrap(); - let result_c = operation.finalize(&stack, &mut finalize_registers); + let result_c = operation.finalize(&stack, None, &mut finalize_registers); // Check that either all operations failed, or all operations succeeded. let all_failed = result_a.is_err() && result_b.is_err() && result_c.is_err(); diff --git a/synthesizer/program/tests/instruction/ecdsa.rs b/synthesizer/program/tests/instruction/ecdsa.rs index 603fb1b766..cca3f27b90 100644 --- a/synthesizer/program/tests/instruction/ecdsa.rs +++ b/synthesizer/program/tests/instruction/ecdsa.rs @@ -290,7 +290,7 @@ fn check_ecdsa>>( message.clone(), ) .unwrap(); - let result_a = operation.finalize(&stack, &mut finalize_registers); + let result_a = operation.finalize(&stack, None, &mut finalize_registers); // Enforce that the signature verifies successfully. assert!(result_a.is_ok(), "The finalization should succeed for a valid operand"); let output = finalize_registers.load(&stack, &destination_operand).unwrap(); @@ -313,7 +313,7 @@ fn check_ecdsa>>( message, ) .unwrap(); - let result_b = operation.finalize(&stack, &mut finalize_registers); + let result_b = operation.finalize(&stack, None, &mut finalize_registers); // Enforce that the signature verification fails. assert!(result_b.is_ok(), "The finalization should succeed for the operand"); let output = finalize_registers.load(&stack, &destination_operand).unwrap(); diff --git a/synthesizer/program/tests/instruction/hash.rs b/synthesizer/program/tests/instruction/hash.rs index 587f19f9ce..0029cdefb9 100644 --- a/synthesizer/program/tests/instruction/hash.rs +++ b/synthesizer/program/tests/instruction/hash.rs @@ -273,7 +273,7 @@ fn check_hash( // Attempt to finalize the valid operand case. let mut finalize_registers = sample_finalize_registers(&stack, &function_name, &[input]).unwrap(); - let result_c = operation.finalize(&stack, &mut finalize_registers); + let result_c = operation.finalize(&stack, None, &mut finalize_registers); // Check that either all operations failed, or all operations succeeded. let all_failed = result_a.is_err() && result_b.is_err() && result_c.is_err(); diff --git a/synthesizer/program/tests/instruction/is.rs b/synthesizer/program/tests/instruction/is.rs index f8d1ed1989..37485e5a40 100644 --- a/synthesizer/program/tests/instruction/is.rs +++ b/synthesizer/program/tests/instruction/is.rs @@ -191,7 +191,7 @@ fn check_is( Plaintext::from(literal_a), ]) .unwrap(); - operation.finalize(&stack, &mut registers).unwrap(); + operation.finalize(&stack, None, &mut registers).unwrap(); // Retrieve the output. let output_c = registers.load_literal(&stack, &destination_operand).unwrap(); @@ -287,7 +287,7 @@ fn check_is( Plaintext::from(literal_b), ]) .unwrap(); - operation.finalize(&stack, &mut registers).unwrap(); + operation.finalize(&stack, None, &mut registers).unwrap(); // Retrieve the output. let output_c = registers.load_literal(&stack, &destination_operand).unwrap(); diff --git a/synthesizer/program/tests/instruction/serialize.rs b/synthesizer/program/tests/instruction/serialize.rs index 81c74e4199..299870dd63 100644 --- a/synthesizer/program/tests/instruction/serialize.rs +++ b/synthesizer/program/tests/instruction/serialize.rs @@ -149,7 +149,7 @@ fn check_serialize( // Attempt to finalize the valid operand case. let mut finalize_registers = sample_finalize_registers(&stack, &function_name, &[plaintext]).unwrap(); - let result_c = operation.finalize(&stack, &mut finalize_registers); + let result_c = operation.finalize(&stack, None, &mut finalize_registers); // Check that either all operations failed, or all operations succeeded. let all_failed = result_a.is_err() && result_b.is_err() && result_c.is_err(); diff --git a/synthesizer/src/vm/tests/test_v15/views.rs b/synthesizer/src/vm/tests/test_v15/views.rs index b4fe46e620..d5d433622b 100644 --- a/synthesizer/src/vm/tests/test_v15/views.rs +++ b/synthesizer/src/vm/tests/test_v15/views.rs @@ -595,3 +595,1633 @@ view echo: assert_eq!(block.transactions().num_accepted(), 0, "Deployment with string-typed view input should be rejected"); assert_eq!(block.aborted_transaction_ids().len(), 1); } + +/// Tests that a finalize body can call a view function in the SAME program and route its +/// return value through normal finalize logic. Deploys a program with a `lookup` view, then +/// executes a function whose finalize calls `lookup`, doubles the result, and writes it back. +#[test] +fn test_finalize_calls_same_program_view() -> Result<()> { + let rng = &mut TestRng::default(); + let caller_private_key = sample_genesis_private_key(rng); + let caller_address = Address::try_from(&caller_private_key)?; + + let program = Program::from_str( + r" + program vw_call_same.aleo; + + mapping balances: + key as address.public; + value as u64.public; + + mapping doubled: + key as address.public; + value as u64.public; + + function seed: + input r0 as address.public; + input r1 as u64.public; + async seed r0 r1 into r2; + output r2 as vw_call_same.aleo/seed.future; + + finalize seed: + input r0 as address.public; + input r1 as u64.public; + set r1 into balances[r0]; + + function compute_doubled: + input r0 as address.public; + async compute_doubled r0 into r1; + output r1 as vw_call_same.aleo/compute_doubled.future; + + // The finalize body calls the in-program `lookup` view, multiplies the result by 2, + // and stores it in the `doubled` mapping. + finalize compute_doubled: + input r0 as address.public; + call lookup r0 into r1; + mul r1 2u64 into r2; + set r2 into doubled[r0]; + + view lookup: + input r0 as address.public; + get.or_use balances[r0] 0u64 into r1; + output r1 as u64.public; + + constructor: + assert.eq true true; + ", + )?; + + let vm = sample_vm_at_height(CurrentNetwork::CONSENSUS_HEIGHT(ConsensusVersion::V15)?, rng); + + // Deploy. + let tx = vm.deploy(&caller_private_key, &program, None, 0, None, rng)?; + add_and_test_with_costs(&vm, &caller_private_key, &caller_address, None, &[tx], rng); + + // Seed `balances[caller] = 21`. + let inputs = [Value::from_str(&caller_address.to_string())?, Value::from_str("21u64")?]; + let tx = vm.execute(&caller_private_key, ("vw_call_same.aleo", "seed"), inputs.iter(), None, 0, None, rng)?; + add_and_test_with_costs(&vm, &caller_private_key, &caller_address, Some(&[&inputs]), &[tx], rng); + + // Run `compute_doubled(caller)`. Its finalize calls `lookup(caller)` (→ 21), doubles + // (→ 42), and writes to `doubled[caller]`. + let inputs = [Value::from_str(&caller_address.to_string())?]; + let tx = + vm.execute(&caller_private_key, ("vw_call_same.aleo", "compute_doubled"), inputs.iter(), None, 0, None, rng)?; + add_and_test_with_costs(&vm, &caller_private_key, &caller_address, Some(&[&inputs]), &[tx], rng); + + // Confirm the new mapping value via an external read. + #[cfg(feature = "history")] + { + let outputs = vm.evaluate_view_at_height( + "vw_call_same.aleo", + "lookup", + vec![Value::from_str(&caller_address.to_string())?], + vm.block_store().current_block_height(), + )?; + // The view reads `balances`, which still holds 21 (untouched by `compute_doubled`'s finalize). + assert_eq!(expect_u64(&outputs), 21, "external view of `lookup` should still see balances=21"); + } + + Ok(()) +} + +/// Tests that a finalize body can call a view function in an IMPORTED (external) program. +#[test] +fn test_finalize_calls_cross_program_view() -> Result<()> { + let rng = &mut TestRng::default(); + let caller_private_key = sample_genesis_private_key(rng); + let caller_address = Address::try_from(&caller_private_key)?; + + // The "data" program: holds the `balances` mapping and the `lookup` view. + let data_program = Program::from_str( + r" + program vw_call_data.aleo; + + mapping balances: + key as address.public; + value as u64.public; + + function seed: + input r0 as address.public; + input r1 as u64.public; + async seed r0 r1 into r2; + output r2 as vw_call_data.aleo/seed.future; + + finalize seed: + input r0 as address.public; + input r1 as u64.public; + set r1 into balances[r0]; + + view lookup: + input r0 as address.public; + get.or_use balances[r0] 0u64 into r1; + output r1 as u64.public; + + constructor: + assert.eq true true; + ", + )?; + + // The "caller" program: imports `vw_call_data.aleo` and calls its `lookup` view from + // within its own finalize body. + let caller_program = Program::from_str( + r" + import vw_call_data.aleo; + + program vw_call_caller.aleo; + + mapping doubled: + key as address.public; + value as u64.public; + + function compute_doubled: + input r0 as address.public; + async compute_doubled r0 into r1; + output r1 as vw_call_caller.aleo/compute_doubled.future; + + finalize compute_doubled: + input r0 as address.public; + call vw_call_data.aleo/lookup r0 into r1; + mul r1 3u64 into r2; + set r2 into doubled[r0]; + + constructor: + assert.eq true true; + ", + )?; + + let vm = sample_vm_at_height(CurrentNetwork::CONSENSUS_HEIGHT(ConsensusVersion::V15)?, rng); + + // Deploy the data program, then the caller program (which imports it). + let tx_data = vm.deploy(&caller_private_key, &data_program, None, 0, None, rng)?; + add_and_test_with_costs(&vm, &caller_private_key, &caller_address, None, &[tx_data], rng); + let tx_caller = vm.deploy(&caller_private_key, &caller_program, None, 0, None, rng)?; + add_and_test_with_costs(&vm, &caller_private_key, &caller_address, None, &[tx_caller], rng); + + // Seed `balances[caller] = 14` in the data program. + let inputs = [Value::from_str(&caller_address.to_string())?, Value::from_str("14u64")?]; + let tx = vm.execute(&caller_private_key, ("vw_call_data.aleo", "seed"), inputs.iter(), None, 0, None, rng)?; + add_and_test_with_costs(&vm, &caller_private_key, &caller_address, Some(&[&inputs]), &[tx], rng); + + // Run `compute_doubled(caller)`. Its finalize calls `vw_call_data.aleo/lookup(caller)` + // (→ 14), multiplies by 3 (→ 42), and writes to `doubled[caller]`. + let inputs = [Value::from_str(&caller_address.to_string())?]; + let tx = + vm.execute(&caller_private_key, ("vw_call_caller.aleo", "compute_doubled"), inputs.iter(), None, 0, None, rng)?; + add_and_test_with_costs(&vm, &caller_private_key, &caller_address, Some(&[&inputs]), &[tx], rng); + + Ok(()) +} + +/// Tests that a finalize body calling a NON-view target (i.e. a regular function) is rejected +/// at deploy time. The type-check resolves the target and bails because it is not a view. +#[test] +fn test_finalize_calls_non_view_rejected_at_deploy() { + let rng = &mut TestRng::default(); + let caller_private_key = sample_genesis_private_key(rng); + let _caller_address = Address::try_from(&caller_private_key).unwrap(); + + let program = Program::from_str( + r" + program vw_bad_call.aleo; + + function helper: + input r0 as u64.private; + output r0 as u64.private; + + function caller: + input r0 as u64.public; + async caller r0 into r1; + output r1 as vw_bad_call.aleo/caller.future; + + finalize caller: + input r0 as u64.public; + call helper r0 into r1; + assert.eq r1 r1; + + constructor: + assert.eq true true; + ", + ); + // The program may either fail to parse or fail to deploy depending on which layer catches + // it first; either way it must NOT successfully deploy. + let vm = sample_vm_at_height(CurrentNetwork::CONSENSUS_HEIGHT(ConsensusVersion::V15).unwrap(), rng); + if let Ok(program) = program { + let deploy = vm.deploy(&caller_private_key, &program, None, 0, None, rng); + assert!(deploy.is_err(), "deploy should reject a finalize that calls a non-view target"); + } +} + +/// Tests that pre-V15 deployments of a program containing a `call` in finalize are rejected. +/// `contains_v15_syntax` flags any `call` in a finalize body as V15 syntax. +#[test] +fn test_deploy_finalize_call_before_and_at_v15() { + let rng = &mut TestRng::default(); + let caller_private_key = sample_genesis_private_key(rng); + + let v15_height = CurrentNetwork::CONSENSUS_HEIGHT(ConsensusVersion::V15).unwrap(); + let vm = sample_vm_at_height(v15_height - 1, rng); + + let program = Program::from_str( + r" + program vw_v15_finalize_call.aleo; + + mapping balances: + key as address.public; + value as u64.public; + + function noop: + input r0 as u64.private; + output r0 as u64.private; + + function caller: + input r0 as address.public; + async caller r0 into r1; + output r1 as vw_v15_finalize_call.aleo/caller.future; + + finalize caller: + input r0 as address.public; + call lookup r0 into r1; + set r1 into balances[r0]; + + view lookup: + input r0 as address.public; + get.or_use balances[r0] 0u64 into r1; + output r1 as u64.public; + + constructor: + assert.eq true true; + ", + ) + .unwrap(); + + // Pre-V15: rejected. + let deployment = vm.deploy(&caller_private_key, &program, None, 0, None, rng).unwrap(); + let block = sample_next_block(&vm, &caller_private_key, &[deployment], rng).unwrap(); + assert_eq!(block.transactions().num_accepted(), 0, "Deployment with finalize-call before V15 should be rejected"); + assert_eq!(block.aborted_transaction_ids().len(), 1); + vm.add_next_block(&block).unwrap(); + assert_eq!(vm.block_store().current_block_height(), v15_height); + + // V15: accepted. + let deployment = vm.deploy(&caller_private_key, &program, None, 0, None, rng).unwrap(); + let block = sample_next_block(&vm, &caller_private_key, &[deployment], rng).unwrap(); + assert_eq!(block.transactions().num_accepted(), 1, "Deployment with finalize-call at V15 should be accepted"); + assert_eq!(block.aborted_transaction_ids().len(), 0); + vm.add_next_block(&block).unwrap(); +} + +/// Tests two semantics together: +/// 1. A single `finalize` body may call the same view multiple times. +/// 2. The second call sees the caller's intervening `set` to the same mapping — i.e. each +/// view call observes the live finalize-batch state at the time of the call, including +/// the caller's own pending writes. +/// +/// Program shape: `balances` is seeded to 11 via `seed`, then `compute`'s finalize calls +/// `lookup` twice with a `set balances[r0] = 55` in between. The two view outputs are packed +/// as `v_old * 1000 + v_new` and stored in `before_after[r0]`. We assert it equals 11_055, +/// which uniquely encodes (11, 55) — proving the first call saw 11 and the second saw 55. +#[test] +fn test_finalize_multiple_calls_and_interleaved_writes() -> Result<()> { + let rng = &mut TestRng::default(); + let caller_private_key = sample_genesis_private_key(rng); + let caller_address = Address::try_from(&caller_private_key)?; + + let program = Program::from_str( + r" + program vw_call_seq.aleo; + + mapping balances: + key as address.public; + value as u64.public; + + mapping before_after: + key as address.public; + value as u64.public; + + function seed: + input r0 as address.public; + input r1 as u64.public; + async seed r0 r1 into r2; + output r2 as vw_call_seq.aleo/seed.future; + + finalize seed: + input r0 as address.public; + input r1 as u64.public; + set r1 into balances[r0]; + + function compute: + input r0 as address.public; + async compute r0 into r1; + output r1 as vw_call_seq.aleo/compute.future; + + finalize compute: + input r0 as address.public; + call lookup r0 into r1; + set 55u64 into balances[r0]; + call lookup r0 into r2; + mul r1 1000u64 into r3; + add r3 r2 into r4; + set r4 into before_after[r0]; + + view lookup: + input r0 as address.public; + get.or_use balances[r0] 0u64 into r1; + output r1 as u64.public; + + constructor: + assert.eq true true; + ", + )?; + + let vm = sample_vm_at_height(CurrentNetwork::CONSENSUS_HEIGHT(ConsensusVersion::V15)?, rng); + + // Deploy. + let tx = vm.deploy(&caller_private_key, &program, None, 0, None, rng)?; + add_and_test_with_costs(&vm, &caller_private_key, &caller_address, None, &[tx], rng); + + // Seed `balances[caller] = 11`. + let inputs = [Value::from_str(&caller_address.to_string())?, Value::from_str("11u64")?]; + let tx = vm.execute(&caller_private_key, ("vw_call_seq.aleo", "seed"), inputs.iter(), None, 0, None, rng)?; + add_and_test_with_costs(&vm, &caller_private_key, &caller_address, Some(&[&inputs]), &[tx], rng); + + // Run `compute(caller)`. The finalize: + // - first call → v_old = 11 + // - set balances[caller] = 55 + // - second call → v_new = 55 + // - store 11 * 1000 + 55 = 11055 in `before_after[caller]`. + let inputs = [Value::from_str(&caller_address.to_string())?]; + let tx = vm.execute(&caller_private_key, ("vw_call_seq.aleo", "compute"), inputs.iter(), None, 0, None, rng)?; + add_and_test_with_costs(&vm, &caller_private_key, &caller_address, Some(&[&inputs]), &[tx], rng); + + // Confirm both calls observed the expected (old, new) pair via the encoded result. + #[cfg(feature = "history")] + { + // We expose the encoded value via `lookup` on `before_after` — but `lookup` reads + // `balances`, not `before_after`. Use a direct historic mapping read instead. + let height = vm.block_store().current_block_height(); + let key = console::program::Plaintext::from(console::program::Literal::Address(caller_address)); + let value = vm + .finalize_store() + .get_historical_mapping_value( + *program.id(), + console::program::Identifier::from_str("before_after")?, + key, + height, + )? + .expect("before_after should have a value at the current height"); + match &*value { + Value::Plaintext(console::program::Plaintext::Literal(console::program::Literal::U64(encoded), _)) => { + assert_eq!( + **encoded, 11_055u64, + "expected v_old*1000 + v_new = 11055, got {encoded}; the two view calls must \ + observe (11, 55) respectively, with the intervening `set` visible to the second call", + ); + } + other => panic!("expected u64 plaintext, got {other:?}"), + } + } + + // Also confirm the final committed balance is 55 (the intervening set landed). + #[cfg(feature = "history")] + { + let outputs = vm.evaluate_view_at_height( + "vw_call_seq.aleo", + "lookup", + vec![Value::from_str(&caller_address.to_string())?], + vm.block_store().current_block_height(), + )?; + assert_eq!(expect_u64(&outputs), 55, "final balance after `compute` must be 55"); + } + + Ok(()) +} + +/// Cross-program negative: an importing program's finalize body calls a regular `function` in an +/// imported program (not a view). Same shape as the same-program negative test, but goes through +/// the `CallOperator::Locator` resolution path. Must be rejected at deploy time. +#[test] +fn test_finalize_calls_cross_program_non_view_rejected_at_deploy() { + let rng = &mut TestRng::default(); + let caller_private_key = sample_genesis_private_key(rng); + let caller_address = Address::try_from(&caller_private_key).unwrap(); + + // Imported program: exposes a regular `function` (no view). + let data_program = Program::from_str( + r" + program vw_bad_cross_data.aleo; + + function helper: + input r0 as u64.public; + output r0 as u64.public; + + constructor: + assert.eq true true; + ", + ) + .unwrap(); + + // Caller: tries to `call vw_bad_cross_data.aleo/helper` from within its finalize body. + let caller_program = Program::from_str( + r" + import vw_bad_cross_data.aleo; + + program vw_bad_cross_caller.aleo; + + function caller: + input r0 as u64.public; + async caller r0 into r1; + output r1 as vw_bad_cross_caller.aleo/caller.future; + + finalize caller: + input r0 as u64.public; + call vw_bad_cross_data.aleo/helper r0 into r1; + assert.eq r1 r1; + + constructor: + assert.eq true true; + ", + ); + + let vm = sample_vm_at_height(CurrentNetwork::CONSENSUS_HEIGHT(ConsensusVersion::V15).unwrap(), rng); + let tx_data = vm.deploy(&caller_private_key, &data_program, None, 0, None, rng).unwrap(); + add_and_test_with_costs(&vm, &caller_private_key, &caller_address, None, &[tx_data], rng); + + // Either parse or deploy must reject — same pattern as the same-program negative test. + if let Ok(caller_program) = caller_program { + let deploy = vm.deploy(&caller_private_key, &caller_program, None, 0, None, rng); + assert!(deploy.is_err(), "deploy should reject a finalize that calls a non-view target in an imported program"); + } +} + +/// Negative: deploy is rejected when the call's operand count does not match the view's input +/// arity. Exercises the arity check in `Call::output_types`. +#[test] +fn test_finalize_call_arity_mismatch_rejected_at_deploy() { + let rng = &mut TestRng::default(); + let caller_private_key = sample_genesis_private_key(rng); + + // `lookup` declares one input, but the caller passes two. + let program = Program::from_str( + r" + program vw_call_arity.aleo; + + mapping balances: + key as address.public; + value as u64.public; + + function caller: + input r0 as address.public; + async caller r0 into r1; + output r1 as vw_call_arity.aleo/caller.future; + + finalize caller: + input r0 as address.public; + call lookup r0 r0 into r1; + assert.eq r1 r1; + + view lookup: + input r0 as address.public; + get.or_use balances[r0] 0u64 into r1; + output r1 as u64.public; + + constructor: + assert.eq true true; + ", + ); + + let vm = sample_vm_at_height(CurrentNetwork::CONSENSUS_HEIGHT(ConsensusVersion::V15).unwrap(), rng); + if let Ok(program) = program { + let deploy = vm.deploy(&caller_private_key, &program, None, 0, None, rng); + assert!(deploy.is_err(), "deploy should reject a finalize-call with wrong arity"); + } +} + +/// Negative: deploy is rejected when a destination of a finalize-call is consumed downstream as +/// a type that does not match the view's declared output type. Proves that `Call::output_types` +/// propagates the view's output types into the surrounding finalize type-check. +#[test] +fn test_finalize_call_destination_type_mismatch_rejected_at_deploy() { + let rng = &mut TestRng::default(); + let caller_private_key = sample_genesis_private_key(rng); + + // `lookup` outputs `u64`, but the caller treats `r1` as `u32` in the following `add`. + let program = Program::from_str( + r" + program vw_call_type_mismatch.aleo; + + mapping balances: + key as address.public; + value as u64.public; + + function caller: + input r0 as address.public; + async caller r0 into r1; + output r1 as vw_call_type_mismatch.aleo/caller.future; + + finalize caller: + input r0 as address.public; + call lookup r0 into r1; + add r1 1u32 into r2; + assert.eq r2 r2; + + view lookup: + input r0 as address.public; + get.or_use balances[r0] 0u64 into r1; + output r1 as u64.public; + + constructor: + assert.eq true true; + ", + ); + + let vm = sample_vm_at_height(CurrentNetwork::CONSENSUS_HEIGHT(ConsensusVersion::V15).unwrap(), rng); + if let Ok(program) = program { + let deploy = vm.deploy(&caller_private_key, &program, None, 0, None, rng); + assert!(deploy.is_err(), "deploy should reject when call destination type conflicts with downstream use"); + } +} + +/// Construction-time rejection: a `call` inside a view body is rejected. Views are leaves in +/// the call graph — `ViewCore::add_command` rejects `is_call()` so a view cannot invoke another +/// view (or any function), preventing recursion at the structural level. +#[test] +fn test_view_rejects_call_command_at_parse() { + let result = Program::::from_str( + r" + program vw_bad_call_in_view.aleo; + + mapping balances: + key as address.public; + value as u64.public; + + function noop: + input r0 as u64.private; + output r0 as u64.private; + + constructor: + assert.eq true true; + + view inner: + input r0 as address.public; + get.or_use balances[r0] 0u64 into r1; + output r1 as u64.public; + + view outer: + input r0 as address.public; + call inner r0 into r1; + output r1 as u64.public; + ", + ); + assert!(result.is_err(), "expected parse error for a view body containing `call`"); +} + +/// Negative: a finalize body that uses a `Locator` form to call its own program (instead of +/// the `Resource` form) is rejected at deploy. Same-program calls must use the bare resource +/// name; a self-locator is treated as an error since the locator form is reserved for external +/// programs. +#[test] +fn test_finalize_call_self_locator_rejected_at_deploy() { + let rng = &mut TestRng::default(); + let caller_private_key = sample_genesis_private_key(rng); + + let program = Program::from_str( + r" + program vw_self_locator.aleo; + + mapping balances: + key as address.public; + value as u64.public; + + function caller: + input r0 as address.public; + async caller r0 into r1; + output r1 as vw_self_locator.aleo/caller.future; + + finalize caller: + input r0 as address.public; + call vw_self_locator.aleo/lookup r0 into r1; + assert.eq r1 r1; + + view lookup: + input r0 as address.public; + get.or_use balances[r0] 0u64 into r1; + output r1 as u64.public; + + constructor: + assert.eq true true; + ", + ); + + let vm = sample_vm_at_height(CurrentNetwork::CONSENSUS_HEIGHT(ConsensusVersion::V15).unwrap(), rng); + if let Ok(program) = program { + let deploy = vm.deploy(&caller_private_key, &program, None, 0, None, rng); + assert!(deploy.is_err(), "deploy should reject a finalize-call using a self-locator"); + } +} + +/// Negative: a finalize body that calls into an external program which is not declared in +/// `import` is rejected at deploy. The target program is deployed independently, but the caller +/// never imports it — the explicit import check in the finalize type-check fires before the +/// external stack lookup. +#[test] +fn test_finalize_call_missing_import_rejected_at_deploy() { + let rng = &mut TestRng::default(); + let caller_private_key = sample_genesis_private_key(rng); + let caller_address = Address::try_from(&caller_private_key).unwrap(); + + // Target program with a view (deployed first). Includes a noop function so the program + // has at least one deployable function alongside the view. + let data_program = Program::from_str( + r" + program vw_no_import_data.aleo; + + mapping balances: + key as address.public; + value as u64.public; + + function noop: + input r0 as u64.private; + output r0 as u64.private; + + view lookup: + input r0 as address.public; + get.or_use balances[r0] 0u64 into r1; + output r1 as u64.public; + + constructor: + assert.eq true true; + ", + ) + .unwrap(); + + // Caller that references `vw_no_import_data.aleo/lookup` from finalize but does NOT include + // `import vw_no_import_data.aleo;`. + let caller_program = Program::from_str( + r" + program vw_no_import_caller.aleo; + + function caller: + input r0 as address.public; + async caller r0 into r1; + output r1 as vw_no_import_caller.aleo/caller.future; + + finalize caller: + input r0 as address.public; + call vw_no_import_data.aleo/lookup r0 into r1; + assert.eq r1 r1; + + constructor: + assert.eq true true; + ", + ); + + let vm = sample_vm_at_height(CurrentNetwork::CONSENSUS_HEIGHT(ConsensusVersion::V15).unwrap(), rng); + let tx_data = vm.deploy(&caller_private_key, &data_program, None, 0, None, rng).unwrap(); + add_and_test_with_costs(&vm, &caller_private_key, &caller_address, None, &[tx_data], rng); + + if let Ok(caller_program) = caller_program { + let deploy = vm.deploy(&caller_private_key, &caller_program, None, 0, None, rng); + assert!(deploy.is_err(), "deploy should reject a finalize-call into a program that is not imported"); + } +} + +/// Construction-time rejection: `call.dynamic` is forbidden inside a finalize body. The +/// `Finalize::add_command` guard rejects `is_dynamic_call`, so parsing the program must fail. +#[test] +fn test_finalize_rejects_call_dynamic_at_parse() { + let result = Program::::from_str( + r" + program vw_bad_call_dynamic.aleo; + + function caller: + input r0 as field.public; + input r1 as field.public; + input r2 as field.public; + async caller r0 r1 r2 into r3; + output r3 as vw_bad_call_dynamic.aleo/caller.future; + + finalize caller: + input r0 as field.public; + input r1 as field.public; + input r2 as field.public; + call.dynamic r0 r1 r2 into r3 (as u64.public); + assert.eq r3 r3; + + constructor: + assert.eq true true; + ", + ); + assert!(result.is_err(), "expected parse error for `call.dynamic` inside a finalize body"); +} + +/// Behavioral: a single finalize-to-view call returns multiple primitive values of different +/// types, each routed into its own destination register and written to a distinct mapping. This +/// exercises: +/// - the destination-zip path in `Call::output_types` for N>1 outputs, +/// - per-destination type propagation when output types differ (u64, boolean, address), +/// - end-to-end storage of each typed value via `set`. +#[test] +fn test_finalize_call_multi_output_multi_type() -> Result<()> { + let rng = &mut TestRng::default(); + let caller_private_key = sample_genesis_private_key(rng); + let caller_address = Address::try_from(&caller_private_key)?; + + let program = Program::from_str( + r" + program vw_multi_type.aleo; + + mapping balances: + key as address.public; + value as u64.public; + + mapping flags: + key as address.public; + value as boolean.public; + + mapping owners: + key as address.public; + value as address.public; + + function compute: + input r0 as address.public; + async compute r0 into r1; + output r1 as vw_multi_type.aleo/compute.future; + + finalize compute: + input r0 as address.public; + call summary r0 into r1 r2 r3; + set r1 into balances[r0]; + set r2 into flags[r0]; + set r3 into owners[r0]; + + view summary: + input r0 as address.public; + get.or_use balances[r0] 0u64 into r1; + add r1 7u64 into r2; + gt r2 5u64 into r3; + output r2 as u64.public; + output r3 as boolean.public; + output r0 as address.public; + + constructor: + assert.eq true true; + ", + )?; + + let vm = sample_vm_at_height(CurrentNetwork::CONSENSUS_HEIGHT(ConsensusVersion::V15)?, rng); + + // Deploy. + let tx = vm.deploy(&caller_private_key, &program, None, 0, None, rng)?; + add_and_test_with_costs(&vm, &caller_private_key, &caller_address, None, &[tx], rng); + + // Run `compute(caller)`. The view observes balances[caller]=0, returns (7u64, true, caller), + // and the finalize routes each output into its respective mapping. + let inputs = [Value::from_str(&caller_address.to_string())?]; + let tx = vm.execute(&caller_private_key, ("vw_multi_type.aleo", "compute"), inputs.iter(), None, 0, None, rng)?; + add_and_test_with_costs(&vm, &caller_private_key, &caller_address, Some(&[&inputs]), &[tx], rng); + + // Verify each destination received the expected typed value. + #[cfg(feature = "history")] + { + let height = vm.block_store().current_block_height(); + let key = console::program::Plaintext::from(console::program::Literal::Address(caller_address)); + let program_id = *program.id(); + + let balances_value = vm + .finalize_store() + .get_historical_mapping_value( + program_id, + console::program::Identifier::from_str("balances")?, + key.clone(), + height, + )? + .expect("balances should be set"); + match &*balances_value { + Value::Plaintext(console::program::Plaintext::Literal(console::program::Literal::U64(v), _)) => { + assert_eq!(**v, 7u64, "balances[caller] must equal the view's u64 output (0 + 7)"); + } + other => panic!("expected u64 plaintext for balances, got {other:?}"), + } + + let flags_value = vm + .finalize_store() + .get_historical_mapping_value( + program_id, + console::program::Identifier::from_str("flags")?, + key.clone(), + height, + )? + .expect("flags should be set"); + match &*flags_value { + Value::Plaintext(console::program::Plaintext::Literal(console::program::Literal::Boolean(v), _)) => { + assert!(**v, "flags[caller] must equal the view's boolean output (7 > 5)"); + } + other => panic!("expected boolean plaintext for flags, got {other:?}"), + } + + let owners_value = vm + .finalize_store() + .get_historical_mapping_value(program_id, console::program::Identifier::from_str("owners")?, key, height)? + .expect("owners should be set"); + match &*owners_value { + Value::Plaintext(console::program::Plaintext::Literal(console::program::Literal::Address(v), _)) => { + assert_eq!(*v, caller_address, "owners[caller] must equal the view's address output"); + } + other => panic!("expected address plaintext for owners, got {other:?}"), + } + } + + Ok(()) +} + +/// Behavioral: a view in one program returns a struct value, and an importing program's +/// finalize body calls into it, extracts a struct field from the destination, and stores it. +/// Exercises `RegisterType::qualify` on a struct type crossing the program boundary: the +/// destination is typed as `vw_struct_data.aleo/Summary` in the caller, and downstream field +/// access (`r1.total`) must resolve against the external struct definition. +#[test] +fn test_finalize_call_struct_return_cross_program() -> Result<()> { + let rng = &mut TestRng::default(); + let caller_private_key = sample_genesis_private_key(rng); + let caller_address = Address::try_from(&caller_private_key)?; + + let data_program = Program::from_str( + r" + program vw_struct_data.aleo; + + struct Summary: + total as u64; + flag as boolean; + + mapping balances: + key as address.public; + value as u64.public; + + function seed: + input r0 as address.public; + input r1 as u64.public; + async seed r0 r1 into r2; + output r2 as vw_struct_data.aleo/seed.future; + + finalize seed: + input r0 as address.public; + input r1 as u64.public; + set r1 into balances[r0]; + + view summarize: + input r0 as address.public; + get.or_use balances[r0] 0u64 into r1; + gt r1 50u64 into r2; + cast r1 r2 into r3 as Summary; + output r3 as Summary.public; + + constructor: + assert.eq true true; + ", + )?; + + let caller_program = Program::from_str( + r" + import vw_struct_data.aleo; + + program vw_struct_caller.aleo; + + mapping totals: + key as address.public; + value as u64.public; + + function compute: + input r0 as address.public; + async compute r0 into r1; + output r1 as vw_struct_caller.aleo/compute.future; + + finalize compute: + input r0 as address.public; + call vw_struct_data.aleo/summarize r0 into r1; + add r1.total 0u64 into r2; + set r2 into totals[r0]; + + constructor: + assert.eq true true; + ", + )?; + + let vm = sample_vm_at_height(CurrentNetwork::CONSENSUS_HEIGHT(ConsensusVersion::V15)?, rng); + + // Deploy data then caller (caller imports data). + let tx_data = vm.deploy(&caller_private_key, &data_program, None, 0, None, rng)?; + add_and_test_with_costs(&vm, &caller_private_key, &caller_address, None, &[tx_data], rng); + let tx_caller = vm.deploy(&caller_private_key, &caller_program, None, 0, None, rng)?; + add_and_test_with_costs(&vm, &caller_private_key, &caller_address, None, &[tx_caller], rng); + + // Seed balances[caller] = 77 in the data program. + let inputs = [Value::from_str(&caller_address.to_string())?, Value::from_str("77u64")?]; + let tx = vm.execute(&caller_private_key, ("vw_struct_data.aleo", "seed"), inputs.iter(), None, 0, None, rng)?; + add_and_test_with_costs(&vm, &caller_private_key, &caller_address, Some(&[&inputs]), &[tx], rng); + + // Run `compute(caller)`. The finalize calls `summarize` which returns Summary{total: 77, + // flag: true}, extracts the `total` field, and stores it in `totals`. + let inputs = [Value::from_str(&caller_address.to_string())?]; + let tx = + vm.execute(&caller_private_key, ("vw_struct_caller.aleo", "compute"), inputs.iter(), None, 0, None, rng)?; + add_and_test_with_costs(&vm, &caller_private_key, &caller_address, Some(&[&inputs]), &[tx], rng); + + // Verify totals[caller] = 77 (the extracted .total field of the returned struct). + #[cfg(feature = "history")] + { + let height = vm.block_store().current_block_height(); + let key = console::program::Plaintext::from(console::program::Literal::Address(caller_address)); + let totals_value = vm + .finalize_store() + .get_historical_mapping_value( + *caller_program.id(), + console::program::Identifier::from_str("totals")?, + key, + height, + )? + .expect("totals should be set"); + match &*totals_value { + Value::Plaintext(console::program::Plaintext::Literal(console::program::Literal::U64(v), _)) => { + assert_eq!(**v, 77u64, "totals[caller] must equal the .total field of the cross-program struct return"); + } + other => panic!("expected u64 plaintext, got {other:?}"), + } + } + + Ok(()) +} + +/// Negative: deploy is rejected when the call lists more destinations than the view declares +/// outputs. Mirrors the operand-arity test but exercises the destination-count check in +/// `Call::output_types_for_view`. +#[test] +fn test_finalize_call_destination_count_mismatch_rejected_at_deploy() { + let rng = &mut TestRng::default(); + let caller_private_key = sample_genesis_private_key(rng); + + // `lookup` declares one output, but the caller binds two destinations. + let program = Program::from_str( + r" + program vw_call_dest_count.aleo; + + mapping balances: + key as address.public; + value as u64.public; + + function caller: + input r0 as address.public; + async caller r0 into r1; + output r1 as vw_call_dest_count.aleo/caller.future; + + finalize caller: + input r0 as address.public; + call lookup r0 into r1 r2; + assert.eq r1 r1; + + view lookup: + input r0 as address.public; + get.or_use balances[r0] 0u64 into r1; + output r1 as u64.public; + + constructor: + assert.eq true true; + ", + ); + + let vm = sample_vm_at_height(CurrentNetwork::CONSENSUS_HEIGHT(ConsensusVersion::V15).unwrap(), rng); + if let Ok(program) = program { + let deploy = vm.deploy(&caller_private_key, &program, None, 0, None, rng); + assert!( + deploy.is_err(), + "deploy should reject a finalize-call binding more destinations than the view returns" + ); + } +} + +/// Negative: deploy is rejected when an operand's register type does not match the view's +/// declared input type. The caller passes a `u32` register where the view expects `u64`. The +/// per-operand type check in `Call::output_types_for_view` rejects this at deploy rather than +/// deferring to runtime. +#[test] +fn test_finalize_call_input_type_mismatch_rejected_at_deploy() { + let rng = &mut TestRng::default(); + let caller_private_key = sample_genesis_private_key(rng); + + // The finalize body propagates a `u32` register as the operand to `lookup`, which expects `u64`. + let program = Program::from_str( + r" + program vw_call_in_type.aleo; + + mapping totals: + key as address.public; + value as u64.public; + + function caller: + input r0 as address.public; + input r1 as u32.public; + async caller r0 r1 into r2; + output r2 as vw_call_in_type.aleo/caller.future; + + finalize caller: + input r0 as address.public; + input r1 as u32.public; + call double r1 into r2; + set r2 into totals[r0]; + + view double: + input r0 as u64.public; + add r0 r0 into r1; + output r1 as u64.public; + + constructor: + assert.eq true true; + ", + ); + + let vm = sample_vm_at_height(CurrentNetwork::CONSENSUS_HEIGHT(ConsensusVersion::V15).unwrap(), rng); + if let Ok(program) = program { + let deploy = vm.deploy(&caller_private_key, &program, None, 0, None, rng); + assert!(deploy.is_err(), "deploy should reject a finalize-call with a wrong-typed operand"); + } +} + +/// Runtime: a view body that errors at runtime (e.g. `get` on a missing key) propagates the +/// failure through the in-finalize call, and the surrounding transaction is finalize-rejected. +/// The deploy itself succeeds — the failure is exclusively a runtime path through +/// `evaluate_call_to_view` -> `evaluate_view_inner` -> per-command bail. +#[test] +fn test_finalize_call_view_runtime_failure_rejects_tx() -> Result<()> { + let rng = &mut TestRng::default(); + let caller_private_key = sample_genesis_private_key(rng); + let caller_address = Address::try_from(&caller_private_key)?; + + // `strict_lookup` uses a non-`or_use` `get`, so a missing key surfaces an error. + let program = Program::from_str( + r" + program vw_call_runtime_fail.aleo; + + mapping balances: + key as address.public; + value as u64.public; + + mapping out: + key as address.public; + value as u64.public; + + function caller: + input r0 as address.public; + async caller r0 into r1; + output r1 as vw_call_runtime_fail.aleo/caller.future; + + finalize caller: + input r0 as address.public; + call strict_lookup r0 into r1; + set r1 into out[r0]; + + view strict_lookup: + input r0 as address.public; + get balances[r0] into r1; + output r1 as u64.public; + + constructor: + assert.eq true true; + ", + )?; + + let vm = sample_vm_at_height(CurrentNetwork::CONSENSUS_HEIGHT(ConsensusVersion::V15)?, rng); + + // Deploy succeeds. + let tx = vm.deploy(&caller_private_key, &program, None, 0, None, rng)?; + add_and_test_with_costs(&vm, &caller_private_key, &caller_address, None, &[tx], rng); + + // Execute `caller(caller_address)` — `balances[caller_address]` is unset, so the view's + // strict `get` fails. The block must finalize-reject the transaction. + let inputs = [Value::from_str(&caller_address.to_string())?]; + let tx = + vm.execute(&caller_private_key, ("vw_call_runtime_fail.aleo", "caller"), inputs.iter(), None, 0, None, rng)?; + let block = sample_next_block(&vm, &caller_private_key, &[tx], rng)?; + assert_eq!(block.transactions().num_accepted(), 0, "tx should not be accepted when the called view fails"); + assert_eq!(block.transactions().num_rejected(), 1, "the failing finalize must surface as a rejected tx"); + assert_eq!(block.aborted_transaction_ids().len(), 0); + vm.add_next_block(&block)?; + + // The `out` mapping must be unchanged — finalize rejection rolls the batch back. + #[cfg(feature = "history")] + { + let height = vm.block_store().current_block_height(); + let key = console::program::Plaintext::from(console::program::Literal::Address(caller_address)); + let out_value = vm.finalize_store().get_historical_mapping_value( + console::program::ProgramID::from_str("vw_call_runtime_fail.aleo")?, + console::program::Identifier::from_str("out")?, + key, + height, + )?; + assert!(out_value.is_none(), "out[caller_address] must remain unset after the rejected tx"); + } + + Ok(()) +} + +/// Behavioral: a finalize body whose only command is a `call` to a view. Verifies that the +/// minimal-body shape deploys and executes successfully — the cost-rollup path must accept a +/// single-call finalize body, and the view's output destinations may be bound without being +/// consumed further. +#[test] +fn test_finalize_call_as_only_finalize_instruction() -> Result<()> { + let rng = &mut TestRng::default(); + let caller_private_key = sample_genesis_private_key(rng); + let caller_address = Address::try_from(&caller_private_key)?; + + let program = Program::from_str( + r" + program vw_call_only.aleo; + + mapping balances: + key as address.public; + value as u64.public; + + function caller: + input r0 as address.public; + async caller r0 into r1; + output r1 as vw_call_only.aleo/caller.future; + + finalize caller: + input r0 as address.public; + call lookup r0 into r1; + + view lookup: + input r0 as address.public; + get.or_use balances[r0] 0u64 into r1; + output r1 as u64.public; + + constructor: + assert.eq true true; + ", + )?; + + let vm = sample_vm_at_height(CurrentNetwork::CONSENSUS_HEIGHT(ConsensusVersion::V15)?, rng); + + let tx = vm.deploy(&caller_private_key, &program, None, 0, None, rng)?; + add_and_test_with_costs(&vm, &caller_private_key, &caller_address, None, &[tx], rng); + + let inputs = [Value::from_str(&caller_address.to_string())?]; + let tx = vm.execute(&caller_private_key, ("vw_call_only.aleo", "caller"), inputs.iter(), None, 0, None, rng)?; + add_and_test_with_costs(&vm, &caller_private_key, &caller_address, Some(&[&inputs]), &[tx], rng); + + Ok(()) +} + +/// Behavioral: a finalize body calling a view that takes zero inputs. Confirms the empty-operand +/// path through `Call::output_types_for_view` (arity match against the zero-input view) and the +/// runtime operand-loading loop (which becomes a no-op). +#[test] +fn test_finalize_calls_zero_input_view() -> Result<()> { + let rng = &mut TestRng::default(); + let caller_private_key = sample_genesis_private_key(rng); + let caller_address = Address::try_from(&caller_private_key)?; + + let program = Program::from_str( + r" + program vw_call_zero.aleo; + + mapping out: + key as address.public; + value as u64.public; + + function caller: + input r0 as address.public; + async caller r0 into r1; + output r1 as vw_call_zero.aleo/caller.future; + + finalize caller: + input r0 as address.public; + call answer into r1; + set r1 into out[r0]; + + view answer: + add 40u64 2u64 into r0; + output r0 as u64.public; + + constructor: + assert.eq true true; + ", + )?; + + let vm = sample_vm_at_height(CurrentNetwork::CONSENSUS_HEIGHT(ConsensusVersion::V15)?, rng); + + let tx = vm.deploy(&caller_private_key, &program, None, 0, None, rng)?; + add_and_test_with_costs(&vm, &caller_private_key, &caller_address, None, &[tx], rng); + + let inputs = [Value::from_str(&caller_address.to_string())?]; + let tx = vm.execute(&caller_private_key, ("vw_call_zero.aleo", "caller"), inputs.iter(), None, 0, None, rng)?; + add_and_test_with_costs(&vm, &caller_private_key, &caller_address, Some(&[&inputs]), &[tx], rng); + + // Confirm out[caller] = 42 via the historic store. + #[cfg(feature = "history")] + { + let height = vm.block_store().current_block_height(); + let key = console::program::Plaintext::from(console::program::Literal::Address(caller_address)); + let value = vm + .finalize_store() + .get_historical_mapping_value( + console::program::ProgramID::from_str("vw_call_zero.aleo")?, + console::program::Identifier::from_str("out")?, + key, + height, + )? + .expect("out should be set"); + match &*value { + Value::Plaintext(console::program::Plaintext::Literal(console::program::Literal::U64(v), _)) => { + assert_eq!(**v, 42u64, "zero-input view should return the constant 42"); + } + other => panic!("expected u64 plaintext, got {other:?}"), + } + } + + Ok(()) +} + +/// Behavioral: a `call` to a view sitting inside a `branch.eq` skip range is skipped at runtime +/// when the branch is taken. Exercises the new finalize-Call dispatch from within the branching +/// control-flow already used by other finalize bodies. +#[test] +fn test_finalize_call_inside_branch() -> Result<()> { + let rng = &mut TestRng::default(); + let caller_private_key = sample_genesis_private_key(rng); + let caller_address = Address::try_from(&caller_private_key)?; + + // When `r1 == 0u8`, the finalize takes the branch and skips both the call AND the `set`, + // so `out[r0]` stays unset. When `r1 == 1u8`, the branch is NOT taken: the call runs and the + // result is stored in `out[r0]`. + let program = Program::from_str( + r" + program vw_call_branch.aleo; + + mapping seeds: + key as address.public; + value as u64.public; + + mapping out: + key as address.public; + value as u64.public; + + function seed: + input r0 as address.public; + input r1 as u64.public; + async seed r0 r1 into r2; + output r2 as vw_call_branch.aleo/seed.future; + + finalize seed: + input r0 as address.public; + input r1 as u64.public; + set r1 into seeds[r0]; + + function caller: + input r0 as address.public; + input r1 as u8.public; + async caller r0 r1 into r2; + output r2 as vw_call_branch.aleo/caller.future; + + finalize caller: + input r0 as address.public; + input r1 as u8.public; + branch.eq r1 0u8 to skip; + call lookup r0 into r2; + set r2 into out[r0]; + position skip; + + view lookup: + input r0 as address.public; + get.or_use seeds[r0] 0u64 into r1; + output r1 as u64.public; + + constructor: + assert.eq true true; + ", + )?; + + let vm = sample_vm_at_height(CurrentNetwork::CONSENSUS_HEIGHT(ConsensusVersion::V15)?, rng); + + let tx = vm.deploy(&caller_private_key, &program, None, 0, None, rng)?; + add_and_test_with_costs(&vm, &caller_private_key, &caller_address, None, &[tx], rng); + + // Seed `seeds[caller] = 7`. + let inputs = [Value::from_str(&caller_address.to_string())?, Value::from_str("7u64")?]; + let tx = vm.execute(&caller_private_key, ("vw_call_branch.aleo", "seed"), inputs.iter(), None, 0, None, rng)?; + add_and_test_with_costs(&vm, &caller_private_key, &caller_address, Some(&[&inputs]), &[tx], rng); + + // Branch taken (r1 == 0u8): call is skipped, `out[r0]` stays unset. + let inputs = [Value::from_str(&caller_address.to_string())?, Value::from_str("0u8")?]; + let tx = vm.execute(&caller_private_key, ("vw_call_branch.aleo", "caller"), inputs.iter(), None, 0, None, rng)?; + add_and_test_with_costs(&vm, &caller_private_key, &caller_address, Some(&[&inputs]), &[tx], rng); + + #[cfg(feature = "history")] + { + let height = vm.block_store().current_block_height(); + let key = console::program::Plaintext::from(console::program::Literal::Address(caller_address)); + let value = vm.finalize_store().get_historical_mapping_value( + console::program::ProgramID::from_str("vw_call_branch.aleo")?, + console::program::Identifier::from_str("out")?, + key.clone(), + height, + )?; + assert!(value.is_none(), "out must remain unset when the call is skipped by branch.eq"); + + // Branch NOT taken (r1 == 1u8): call runs and stores 7. + let inputs = [Value::from_str(&caller_address.to_string())?, Value::from_str("1u8")?]; + let tx = + vm.execute(&caller_private_key, ("vw_call_branch.aleo", "caller"), inputs.iter(), None, 0, None, rng)?; + add_and_test_with_costs(&vm, &caller_private_key, &caller_address, Some(&[&inputs]), &[tx], rng); + + let height = vm.block_store().current_block_height(); + let value = vm + .finalize_store() + .get_historical_mapping_value( + console::program::ProgramID::from_str("vw_call_branch.aleo")?, + console::program::Identifier::from_str("out")?, + key, + height, + )? + .expect("out should be set when the branch is not taken"); + match &*value { + Value::Plaintext(console::program::Plaintext::Literal(console::program::Literal::U64(v), _)) => { + assert_eq!(**v, 7u64, "out[caller] must equal the view's result when the call runs"); + } + other => panic!("expected u64 plaintext, got {other:?}"), + } + } + + Ok(()) +} + +/// Behavioral: a single finalize body calls the same IMPORTED view twice (with different keys) +/// and combines the results. Counterpart to the same-program multi-call test +/// (`test_finalize_multiple_calls_and_interleaved_writes`) — exercises that locator-resolution +/// and cost rollup walk correctly on repeated cross-program calls within one finalize. +#[test] +fn test_finalize_cross_program_multiple_calls() -> Result<()> { + let rng = &mut TestRng::default(); + let caller_private_key = sample_genesis_private_key(rng); + let caller_address = Address::try_from(&caller_private_key)?; + + // Data program: holds the `balances` mapping and the `lookup` view. + let data_program = Program::from_str( + r" + program vw_cross_multi_data.aleo; + + mapping balances: + key as address.public; + value as u64.public; + + function seed: + input r0 as address.public; + input r1 as u64.public; + async seed r0 r1 into r2; + output r2 as vw_cross_multi_data.aleo/seed.future; + + finalize seed: + input r0 as address.public; + input r1 as u64.public; + set r1 into balances[r0]; + + view lookup: + input r0 as address.public; + get.or_use balances[r0] 0u64 into r1; + output r1 as u64.public; + + constructor: + assert.eq true true; + ", + )?; + + // Caller: imports the data program and calls `lookup` twice in one finalize body — once + // for each key — and stores their sum. + let caller_program = Program::from_str( + r" + import vw_cross_multi_data.aleo; + + program vw_cross_multi_caller.aleo; + + mapping totals: + key as address.public; + value as u64.public; + + function combine: + input r0 as address.public; + input r1 as address.public; + async combine r0 r1 into r2; + output r2 as vw_cross_multi_caller.aleo/combine.future; + + finalize combine: + input r0 as address.public; + input r1 as address.public; + call vw_cross_multi_data.aleo/lookup r0 into r2; + call vw_cross_multi_data.aleo/lookup r1 into r3; + add r2 r3 into r4; + set r4 into totals[r0]; + + constructor: + assert.eq true true; + ", + )?; + + let vm = sample_vm_at_height(CurrentNetwork::CONSENSUS_HEIGHT(ConsensusVersion::V15)?, rng); + + let tx_data = vm.deploy(&caller_private_key, &data_program, None, 0, None, rng)?; + add_and_test_with_costs(&vm, &caller_private_key, &caller_address, None, &[tx_data], rng); + let tx_caller = vm.deploy(&caller_private_key, &caller_program, None, 0, None, rng)?; + add_and_test_with_costs(&vm, &caller_private_key, &caller_address, None, &[tx_caller], rng); + + // Sample a second address to use as the other key. + let other_private_key = PrivateKey::::new(rng)?; + let other_address = Address::try_from(&other_private_key)?; + + // Seed `balances[caller] = 17` and `balances[other] = 25`. + let inputs = [Value::from_str(&caller_address.to_string())?, Value::from_str("17u64")?]; + let tx = + vm.execute(&caller_private_key, ("vw_cross_multi_data.aleo", "seed"), inputs.iter(), None, 0, None, rng)?; + add_and_test_with_costs(&vm, &caller_private_key, &caller_address, Some(&[&inputs]), &[tx], rng); + let inputs = [Value::from_str(&other_address.to_string())?, Value::from_str("25u64")?]; + let tx = + vm.execute(&caller_private_key, ("vw_cross_multi_data.aleo", "seed"), inputs.iter(), None, 0, None, rng)?; + add_and_test_with_costs(&vm, &caller_private_key, &caller_address, Some(&[&inputs]), &[tx], rng); + + // Run `combine(caller, other)` — finalize calls `lookup` twice, sums (17 + 25 = 42), stores + // into `totals[caller]`. + let inputs = [Value::from_str(&caller_address.to_string())?, Value::from_str(&other_address.to_string())?]; + let tx = + vm.execute(&caller_private_key, ("vw_cross_multi_caller.aleo", "combine"), inputs.iter(), None, 0, None, rng)?; + add_and_test_with_costs(&vm, &caller_private_key, &caller_address, Some(&[&inputs]), &[tx], rng); + + #[cfg(feature = "history")] + { + let height = vm.block_store().current_block_height(); + let key = console::program::Plaintext::from(console::program::Literal::Address(caller_address)); + let value = vm + .finalize_store() + .get_historical_mapping_value( + console::program::ProgramID::from_str("vw_cross_multi_caller.aleo")?, + console::program::Identifier::from_str("totals")?, + key, + height, + )? + .expect("totals should be set"); + match &*value { + Value::Plaintext(console::program::Plaintext::Literal(console::program::Literal::U64(v), _)) => { + assert_eq!(**v, 42u64, "totals[caller] should equal balances[caller] + balances[other]"); + } + other => panic!("expected u64 plaintext, got {other:?}"), + } + } + + Ok(()) +} + +/// Guard-view lifecycle (zero-output view): a view that has no outputs serves as a precondition +/// check. Calling it from a finalize body with `call vw/require_zero r0;` (no `into`) is valid; +/// when the assertion in the view body holds, the caller's finalize continues; when it fails, +/// the entire transaction is finalize-rejected. This is the Aleo analogue of Solidity's +/// `function require_member(address) external view { require(...); }` pattern. +#[test] +fn test_finalize_call_zero_output_guard_view() -> Result<()> { + let rng = &mut TestRng::default(); + let caller_private_key = sample_genesis_private_key(rng); + let caller_address = Address::try_from(&caller_private_key)?; + + // `require_zero` is a guard view: it asserts that its input is `0u64` and returns nothing. + // The caller's finalize calls it, then writes a marker to `out[r0]` so that we can + // distinguish the success path (write happens) from the failure path (tx rejected, no write). + let program = Program::from_str( + r" + program vw_guard.aleo; + + mapping out: + key as address.public; + value as u64.public; + + function caller: + input r0 as address.public; + input r1 as u64.public; + async caller r0 r1 into r2; + output r2 as vw_guard.aleo/caller.future; + + finalize caller: + input r0 as address.public; + input r1 as u64.public; + call require_zero r1; + set 1u64 into out[r0]; + + view require_zero: + input r0 as u64.public; + assert.eq r0 0u64; + + constructor: + assert.eq true true; + ", + )?; + + let vm = sample_vm_at_height(CurrentNetwork::CONSENSUS_HEIGHT(ConsensusVersion::V15)?, rng); + + // Deploy succeeds — a zero-output view is now a valid program element. + let tx = vm.deploy(&caller_private_key, &program, None, 0, None, rng)?; + add_and_test_with_costs(&vm, &caller_private_key, &caller_address, None, &[tx], rng); + + // Happy path: pass `0u64`. The guard's `assert.eq` holds, the finalize body continues and + // writes `out[caller] = 1`. + let inputs = [Value::from_str(&caller_address.to_string())?, Value::from_str("0u64")?]; + let tx = vm.execute(&caller_private_key, ("vw_guard.aleo", "caller"), inputs.iter(), None, 0, None, rng)?; + add_and_test_with_costs(&vm, &caller_private_key, &caller_address, Some(&[&inputs]), &[tx], rng); + + #[cfg(feature = "history")] + { + let height = vm.block_store().current_block_height(); + let key = console::program::Plaintext::from(console::program::Literal::Address(caller_address)); + let value = vm + .finalize_store() + .get_historical_mapping_value( + console::program::ProgramID::from_str("vw_guard.aleo")?, + console::program::Identifier::from_str("out")?, + key.clone(), + height, + )? + .expect("out should be set after the guard passes"); + match &*value { + Value::Plaintext(console::program::Plaintext::Literal(console::program::Literal::U64(v), _)) => { + assert_eq!(**v, 1u64, "guard pass: caller's finalize should have written 1"); + } + other => panic!("expected u64 plaintext, got {other:?}"), + } + + // Failure path: pass `1u64`. The guard's `assert.eq` fails, the finalize is rejected, + // and `out[caller]` retains the value from the previous run (still `1u64`, not a new write). + let inputs = [Value::from_str(&caller_address.to_string())?, Value::from_str("1u64")?]; + let tx = vm.execute(&caller_private_key, ("vw_guard.aleo", "caller"), inputs.iter(), None, 0, None, rng)?; + let block = sample_next_block(&vm, &caller_private_key, &[tx], rng)?; + assert_eq!(block.transactions().num_accepted(), 0, "guard fail: tx must not be accepted"); + assert_eq!(block.transactions().num_rejected(), 1, "guard fail: tx must be finalize-rejected"); + assert_eq!(block.aborted_transaction_ids().len(), 0); + vm.add_next_block(&block)?; + + // out[caller] is still 1 (the rejected tx didn't write 1 again — but, more importantly, + // didn't apply any state change). We re-read to confirm the value is unchanged from the + // happy-path write above. + let height = vm.block_store().current_block_height(); + let value = vm + .finalize_store() + .get_historical_mapping_value( + console::program::ProgramID::from_str("vw_guard.aleo")?, + console::program::Identifier::from_str("out")?, + key, + height, + )? + .expect("out should still be set from the earlier successful run"); + match &*value { + Value::Plaintext(console::program::Plaintext::Literal(console::program::Literal::U64(v), _)) => { + assert_eq!(**v, 1u64, "rejected tx must not have mutated state"); + } + other => panic!("expected u64 plaintext, got {other:?}"), + } + } + + Ok(()) +} + +/// Negative: a finalize-call that binds destinations to a zero-output guard view must be +/// rejected at deploy. The `view.outputs().len() != self.destinations.len()` check in +/// `Call::output_types_for_view` should bail (0 outputs vs. 1 destination). +#[test] +fn test_finalize_call_zero_output_view_with_destinations_rejected_at_deploy() { + let rng = &mut TestRng::default(); + let caller_private_key = sample_genesis_private_key(rng); + + // The caller mistakenly binds `r1` for the guard view's (nonexistent) return value. + let program = Program::from_str( + r" + program vw_guard_bad.aleo; + + function caller: + input r0 as u64.public; + async caller r0 into r1; + output r1 as vw_guard_bad.aleo/caller.future; + + finalize caller: + input r0 as u64.public; + call require_zero r0 into r1; + assert.eq r1 r1; + + view require_zero: + input r0 as u64.public; + assert.eq r0 0u64; + + constructor: + assert.eq true true; + ", + ); + + let vm = sample_vm_at_height(CurrentNetwork::CONSENSUS_HEIGHT(ConsensusVersion::V15).unwrap(), rng); + if let Ok(program) = program { + let deploy = vm.deploy(&caller_private_key, &program, None, 0, None, rng); + assert!(deploy.is_err(), "deploy should reject binding destinations to a zero-output view"); + } +}