Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

iip: 11
title: Core Move View Functions
description: Move view functions - definition and specification at the language level
author: Lorenzo Benetollo (@lollobene) , Mirko Zichichi (@miker83z) 
discussions-to: https://github.com/iotaledger/IIPs/discussions/42
status: Draft
type: Standards Track
layer (*only required for Standards Track): Core
created: 2026-04-27
requires (*optional): IIP-0010
replaces (*optional): None

Abstract

This proposal introduces view functions in Move, a class of functions that are guaranteed not to modify the persistent global storage. A view function is explicitly annotated using #[view] and its properties are statically enforced by the compiler and by the IOTA bytecode verifier.

These functions are suitable for reading from the ledger state and/or executing code in Move with no effect on the state. This proposal, then, aims to improve developer ergonomics, enable safer APIs, and allow optimized execution paths for read-only on-chain queries.

Motivation

Smart contract developers frequently need to query on-chain storage without modifying it. Today, this is harder than it should be:

  • No distinction between read-only and state-modifying functions. If your dApp depends on a third-party Move package, you have no way to know whether calling a function will mutate state without manually auditing its code. This increases the risk of unintended storage mutations. See Nemo Protocol security incident
  • No clear or lightweight way to read on-chain data. If you deploy a Move package with custom object types, reading their fields requires building a custom indexer, a heavyweight solution for what should be a simple query.

Introducing view functions provides:

  • A clear and statically enforced guarantee of read-only behavior
  • Improved developer experience and reduced risk of unintended state mutations
  • Opportunities for optimized execution and seamless integration with RPC interfaces and query-oriented tooling, enabling off-chain evaluation without gas costs or signature requirements

Background

In Move on IOTA, the original Move Resource model has evolved into an object-centric model, in which structs with the key ability and a required UID (as a first field) represent on-chain objects.

As a consequence, functions must explicitly declare as parameters all the objects they operate on, either by value or by reference, while the virtual machine is responsible for loading and deserializing these objects at execution time.

This eliminates implicit global storage access and requires all storage interactions to occur via explicitly passed objects.

Additionally, in Move on IOTA, object creation is not exposed as a language-level primitive.

Instead, it is realized through framework-provided functions (e.g., object::new) combined with transfer operations.

This design implies that object creation is not performed by any language primitive but is delegated to the platform framework. More generally, as we will see in the next section, the storage can be modified either by language primitives or by framework functions.

What can alter the storage?

As previously mentioned, in Move on IOTA, objects are the only entities stored in persistent storage. Consequently, all changes to the global storage occur through operations on objects.

These operations include:

  • Creating an object, which adds a new object to the storage

    public struct Object has key, store { id: UID, value: u64 }
    public struct Wrapper has key { id: UID, o: Object }
    
    public fun create(addr: address, ctx: &mut TxContext) {
        let o = Object { id: object::new(ctx), val: 44 };
        transfer::transfer(o, addr);
    }
    
  • Modifying an object’s fields, which updates its internal state

    public fun edit(o:  &mut  Object, new_val:  u64) {
        o.val = new_val;
    }
    
  • Deleting an object, which removes it from storage

    public fun delete(o:  Object) {
        let  Object  { id, val }  = o;
        object::delete(id);
    }
    
  • Transferring an object, which changes its ownership

    public fun transfer(o:  Object, recipient: address) {
        transfer::public_transfer(o, recipient)
    }
    
  • Sharing an object, which changes its accessibility model

    public fun share(value:  u64, ctx:  &mut  TxContext) {
        transfer::share_object(Object  { id:  object::new(ctx), value })
    }
    
  • Wrapping an object

    public fun wrap(o:  Object, ctx:  &mut  TxContext) {
        transfer::transfer(Wrapper  { id:  object::new(ctx), o }, ctx.sender());
    }
    
  • Adding or removing dynamic fields, which modify associated dynamic fields

    public fun add_dynamic_field(self:  &mut  Object, value:  bool) {
        dynamic_field::add(&mut  self.id,  b"dynamic_field", value);
    }
    

By-value semantics

In this proposal, an argument or return value, whether an object or a native type, is considered to be passed by value when ownership is transferred, rather than when access is provided through a reference.

In Move, passing an argument by value consumes it: ownership moves to the callee, and the original binding can no longer be used. References (&T and &mut T) instead provide access to a value without transferring ownership.

