Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
fb67e21
feat(view): allow finalize bodies to call view functions
mohammadfawaz May 11, 2026
453a4b9
test(view): cover finalize-to-view call paths and V15 gating
mohammadfawaz May 11, 2026
5618e81
test(view): cover multiple-call + interleaved write read-write-read
mohammadfawaz May 11, 2026
586b68b
refactor(view): apply review fixes for finalize-to-view call
mohammadfawaz May 12, 2026
1e6645e
test(view): expand finalize-to-view coverage (negative paths + multi-…
mohammadfawaz May 12, 2026
a923761
docs(view): address review comments on finalize-to-view call
May 14, 2026
cde8cb9
refactor(view): split view-target handling out of Call::output_types
May 14, 2026
7fbce84
Merge remote-tracking branch 'origin/staging' into mohammadfawaz/fina…
May 14, 2026
ef0b187
refactor(view): tighten finalize-to-view call paths
May 14, 2026
0afb918
feat(view): allow zero-output views (guard pattern)
May 14, 2026
088684e
feat(view): allow empty bodies
May 14, 2026
b74ddbd
Revert "refactor(view): split view-target handling out of Call::outpu…
May 20, 2026
8f0ac10
refactor(view): route finalize-context view calls through Call::finalize
May 20, 2026
28a9dbe
Merge branch 'staging' into mohammadfawaz/finalize_calls_query
May 20, 2026
935efcf
refactor(view): take Option<&dyn FinalizeStoreTrait> in Instruction::…
May 21, 2026
56c0f46
Merge branch 'staging' into mohammadfawaz/finalize_calls_query
May 21, 2026
1ab99e5
Merge remote-tracking branch 'origin/staging' into mohammadfawaz/fina…
vicsn May 22, 2026
fae8a28
feat(view): bound finalize view-calls and check views on upgrade
May 22, 2026
d4c5700
Merge branch 'staging' into mohammadfawaz/finalize_calls_query
mohammadfawaz May 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions console/network/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
26 changes: 22 additions & 4 deletions synthesizer/process/src/cost.rs
Original file line number Diff line number Diff line change
Expand Up @@ -543,7 +543,26 @@ pub fn cost_per_command<N: Network>(
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::<N>::opcode())
}
Expand Down Expand Up @@ -1025,9 +1044,8 @@ fn view_cost_for_single_view<N: Network>(
consensus_fee_version: ConsensusFeeVersion,
) -> Result<u64> {
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() {
Expand Down
8 changes: 5 additions & 3 deletions synthesizer/process/src/finalize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -769,13 +769,15 @@ fn initialize_finalize_state<N: Network>(
// 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<N>` 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<N: Network>(
program_id: Option<(ProgramID<N>, u16)>,
resource: Option<Identifier<N>>,
store: &impl FinalizeStoreTrait<N>,
stack: &impl StackTrait<N>,
store: &dyn FinalizeStoreTrait<N>,
stack: &Stack<N>,
registers: &mut FinalizeRegisters<N>,
positions: &HashMap<Identifier<N>, usize>,
command: &Command<N>,
Expand Down
1 change: 0 additions & 1 deletion synthesizer/process/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
54 changes: 51 additions & 3 deletions synthesizer/process/src/stack/finalize_types/initialize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -821,8 +821,13 @@ impl<N: Network> FinalizeTypes<N> {
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
Expand Down Expand Up @@ -873,7 +878,50 @@ impl<N: Network> FinalizeTypes<N> {
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" => {
Expand Down
16 changes: 16 additions & 0 deletions synthesizer/process/src/stack/helpers/check_upgrade.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ impl<N: Network> Stack<N> {
/// | closure | ❌ | ❌ | ✅ |
/// | function | ❌ | ✅ (logic) | ✅ |
/// | finalize | ❌ | ✅ (logic) | ✅ |
/// | view | ❌ | ✅ (logic) | ✅ |
/// |-------------------|--------|--------------|-------|
///
/// There is one important caveat in that output register indices **MUST** remain the same.
Expand Down Expand Up @@ -128,6 +129,21 @@ impl<N: Network> Stack<N> {
}
}
}
// 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(())
}
}
1 change: 1 addition & 0 deletions synthesizer/process/src/stack/helpers/initialize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ impl<N: Network> Stack<N> {
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(),
Expand Down
10 changes: 10 additions & 0 deletions synthesizer/process/src/stack/helpers/stack_trait.rs
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,16 @@ impl<N: Network> StackTrait<N> for Stack<N> {
// 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<N>,
view_name: &Identifier<N>,
inputs: Vec<Value<N>>,
) -> Result<Vec<Value<N>>> {
crate::view::evaluate_view_inner(state, store, self, view_name, inputs)
}
}

impl<N: Network> Stack<N> {
Expand Down
31 changes: 25 additions & 6 deletions synthesizer/process/src/stack/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ use snarkvm_synthesizer_error::*;
use snarkvm_synthesizer_program::{
CallOperator,
Closure,
FinalizeGlobalState,
FinalizeStoreTrait,
Function,
Instruction,
Operand,
Expand Down Expand Up @@ -257,6 +259,8 @@ pub struct Stack<N: Network> {
register_types: Arc<RwLock<IndexMap<Identifier<N>, RegisterTypes<N>>>>,
/// The mapping of finalize names to their register types.
finalize_types: Arc<RwLock<IndexMap<Identifier<N>, FinalizeTypes<N>>>>,
/// The mapping of view function names to their register types.
view_types: Arc<RwLock<IndexMap<Identifier<N>, FinalizeTypes<N>>>>,
/// The universal SRS.
universal_srs: UniversalSRS<N>,
/// The mapping of function name or record name to proving key.
Expand Down Expand Up @@ -319,15 +323,17 @@ impl<N: Network> Stack<N> {

/// 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<N>) -> 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() {
Expand Down Expand Up @@ -379,17 +385,21 @@ impl<N: Network> Stack<N> {
}
}

// 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() {
Expand Down Expand Up @@ -430,6 +440,15 @@ impl<N: Network> Stack<N> {
}
}

/// Returns the register types for the given view function name.
#[inline]
pub fn get_view_types(&self, name: &Identifier<N>) -> Result<FinalizeTypes<N>> {
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<N>) -> Result<()> {
// If the program is 'credits.aleo' and it does not exist yet, load the proving key directly.
Expand Down
7 changes: 7 additions & 0 deletions synthesizer/process/src/stack/register_types/initialize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,13 @@ impl<N: Network> RegisterTypes<N> {
);
}
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) => {
Expand Down
37 changes: 27 additions & 10 deletions synthesizer/process/src/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -27,6 +32,7 @@ use snarkvm_synthesizer_program::{
StackTrait,
};

#[cfg(feature = "history")]
impl<N: Network> Process<N> {
/// 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
Expand Down Expand Up @@ -72,6 +78,7 @@ impl<N: Network> Process<N> {
/// `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<N: Network, P: FinalizeStorage<N>>(
state: FinalizeGlobalState,
store: &FinalizeStore<N, P>,
Expand All @@ -88,11 +95,13 @@ pub fn evaluate_view_at_height<N: Network, P: FinalizeStorage<N>>(
/// 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<N>> {
store: &'a FinalizeStore<N, P>,
height: u32,
}

#[cfg(feature = "history")]
impl<N: Network, P: FinalizeStorage<N>> FinalizeStoreTrait<N> for HistoricFinalizeStore<'_, N, P> {
fn contains_mapping_confirmed(
&self,
Expand Down Expand Up @@ -167,18 +176,18 @@ impl<N: Network, P: FinalizeStorage<N>> FinalizeStoreTrait<N> 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<N: Network>(
pub(crate) fn evaluate_view_inner<N: Network>(
state: FinalizeGlobalState,
store: &impl FinalizeStoreTrait<N>,
store: &dyn FinalizeStoreTrait<N>,
stack: &Stack<N>,
view_name: &Identifier<N>,
inputs: Vec<Value<N>>,
) -> Result<Vec<Value<N>>> {
// 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
Expand Down Expand Up @@ -250,9 +259,14 @@ fn evaluate_view_inner<N: Network>(
)?;
}
// 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());
Expand All @@ -262,7 +276,10 @@ fn evaluate_view_inner<N: Network>(
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;
Expand Down
Loading