Skip to content

Commit 2207f4b

Browse files
lollobenemiker83z
andauthored
feat(move): Move view functions (verifier implementation) - package upgradability (#11449)
# Description of change View Functions metadata upgradability ## Links to any relevant issues fixes #11414 --------- Co-authored-by: Mirko Zichichi <mirko.zichichi@iota.org>
1 parent d067e78 commit 2207f4b

7 files changed

Lines changed: 213 additions & 11 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
[package]
2+
name = "package_upgrade_base"
3+
version = "0.0.1"
4+
edition = "2024"
5+
6+
[addresses]
7+
base_addr = "0x0"
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// Copyright (c) Mysten Labs, Inc.
2+
// Modifications Copyright (c) 2026 IOTA Stiftung
3+
// SPDX-License-Identifier: Apache-2.0
4+
5+
module base_addr::base {
6+
#[view]
7+
public fun return_0(): u64 { 0 }
8+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
[package]
2+
name = "package_upgrade_base"
3+
version = "0.0.1"
4+
edition = "2024"
5+
6+
[addresses]
7+
base_addr = "0x0"
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// Copyright (c) Mysten Labs, Inc.
2+
// Modifications Copyright (c) 2026 IOTA Stiftung
3+
// SPDX-License-Identifier: Apache-2.0
4+
5+
module base_addr::base {
6+
public fun return_0(): u64 { 0 }
7+
}

crates/iota-core/src/unit_tests/move_package_upgrade_tests.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,23 @@ async fn test_upgrade_incompatible() {
459459
)
460460
}
461461

462+
#[tokio::test]
463+
async fn test_upgrade_cannot_remove_view_attribute() {
464+
let mut runner = UpgradeStateRunner::new("move_upgrade/view_base").await;
465+
466+
let (digest, modules) = build_upgrade_test_modules("view_removed");
467+
let effects = runner
468+
.upgrade(UpgradePolicy::COMPATIBLE, digest, modules, vec![])
469+
.await;
470+
471+
assert_eq!(
472+
effects.into_status().unwrap_err().0,
473+
ExecutionFailureStatus::PackageUpgradeError {
474+
upgrade_error: PackageUpgradeError::IncompatibleUpgrade,
475+
},
476+
)
477+
}
478+
462479
#[tokio::test]
463480
async fn test_upgrade_package_incorrect_digest() {
464481
let mut runner = UpgradeStateRunner::new("move_upgrade/base").await;

crates/iota-types/src/move_package.rs

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,9 @@ use derive_more::Display;
4242
use fastcrypto::hash::HashFunction;
4343
use iota_protocol_config::ProtocolConfig;
4444
use move_binary_format::{
45-
binary_config::BinaryConfig, file_format::CompiledModule, file_format_common::VERSION_6,
45+
binary_config::BinaryConfig,
46+
file_format::CompiledModule,
47+
file_format_common::{IOTA_METADATA_KEY, VERSION_6},
4648
normalized,
4749
};
4850
use move_core_types::{
@@ -755,6 +757,43 @@ where
755757
Ok(normalized_modules)
756758
}
757759

760+
/// If `include_code` is set to `false`, the normalized module will skip
761+
/// function bodies but still include the signatures.
762+
///
763+
/// The returned metadata is the IOTA-specific runtime metadata attached to the
764+
/// module, or the default empty metadata when the module has no IOTA metadata.
765+
pub fn normalize_modules_with_metadata<
766+
'a,
767+
S: Hash + Eq + Clone + ToString,
768+
Pool: normalized::StringPool<String = S>,
769+
I,
770+
>(
771+
pool: &mut Pool,
772+
modules: I,
773+
binary_config: &BinaryConfig,
774+
include_code: bool,
775+
) -> IotaResult<BTreeMap<String, (normalized::Module<S>, RuntimeModuleMetadata)>>
776+
where
777+
I: Iterator<Item = &'a Vec<u8>>,
778+
{
779+
let mut normalized_modules = BTreeMap::new();
780+
for bytecode in modules {
781+
let module =
782+
CompiledModule::deserialize_with_config(bytecode, binary_config).map_err(|error| {
783+
IotaError::ModuleDeserializationFailure {
784+
error: error.to_string(),
785+
}
786+
})?;
787+
let metadata = runtime_module_metadata(&module)?;
788+
let normalized_module = normalized::Module::new(pool, &module, include_code);
789+
normalized_modules.insert(
790+
normalized_module.name().to_string(),
791+
(normalized_module, metadata),
792+
);
793+
}
794+
Ok(normalized_modules)
795+
}
796+
758797
/// If `include_code` is set to `false`, the normalized module will skip
759798
/// function bodies but still include the signatures.
760799
pub fn normalize_deserialized_modules<
@@ -778,6 +817,54 @@ where
778817
normalized_modules
779818
}
780819

820+
/// If `include_code` is set to `false`, the normalized module will skip
821+
/// function bodies but still include the signatures.
822+
///
823+
/// The returned metadata is the IOTA-specific runtime metadata attached to the
824+
/// module, or the default empty metadata when the module has no IOTA metadata.
825+
pub fn normalize_deserialized_modules_with_metadata<
826+
'a,
827+
S: Hash + Eq + Clone + ToString,
828+
Pool: normalized::StringPool<String = S>,
829+
I,
830+
>(
831+
pool: &mut Pool,
832+
modules: I,
833+
include_code: bool,
834+
) -> IotaResult<BTreeMap<String, (normalized::Module<S>, RuntimeModuleMetadata)>>
835+
where
836+
I: Iterator<Item = &'a CompiledModule>,
837+
{
838+
let mut normalized_modules = BTreeMap::new();
839+
for module in modules {
840+
let metadata = runtime_module_metadata(module)?;
841+
let normalized_module = normalized::Module::new(pool, module, include_code);
842+
normalized_modules.insert(
843+
normalized_module.name().to_string(),
844+
(normalized_module, metadata),
845+
);
846+
}
847+
Ok(normalized_modules)
848+
}
849+
850+
fn runtime_module_metadata(module: &CompiledModule) -> IotaResult<RuntimeModuleMetadata> {
851+
let Some(metadata) = module
852+
.metadata
853+
.iter()
854+
.find(|metadata| metadata.key == IOTA_METADATA_KEY)
855+
else {
856+
return Ok(RuntimeModuleMetadata::default());
857+
};
858+
859+
let metadata_wrapper: RuntimeModuleMetadataWrapper =
860+
bcs::from_bytes(&metadata.value).map_err(|error| {
861+
IotaError::RuntimeModuleMetadataDeserialization {
862+
error: error.to_string(),
863+
}
864+
})?;
865+
RuntimeModuleMetadata::try_from(metadata_wrapper)
866+
}
867+
781868
fn build_linkage_table<'p>(
782869
mut immediate_dependencies: BTreeSet<ObjectID>,
783870
transitive_dependencies: impl IntoIterator<Item = &'p MovePackage>,

iota-execution/latest/iota-adapter/src/programmable_transactions/execution.rs

Lines changed: 79 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,15 @@ mod checked {
2424
TxContext, TxContextKind,
2525
},
2626
coin::Coin,
27-
error::{ExecutionError, ExecutionErrorKind, command_argument_error},
27+
error::{ExecutionError, ExecutionErrorKind, IotaError, command_argument_error},
2828
execution_config_utils::to_binary_config,
2929
execution_status::{CommandArgumentError, PackageUpgradeError},
3030
id::RESOLVED_IOTA_ID,
3131
metrics::LimitsMetrics,
3232
move_package::{
3333
IotaAttribute, MovePackage, PackageMetadata, RuntimeModuleMetadata,
3434
RuntimeModuleMetadataWrapper, UpgradeCap, UpgradePolicy, UpgradeReceipt, UpgradeTicket,
35-
normalize_deserialized_modules,
35+
normalize_deserialized_modules_with_metadata, normalize_modules_with_metadata,
3636
},
3737
object::OBJECT_START_VERSION,
3838
storage::{PackageObject, get_package_objects},
@@ -769,10 +769,24 @@ mod checked {
769769

770770
let pool = &mut normalized::RcPool::new();
771771
let binary_config = to_binary_config(context.protocol_config);
772-
let Ok(current_normalized) =
773-
existing_package.normalize(pool, &binary_config, /* include code */ true)
774-
else {
775-
invariant_violation!("Tried to normalize modules in existing package but failed")
772+
let current_normalized = match normalize_modules_with_metadata(
773+
pool,
774+
existing_package.serialized_module_map().values(),
775+
&binary_config,
776+
true, // include code
777+
) {
778+
Ok(modules) => modules,
779+
Err(IotaError::ModuleDeserializationFailure { .. }) => {
780+
invariant_violation!("Tried to normalize modules in existing package but failed")
781+
}
782+
Err(e) => {
783+
return Err(ExecutionError::new_with_source(
784+
ExecutionErrorKind::PackageUpgradeError {
785+
upgrade_error: PackageUpgradeError::IncompatibleUpgrade,
786+
},
787+
e,
788+
));
789+
}
776790
};
777791

778792
let existing_modules_len = current_normalized.len();
@@ -792,13 +806,22 @@ mod checked {
792806
),
793807
));
794808
}
795-
let mut new_normalized = normalize_deserialized_modules(
809+
810+
let mut new_normalized = normalize_deserialized_modules_with_metadata(
796811
pool,
797812
upgrading_modules.iter(),
798813
true, // include code
799-
);
800-
for (name, cur_module) in current_normalized {
801-
let Some(new_module) = new_normalized.remove(&name) else {
814+
)
815+
.map_err(|e| {
816+
ExecutionError::new_with_source(
817+
ExecutionErrorKind::PackageUpgradeError {
818+
upgrade_error: PackageUpgradeError::IncompatibleUpgrade,
819+
},
820+
e,
821+
)
822+
})?;
823+
for (name, (cur_module, cur_metadata)) in current_normalized {
824+
let Some((new_module, new_metadata)) = new_normalized.remove(&name) else {
802825
return Err(ExecutionError::new_with_source(
803826
ExecutionErrorKind::PackageUpgradeError {
804827
upgrade_error: PackageUpgradeError::IncompatibleUpgrade,
@@ -807,6 +830,7 @@ mod checked {
807830
));
808831
};
809832

833+
check_view_function_compatibility(&name, &cur_metadata, &new_metadata)?;
810834
check_module_compatibility(&policy, &cur_module, &new_module)?;
811835
}
812836

@@ -846,6 +870,51 @@ mod checked {
846870
})
847871
}
848872

873+
/// Verifies that any function marked `#[view]` in the current package
874+
/// remains marked `#[view]` in the upgraded package. Removing the attribute
875+
/// would preserve the Move bytecode signature but break clients that rely
876+
/// on view-function metadata for read-only execution.
877+
fn check_view_function_compatibility(
878+
module_name: &str,
879+
cur_metadata: &RuntimeModuleMetadata,
880+
new_metadata: &RuntimeModuleMetadata,
881+
) -> Result<(), ExecutionError> {
882+
let cur_view_functions = view_functions(cur_metadata);
883+
if cur_view_functions.is_empty() {
884+
return Ok(());
885+
}
886+
887+
let new_view_functions = view_functions(new_metadata);
888+
for function_name in cur_view_functions {
889+
if !new_view_functions.contains(&function_name) {
890+
return Err(ExecutionError::new_with_source(
891+
ExecutionErrorKind::PackageUpgradeError {
892+
upgrade_error: PackageUpgradeError::IncompatibleUpgrade,
893+
},
894+
format!(
895+
"Function {module_name}::{function_name} was marked #[view] in the \
896+
previous package version but is not marked #[view] in the upgraded \
897+
package"
898+
),
899+
));
900+
}
901+
}
902+
903+
Ok(())
904+
}
905+
906+
fn view_functions(metadata: &RuntimeModuleMetadata) -> BTreeSet<String> {
907+
metadata
908+
.fun_attributes_iter()
909+
.filter(|&(_function_name, attributes)| {
910+
attributes
911+
.iter()
912+
.any(|attribute| matches!(attribute, IotaAttribute::View))
913+
})
914+
.map(|(function_name, _attributes)| function_name.clone())
915+
.collect()
916+
}
917+
849918
/// Retrieves a `PackageObject` from the storage based on the provided
850919
/// `package_id`. It ensures that exactly one package is fetched,
851920
/// returning an invariant violation if the number of fetched packages

0 commit comments

Comments
 (0)