This distinction matters for view functions because accepting or returning an IOTA object by value would move ownership of a persistent object, which can enable ownership changes, wrapping, deletion, or other state-modifying behavior. For this reason, view functions cannot accept or return IOTA objects by value.

Specification

Definition

Based on the premise that global storage is solely constituted by objects in IOTA, then:

A view function is a Move function that does not mutate any IOTA object and that returns at least one value. Returned values MUST NOT be IOTA objects, or values that could contain IOTA objects, by value. Returned values MUST NOT contain mutable references.

View functions MAY return immutable references (&T). Such references remain subject to the normal Move borrow checker rules, which determine whether a returned reference is well formed and whether it can outlive its source.

Rules

A compliant view function is declared using the #[view] attribute, which enforces static validation by the compiler and by IOTA bytecode verifier:

  • It MUST NOT create, update, or delete IOTA objects
  • It MUST return at least one value. Returned values MUST NOT be IOTA objects, or values that could contain IOTA objects, by value.
  • It MUST NOT return mutable references
  • It MAY return immutable references
  • It MAY read from the global storage through a reference to an object
  • It MUST NOT have mutable references as parameters
  • It MAY perform pure computation

Annotation

A view function is declared as:

#[view]
public fun foo(...): T {
    ...
}

The #[view] attribute defines a compiler- and verifier-enforced invariant.

Nested Function Calls

A function annotated with #[view] MAY call other functions, including private and native ones.

In IOTA Move, access to global storage is mediated through objects. As a result, modifications to the global storage require access to objects by value or through mutable references.

Given the constraints imposed on view functions, it follows that a view function cannot pass the necessary inputs to call functions that perform state-modifying operations.

In particular, any function that requires:

  • an object passed by value
  • a mutable reference
  • access to &mut TxContext

cannot be invoked from a view function, as such values cannot be constructed or obtained within the constraints of a view execution.

Therefore, even if a view function calls other functions, those calls are effectively restricted to functions whose parameter and return types are compatible with view constraints. This ensures that state-modifying operations cannot be reached, even transitively.

View-specific validation is based on function signatures. It does not add a separate body-level provenance analysis for immutable reference returns; normal Move borrow checking continues to enforce reference safety. In practice, an immutable reference returned by a view function must be derived from a valid reference source, such as an immutable reference parameter, a field borrowed from such a parameter, or an immutable reference obtained by reading a dynamic object field.

Invocation of View Functions

Any other function MAY invoke a function annotated with #[view], so the constraints imposed on view functions MUST hold independently of the caller context.

Although a view function may not receive objects by value or as mutable references, allowing mutable references to non-object types would still enable indirect state modifications. For example, a mutable reference to an object’s field could be passed to a view function, allowing modification of the object’s field through that reference.

To prevent such indirect mutations, view functions MUST NOT accept mutable references (&mut T) as parameters, regardless of whether T is an object type.

For example:

public fun edit(mut o: Object, new_val: u64): Object {
    edit_u64_by_ref(&mut o.val, new_val);
    o
}

// NON view function because it can mutate an object field
public fun edit_u64_by_ref(val: &mut u64, new_val: u64): u64 {
    let old_val = *val;
    *val = new_val;
    old_val
}

Function Signature Constraints

A function annotated with #[view] MUST satisfy:

  • Visibility

    • MUST be public or public entry
  • Return values

    • MUST return at least one value
    • MUST NOT return IOTA objects, or values that could contain IOTA objects, by value
    • MUST NOT return mutable references
    • MAY return immutable references (&T)
  • Parameters

    • MUST NOT include IOTA objects, or values that could contain IOTA objects, passed by value
    • MAY include immutable references (&T)
    • MAY include primitive types or user-defined types with at least one of the Copy or Drop abilities (neither IOTA objects nor types that can contain objects by value)
    • References
      • MUST NOT use mutable references (&mut T) to any type, including TxContext
      • MAY use immutable references
  • Type Parameters

    • Type parameters used by value in parameters or return values MUST have at least one of the Copy or Drop abilities
    • Type parameters used only behind immutable references, or not used in the function signature, MAY be unconstrained

Native Functions

Native functions MAY be annotated with #[view].

Compiler Behavior

Errors

The compiler MUST reject any source function annotated with #[view] that violates the specification.

The publish-time bytecode verifier MUST also reject package metadata that marks a function as a view function when the compiled function signature violates these constraints. This prevents packages from bypassing source-level checks by publishing bytecode or MVIR directly.

Example:

This function does not satisfy view constraints, and it can not be marked as #[view].

Warnings

The compiler MAY emit a warning when a function:

  • satisfies all constraints of a view function
  • is not annotated with #[view]

Example:

This function satisfies view constraints and could be marked as #[view].

Execution Semantics

View functions MAY be executed in a read-only execution mode:

  • Without submitting a transaction
  • Without requiring a signature
  • Without consuming gas (implementation-dependent)

Execution MUST be deterministic:

  • Given the same state, the function MUST return the same result

Examples

Valid Examples

module view_functions::valid {
    public struct Object has key {
        id: iota::object::UID,
    }

    public struct Wrapped has copy, drop, store {
        value: u64,
    }

    public struct Wrapped2 has copy, drop, store {
        value: u64,
    }

    public struct GenericObject2<T: store, U: store> has key {
        id: iota::object::UID,
        inner: T,
        other: U,
    }

    public struct GenericObject<T: store> has key, store {
        id: iota::object::UID,
        inner: T,
    }

    public struct NonObject has copy, drop, store {
        value: u64,
    }

    public struct NonObjectTemplated<T: copy + drop + store> has copy, drop, store {
        inner: T,
    }

    public struct Receiving<phantom T: key> has copy, drop, store {
        id: iota::object::ID,
    }

    public struct DynamicField has copy, drop, store {
        value: u64,
    }

    #[view]
    public entry fun entry_view(a: u64): u64 {
        a
    }

    #[view]
    public fun object_immutable_ref(object: &Object): u64 {
        let _ = object;
        0
    }

    #[view]
    public fun primitive_by_value(object: &Object, val: u8): u64 {
        let _ = object;
        val as u64
    }

    #[view]
    public fun multiple_generic_object_immutable_ref(generic_object: &GenericObject2<Wrapped, Wrapped2>): u64 {
        generic_object.inner.value + generic_object.other.value
    }

    #[view]
    public fun wrapped_by_value(wrapped: Wrapped) : bool {
        wrapped.value > 44
    }

    #[view]
    public fun generic_object_immutable_ref(generic_object: &GenericObject<Wrapped>): u64 {
        generic_object.inner.value
    }

    #[view]
    public fun template_immutable_ref<T: store>(generic_object: &GenericObject<T>): u64 {
        let _ = generic_object;
        0
    }

    #[view]
    public fun template_key_store_immutable_ref<T: key + store>(generic_object: &GenericObject<T>): u64 {
        let _ = generic_object;
        0
    }

    #[view]
    public fun template_copy_drop_store_immutable_ref<T: copy + drop + store>(
        generic_object: &GenericObject<T>,
    ): u64 {
        let _ = generic_object;
        0
    }

    #[view]
    public fun non_object_by_value(value: NonObject): u64 {
        value.value
    }

    #[view]
    public fun templated_non_object_by_value<T: copy + drop + store>(
        value: NonObjectTemplated<T>,
    ): u64 {
        let _ = value;
        0
    }

    #[view]
    public fun option_primitive_by_value(value: Option<u64>): u64 {
        if (value.is_some()) {
            value.destroy_some()
        } else {
            0
        }
    }

    #[view]
    public fun option_non_object_by_value(value: Option<NonObject>): u64 {
        if (value.is_some()) {
            value.destroy_some().value
        } else {
            0
        }
    }

    #[view]
    public fun option_generic_object_immutable_ref(value: &Option<GenericObject<Wrapped>>): u64 {
        let _ = value;
        0
    }

    #[view]
    public fun vector_primitive_by_value(value: vector<u64>): u64 {
        value.length()
    }

    #[view]
    public fun vector_non_object_by_value(value: vector<NonObject>): u64 {
        value.length()
    }

    #[view]
    public fun vector_generic_object_immutable_ref(value: &vector<GenericObject<Wrapped>>): u64 {
        value.length()
    }

    #[view]
    public fun receiving_immutable_ref(receiving: &Receiving<GenericObject<Wrapped>>): u64 {
        let _ = receiving;
        0
    }

    #[view]
    public fun copy_type_param<T: copy>(value: T): T {
        value
    }

    #[view]
    public fun drop_type_param<T: drop>(value: T): u64 {
        let _ = value;
        0
    }

    #[view]
    public fun copy_store_type_param<T: copy + store>(value: T): T {
        value
    }

    #[view]
    public fun primitive_tuple_return(a: u64, b: bool): (u64, bool) {
        (a, b)
    }

    #[view]
    public fun returns_generic_obj_reference(
        input: &GenericObject<Wrapped>,
    ): &GenericObject<Wrapped> {
        input
    }

    #[view]
    public fun returns_u64_reference(input: &u64): &u64 {
        input
    }

    #[view]
    public fun returns_tuple_with_reference(input: &u64): (&u64, u64) {
        (input, 0)
    }

    #[view]
    public fun returns_dynamic_field_reference(object: &Object, name: u64): &DynamicField {
        iota::dynamic_field::borrow<u64, DynamicField>(&object.id, name)
    }

    #[view]
    public native fun native_view(v: u64): u64;

    #[view]
    public native fun native_view_no_param(): bool;

    #[view]
    public native fun native_type_param<T: key>(): u64;

    #[view]
    public fun unused_unconstrained_type_param<T>(): u64 {
        0
    }

    #[view]
    public fun unconstrained_type_param_by_ref<T>(x: &T): u64 {
        let _ = x;
        0
    }

    #[view]
    public fun unconstrained_type_param_vector_by_ref<T>(x: &vector<T>): u64 {
        let _ = x;
        0
    }

    #[view]
    public fun unconstrained_type_param_option_by_ref<T>(x: &Option<T>): u64 {
        let _ = x;
        0
    }
}

Non Valid Examples

module view_functions::invalid {
    se std::ascii::{String, char};

    public struct Object has key {
        id: iota::object::UID,
    }

    public struct Wrapped has copy, drop, store {
        value: u64,
    }

    public struct StoreOnly has store {
        value: u64,
    }

    public struct GenericObject<T: store> has key, store {
        id: iota::object::UID,
        inner: T,
    }

    public struct GenericObject2<T: store, U: store> has key {
        id: iota::object::UID,
        inner: T,
        other: U,
    }

    public struct Wrapper<T> has key {
        id: iota::object::UID,
        wrapped: vector<T>,
    }

    public struct NonObject has copy, drop, store {
        value: u64,
    }

    public struct NonObjectTemplated<T: copy + drop + store> has copy, drop, store {
        inner: T,
    }

    public struct Receiving<phantom T: key> has copy, drop, store {
        id: iota::object::ID,
    }

    #[view]
    fun private_view(): u64 {
        0
    }

    #[view]
    public fun no_return() {
        abort 0
    }

    #[view]
    public fun object_by_value(_object: Object): u64 {
        abort 0
    }

    #[view]
    public fun object_mutable_ref(_object_ref: &mut Object): u64 {
        abort 0
    }

    #[view]
    public fun concrete_multiple_object_by_value(
        _generic_object2: GenericObject2<Wrapped, Wrapped>,
    ): u64 {
        abort 0
    }

    #[view]
    public fun generic_object_by_value(_generic_object: GenericObject<Wrapped>): u64 {
        abort 0
    }

    #[view]
    public fun generic_object_mutable_ref(_object_ref: &mut GenericObject<Wrapped>): u64 {
        abort 0
    }

    #[view]
    public fun template_by_value<T: store>(_generic_object: GenericObject<T>): u64 {
        abort 0
    }

    #[view]
    public fun template_key_store_by_value<T: key + store>(
        _generic_object: GenericObject<T>,
        _wrapper: &Wrapper<T>,
    ): u64 {
        abort 0
    }

    #[view]
    public fun template_copy_drop_store_by_value<T: copy + drop + store>(
        _generic_object: GenericObject<T>,
    ): u64 {
        abort 0
    }

    #[view]
    public fun mutable_primitive_param(mut value: u64): u64 {
        value = value + 1;
        value
    }

    #[view]
    public fun mutable_non_object_param(mut value: NonObject): u64 {
        value.value = value.value + 1;
        value.value
    }

    #[view]
    public fun update_string_by_value(mut name: String): String {
        name.push_char(char(43));
        name
    }

    #[view]
    public fun direct_key_store_type_param_by_value<T: key + store>(_generic_object: T): u64 {
        abort 0
    }

    #[view]
    public fun unconstrained_type_param_by_value<T>(_value: T): u64 {
        abort 0
    }

    #[view]
    public fun store_only_by_value(value: StoreOnly): u64 {
        let StoreOnly { value } = value;
        value
    }

    #[view]
    public fun store_only_type_param_by_value<T: store>(_value: T): u64 {
        abort 0
    }

    #[view]
    public fun option_object_by_value(_value: Option<GenericObject<Wrapped>>): u64 {
        abort 0
    }

    #[view]
    public fun option_template_object_by_value<T: key + store>(_value: Option<T>): u64 {
        abort 0
    }

    #[view]
    public fun option_vector_object_by_value(_value: Option<vector<GenericObject<Wrapped>>>): u64 {
        abort 0
    }

    #[view]
    public fun vector_option_object_by_value(_value: vector<Option<GenericObject<Wrapped>>>): u64 {
        abort 0
    }

    #[view]
    public fun option_primitive_mutable_ref(_value: &mut Option<u64>): u64 {
        abort 0
    }

    #[view]
    public fun option_non_object_mutable_ref(_value: &mut Option<NonObject>): u64 {
        abort 0
    }

    #[view]
    public fun option_object_mutable_ref(_value: &mut Option<GenericObject<Wrapped>>): u64 {
        abort 0
    }

    #[view]
    public fun vector_object_by_value(_value: vector<GenericObject<Wrapped>>): u64 {
        abort 0
    }

    #[view]
    public fun vector_template_object_by_value<T: key + store>(_value: vector<T>): u64 {
        abort 0
    }

    #[view]
    public fun vector_primitive_mutable_ref(_value: &mut vector<u64>): u64 {
        abort 0
    }

    #[view]
    public fun vector_non_object_mutable_ref(_value: &mut vector<NonObject>): u64 {
        abort 0
    }

    #[view]
    public fun vector_object_mutable_ref(_value: &mut vector<GenericObject<Wrapped>>): u64 {
        abort 0
    }

    #[view]
    public fun receiving_by_value(_receiving: Receiving<GenericObject<Wrapped>>): u64 {
        abort 0
    }

    #[view]
    public fun receiving_mutable_ref(_receiving: &mut Receiving<GenericObject<Wrapped>>): u64 {
        abort 0
    }

    #[view]
    public fun tx_context_mutable_ref(_ctx: &mut iota::tx_context::TxContext): u64 {
        abort 0
    }

    #[view]
    public fun returns_object(): Object {
        abort 0
    }

    #[view]
    public fun returns_object_vector(): vector<GenericObject<Wrapped>> {
        abort 0
    }

    #[view]
    public fun returns_option_object(): Option<GenericObject<Wrapped>> {
        abort 0
    }

    #[view]
    public fun returns_store_only(): StoreOnly {
        abort 0
    }

    #[view]
    public fun returns_option_store_only(): Option<StoreOnly> {
        abort 0
    }

    #[view]
    public fun returns_key_store_type_param<T: key + store>(): T {
        abort 0
    }

    #[view]
    public fun returns_store_only_type_param<T: store>(): T {
        abort 0
    }

    #[view]
    public fun returns_tuple_with_object(): (u64, GenericObject<Wrapped>) {
        abort 0
    }

    #[view]
    public native fun returns_mut_reference(input: &u64): &mut u64;

    #[view]
    public native fun store_only_type_param<T: store>(x: T): u64;

    #[view]
    public native fun native_mut_ref(x: &mut u64): u64;
}

Rationale

This proposal introduces an explicit abstraction for read-only execution, similar to “view” or “pure” functions in other smart contract platforms.

Key design decisions:

  • Explicit annotation (#[view])
    • Avoids ambiguity and enables static guarantees
  • Static enforcement
    • Ensures safety at compile time
  • Recursive restriction
    • Guarantees no indirect storage mutation
  • Separation of constraints
    • Improves clarity and maintainability

Backwards Compatibility

This proposal is backwards-compatible:

  • Existing code is unaffected
  • The #[view] attribute is optional

However:

  • Adding #[view] introduces stricter constraints that are enforced at compile time
  • Removing #[view] may affect client expectations, particularly for off-chain execution and API usage

This proposal builds on IIP-0010 by treating #[view] as package metadata. View functions are included in PackageMetadata, similarly to how authenticator functions are currently exposed. This allows clients and tooling to reliably discover view functions without requiring additional static analysis.

Such metadata exposure does not affect program semantics but improves interoperability with RPC layers and developer tooling.

This proposal is also compatible with the RPC interface proposed in IIP-0005. IIP-0005 defines an off-chain interface for invoking Move view functions and explicitly allows that interface to be limited to a future explicit on-chain read API. Under this proposal, that explicit API is the set of functions annotated with #[view] and accepted by the compiler, so implementations of IIP-0005 SHOULD use this proposal’s validation rules and PackageMetadata exposure when determining which functions are callable as view functions.

Copyright and related rights waived via CC0.