diff --git a/CLAUDE.md b/CLAUDE.md index 3c638aa428f..4574f593b44 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -263,7 +263,7 @@ KVDatabase: get(key) → Vec, put(key, data) (metadata) Key prefixes (enum): BlockSmallData(H256), BlockEvents(H256), AnnounceProgramStates(HashOf), AnnounceSchedule(HashOf), - ProgramToCodeId(ActorId), InstrumentedCode(u32, CodeId), + ProgramToCodeId(ActorId), InstrumentedCode(runtime_id: u32, version: u32, CodeId), CodeMetadata(CodeId), CodeValid(CodeId), InjectedTransaction(HashOf), Config, Globals, LatestEraValidatorsCommitted(H256) diff --git a/core/src/code/mod.rs b/core/src/code/mod.rs index 88aac3a6639..ea45eb69adf 100644 --- a/core/src/code/mod.rs +++ b/core/src/code/mod.rs @@ -186,6 +186,8 @@ impl Code { let table_section_size = utils::get_instantiated_table_section_size(&module); let element_section_size = utils::get_instantiated_element_section_size(&module)?; + module.strip_custom_sections(); + let code = module.serialize()?; // Use instrumented code to get section sizes. @@ -458,7 +460,7 @@ mod tests { }, gas_metering::CustomConstantCostRules, }; - use alloc::{format, vec::Vec}; + use alloc::{format, string::String, vec::Vec}; use gear_wasm_instrument::{InstrumentationError, ModuleError, STACK_END_EXPORT_NAME}; fn wat2wasm_with_validate(s: &str, validate: bool) -> Vec { @@ -1245,4 +1247,82 @@ mod tests { )) )); } + + /// Walks a WASM binary and returns `true` if it contains a custom section + /// with the given name. + fn has_custom_section(wasm: &[u8], name: &str) -> bool { + wasmparser::Parser::new(0) + .parse_all(wasm) + .filter_map(|p| p.ok()) + .any(|payload| match payload { + wasmparser::Payload::CustomSection(reader) => reader.name() == name, + _ => false, + }) + } + + #[test] + fn instrumented_code_strips_custom_sections_but_original_keeps_them() { + // Build a valid gear program and inject a `sails:idl` custom section + // into its bytes before instrumentation. + let wat = r#" + (module + (import "env" "memory" (memory 1)) + (export "init" (func $init)) + (func $init) + ) + "#; + let base_bytes = wat2wasm(wat); + + // Same mechanism sails tooling uses to embed the IDL. + let idl_payload: Vec = (0..64u8).collect(); + let module = gear_wasm_instrument::Module::new(&base_bytes).unwrap(); + let mut builder = gear_wasm_instrument::ModuleBuilder::from_module(module); + builder.push_custom_section("sails:idl", idl_payload.clone()); + let original_with_idl = builder.build().serialize().unwrap(); + + // Sanity: the constructed original actually carries the section. + assert!( + has_custom_section(&original_with_idl, "sails:idl"), + "test fixture must contain the sails:idl custom section before instrumentation" + ); + + // Run through the full Code pipeline. + let code = Code::try_new_mock_const_or_no_rules( + original_with_idl, + true, + TryNewCodeConfig::default(), + ) + .expect("valid gear program must instrument"); + + // OriginalCode preserves the section — IDL readers (RPC) rely on this. + assert!( + has_custom_section(code.original_code(), "sails:idl"), + "OriginalCode must retain sails:idl custom section" + ); + + // InstrumentedCode must have the sails:idl section stripped. + assert!( + !has_custom_section(code.instrumented_code().bytes(), "sails:idl"), + "InstrumentedCode must have sails:idl stripped" + ); + + // Broader check: every custom section other than `name` is gone. + // The WASM binary format stores the name section as a custom section + // named "name"; we intentionally preserve it for readable trap + // backtraces (see `Module::strip_custom_sections`). + let lingering: Vec = wasmparser::Parser::new(0) + .parse_all(code.instrumented_code().bytes()) + .filter_map(|p| p.ok()) + .filter_map(|payload| match payload { + wasmparser::Payload::CustomSection(reader) if reader.name() != "name" => { + Some(String::from(reader.name())) + } + _ => None, + }) + .collect(); + assert!( + lingering.is_empty(), + "InstrumentedCode must have no custom sections apart from `name`; found: {lingering:?}" + ); + } } diff --git a/ethexe/common/src/db.rs b/ethexe/common/src/db.rs index a20bbfd4338..899ad446f3e 100644 --- a/ethexe/common/src/db.rs +++ b/ethexe/common/src/db.rs @@ -76,8 +76,8 @@ pub trait CodesStorageRO { fn original_code_exists(&self, code_id: CodeId) -> bool; fn original_code(&self, code_id: CodeId) -> Option>; fn program_code_id(&self, program_id: ActorId) -> Option; - fn instrumented_code_exists(&self, runtime_id: u32, code_id: CodeId) -> bool; - fn instrumented_code(&self, runtime_id: u32, code_id: CodeId) -> Option; + fn instrumented_code_exists(&self, version: u32, code_id: CodeId) -> bool; + fn instrumented_code(&self, version: u32, code_id: CodeId) -> Option; fn code_metadata(&self, code_id: CodeId) -> Option; fn code_valid(&self, code_id: CodeId) -> Option; fn valid_codes(&self) -> BTreeSet; @@ -87,7 +87,7 @@ pub trait CodesStorageRO { pub trait CodesStorageRW: CodesStorageRO { fn set_original_code(&self, code: &[u8]) -> CodeId; fn set_program_code_id(&self, program_id: ActorId, code_id: CodeId); - fn set_instrumented_code(&self, runtime_id: u32, code_id: CodeId, code: InstrumentedCode); + fn set_instrumented_code(&self, version: u32, code_id: CodeId, code: InstrumentedCode); fn set_code_metadata(&self, code_id: CodeId, code_metadata: CodeMetadata); fn set_code_valid(&self, code_id: CodeId, valid: bool); } diff --git a/ethexe/common/src/mock.rs b/ethexe/common/src/mock.rs index 2bc84264114..ea05ebb9af7 100644 --- a/ethexe/common/src/mock.rs +++ b/ethexe/common/src/mock.rs @@ -26,6 +26,11 @@ use crate::{ gear::{BatchCommitment, ChainCommitment, CodeCommitment, Message, StateTransition}, injected::{AddressedInjectedTransaction, InjectedTransaction}, }; + +/// Mock equivalent of `ethexe_runtime_common::VERSION` (can't import directly: +/// `ethexe-runtime-common` depends on `ethexe-common`). A matching-constants +/// test in `ethexe-runtime-common` fails if this drifts. +pub const MOCK_VERSION: u32 = 2; use alloc::{collections::BTreeMap, vec}; use gear_core::{ code::{CodeMetadata, InstrumentedCode}, @@ -725,7 +730,7 @@ impl BlockChain { db.set_original_code(&original_bytes); if let Some(InstrumentedCodeData { instrumented, meta }) = instrumented { - db.set_instrumented_code(1, code_id, instrumented); + db.set_instrumented_code(MOCK_VERSION, code_id, instrumented); db.set_code_metadata(code_id, meta); db.set_code_blob_info(code_id, blob_info); db.set_code_valid(code_id, true); diff --git a/ethexe/compute/src/compute.rs b/ethexe/compute/src/compute.rs index e2f67248057..d7895859627 100644 --- a/ethexe/compute/src/compute.rs +++ b/ethexe/compute/src/compute.rs @@ -428,7 +428,7 @@ mod tests { injected::{InjectedTransaction, SignedInjectedTransaction}, }; use ethexe_processor::ValidCodeInfo; - use ethexe_runtime_common::RUNTIME_ID; + use ethexe_runtime_common::VERSION; use gear_core::ids::prelude::CodeIdExt; use gprimitives::{CodeId, MessageId}; @@ -454,7 +454,7 @@ mod tests { .expect("code is invalid"); db.set_original_code(&code); - db.set_instrumented_code(RUNTIME_ID, code_id, instrumented_code); + db.set_instrumented_code(VERSION, code_id, instrumented_code); db.set_code_metadata(code_id, code_metadata); db.set_code_valid(code_id, true); diff --git a/ethexe/db/src/database.rs b/ethexe/db/src/database.rs index 9ae3d1d633a..9eda90bcf0a 100644 --- a/ethexe/db/src/database.rs +++ b/ethexe/db/src/database.rs @@ -69,6 +69,8 @@ enum Key { AnnounceMeta(HashOf) = 6, ProgramToCodeId(ActorId) = 7, + /// `(instrumentation_version, code_id)`. Bumping + /// `ethexe_runtime_common::VERSION` invalidates every prior entry. InstrumentedCode(u32, CodeId) = 8, CodeMetadata(CodeId) = 9, CodeUploadInfo(CodeId) = 10, @@ -93,7 +95,9 @@ impl Key { } fn to_bytes(&self) -> Vec { - // Pre-allocate enough space for the largest possible key. + // Pre-allocate enough space for the largest possible key + // (InstrumentedCode carries a u32 prefix in addition to the + // discriminant and CodeId). let mut bytes = Vec::with_capacity(2 * size_of::() + size_of::()); bytes.extend(self.prefix()); @@ -120,8 +124,8 @@ impl Key { | Self::CodeUploadInfo(code_id) | Self::CodeValid(code_id) => bytes.extend(code_id.as_ref()), - Self::InstrumentedCode(runtime_id, code_id) => { - bytes.extend(runtime_id.to_le_bytes()); + Self::InstrumentedCode(version, code_id) => { + bytes.extend(version.to_le_bytes()); bytes.extend(code_id.as_ref()); } Self::Globals | Self::Config => { @@ -609,14 +613,14 @@ impl CodesStorageRO for RawDatabase { }) } - fn instrumented_code_exists(&self, runtime_id: u32, code_id: CodeId) -> bool { + fn instrumented_code_exists(&self, version: u32, code_id: CodeId) -> bool { self.kv - .contains(&Key::InstrumentedCode(runtime_id, code_id).to_bytes()) + .contains(&Key::InstrumentedCode(version, code_id).to_bytes()) } - fn instrumented_code(&self, runtime_id: u32, code_id: CodeId) -> Option { + fn instrumented_code(&self, version: u32, code_id: CodeId) -> Option { self.kv - .get(&Key::InstrumentedCode(runtime_id, code_id).to_bytes()) + .get(&Key::InstrumentedCode(version, code_id).to_bytes()) .map(|data| { Decode::decode(&mut data.as_slice()) .expect("Failed to decode data into `InstrumentedCode`") @@ -678,14 +682,14 @@ impl CodesStorageRW for RawDatabase { ); } - fn set_instrumented_code(&self, runtime_id: u32, code_id: CodeId, code: InstrumentedCode) { + fn set_instrumented_code(&self, version: u32, code_id: CodeId, code: InstrumentedCode) { tracing::trace!( code_id = ?code_id, - runtime_id = %runtime_id, + version = %version, "Set instrumented code" ); self.kv.put( - &Key::InstrumentedCode(runtime_id, code_id).to_bytes(), + &Key::InstrumentedCode(version, code_id).to_bytes(), code.encode(), ); } @@ -960,8 +964,8 @@ impl CodesStorageRO for Database { fn original_code_exists(&self, code_id: CodeId) -> bool; fn original_code(&self, code_id: CodeId) -> Option>; fn program_code_id(&self, program_id: ActorId) -> Option; - fn instrumented_code_exists(&self, runtime_id: u32, code_id: CodeId) -> bool; - fn instrumented_code(&self, runtime_id: u32, code_id: CodeId) -> Option; + fn instrumented_code_exists(&self, version: u32, code_id: CodeId) -> bool; + fn instrumented_code(&self, version: u32, code_id: CodeId) -> Option; fn code_metadata(&self, code_id: CodeId) -> Option; fn code_valid(&self, code_id: CodeId) -> Option; fn valid_codes(&self) -> BTreeSet; @@ -972,7 +976,7 @@ impl CodesStorageRW for Database { delegate!(to self.raw { fn set_original_code(&self, code: &[u8]) -> CodeId; fn set_program_code_id(&self, program_id: ActorId, code_id: CodeId); - fn set_instrumented_code(&self, runtime_id: u32, code_id: CodeId, code: InstrumentedCode); + fn set_instrumented_code(&self, version: u32, code_id: CodeId, code: InstrumentedCode); fn set_code_metadata(&self, code_id: CodeId, code_metadata: CodeMetadata); fn set_code_valid(&self, code_id: CodeId, valid: bool); }); @@ -1044,6 +1048,17 @@ mod tests { limited::LimitedVec, }; + /// `migrations::v5` hardcodes discriminant `8`; this test pins it. + #[test] + fn instrumented_code_key_discriminant_is_stable() { + let bytes = Key::InstrumentedCode(0, CodeId::zero()).to_bytes(); + assert_eq!( + &bytes[..size_of::()], + H256::from_low_u64_be(8).as_bytes(), + "Key::InstrumentedCode discriminant drifted; update ethexe/db/src/migrations/v5.rs" + ); + } + #[test] fn test_injected_transaction() { let db = Database::memory(); @@ -1170,17 +1185,22 @@ mod tests { fn test_instrumented_code() { let db = Database::memory(); - let runtime_id = 1; + let version = 2; let code_id = CodeId::default(); let section_sizes = InstantiatedSectionSizes::new(0, 0, 0, 0, 0, 0); let instrumented_code = InstrumentedCode::new(vec![1, 2, 3, 4], section_sizes); - db.set_instrumented_code(runtime_id, code_id, instrumented_code.clone()); + db.set_instrumented_code(version, code_id, instrumented_code.clone()); assert_eq!( - db.instrumented_code(runtime_id, code_id) + db.instrumented_code(version, code_id) .as_ref() .map(|c| c.bytes()), Some(instrumented_code.bytes()) ); + + assert!( + db.instrumented_code(version + 1, code_id).is_none(), + "bumping version must invalidate prior entries" + ); } #[test] diff --git a/ethexe/db/src/iterator.rs b/ethexe/db/src/iterator.rs index 28653dce8d3..795e643db56 100644 --- a/ethexe/db/src/iterator.rs +++ b/ethexe/db/src/iterator.rs @@ -587,7 +587,7 @@ where if let Some(instrumented_code) = self .storage - .instrumented_code(ethexe_runtime_common::RUNTIME_ID, code_id) + .instrumented_code(ethexe_runtime_common::VERSION, code_id) { self.push_node(InstrumentedCodeNode { code_id, diff --git a/ethexe/db/src/migrations/init.rs b/ethexe/db/src/migrations/init.rs index b45e4686e08..7bdace6d92e 100644 --- a/ethexe/db/src/migrations/init.rs +++ b/ethexe/db/src/migrations/init.rs @@ -29,7 +29,7 @@ use ethexe_common::{ gear::{GenesisBlockInfo, Timelines}, }; use ethexe_ethereum::router::RouterQuery; -use ethexe_runtime_common::{RUNTIME_ID, ScheduleRestorer, state::Storage}; +use ethexe_runtime_common::{ScheduleRestorer, VERSION, state::Storage}; use futures::{TryStreamExt, stream::FuturesUnordered}; use gprimitives::{CodeId, H256}; @@ -297,7 +297,7 @@ async fn genesis_data_initialization( ); db_clone.set_code_metadata(code_id, code_metadata); - db_clone.set_instrumented_code(RUNTIME_ID, code_id, instrumented_code); + db_clone.set_instrumented_code(VERSION, code_id, instrumented_code); db_clone.set_code_valid(code_id, true); Ok::<_, anyhow::Error>(()) diff --git a/ethexe/db/src/migrations/mod.rs b/ethexe/db/src/migrations/mod.rs index cfb86c844e7..3c590dd969d 100644 --- a/ethexe/db/src/migrations/mod.rs +++ b/ethexe/db/src/migrations/mod.rs @@ -35,15 +35,17 @@ mod v1; mod v2; mod v3; mod v4; +mod v5; pub const OLDEST_SUPPORTED_VERSION: u32 = v0::VERSION; -pub const LATEST_VERSION: u32 = v4::VERSION; +pub const LATEST_VERSION: u32 = v5::VERSION; pub const MIGRATIONS: &[&dyn Migration] = &[ &v1::migration_from_v0, &v2::migration_from_v1, &v3::migration_from_v2, &v4::migration_from_v3, + &v5::migration_from_v4, ]; const _: () = assert!( diff --git a/ethexe/db/src/migrations/v1.rs b/ethexe/db/src/migrations/v1.rs index 59019e406bc..20f362d14bf 100644 --- a/ethexe/db/src/migrations/v1.rs +++ b/ethexe/db/src/migrations/v1.rs @@ -28,7 +28,7 @@ pub const VERSION: u32 = 1; const _: () = const { assert!( - crate::VERSION == super::v4::VERSION, + crate::VERSION == super::v5::VERSION, "Check migration code for types changing in case of version change: DBConfig, DBGlobals, ProtocolTimelines" ); }; diff --git a/ethexe/db/src/migrations/v2.rs b/ethexe/db/src/migrations/v2.rs index 579ebdbd480..5440a985c60 100644 --- a/ethexe/db/src/migrations/v2.rs +++ b/ethexe/db/src/migrations/v2.rs @@ -37,7 +37,7 @@ pub const VERSION: u32 = 2; const _: () = const { assert!( - crate::VERSION == super::v4::VERSION, + crate::VERSION == super::v5::VERSION, "Check migration code for types changing in case of version change: DBConfig, DBGlobals, Announce, BlockSmallData. \ Also check AnnounceStorageRW, KVDatabase, dyn KVDatabase implementations" ); diff --git a/ethexe/db/src/migrations/v4.rs b/ethexe/db/src/migrations/v4.rs index 1a24c7a16b5..85f89dc1ad2 100644 --- a/ethexe/db/src/migrations/v4.rs +++ b/ethexe/db/src/migrations/v4.rs @@ -29,7 +29,7 @@ pub const VERSION: u32 = 4; const _: () = const { assert!( - crate::VERSION == VERSION, + crate::VERSION == super::v5::VERSION, "Check migration code for types changing in case of version change: DBConfig, DBGlobals, Announce, BlockSmallData. \ Also check AnnounceStorageRW, KVDatabase, dyn KVDatabase implementations" ); diff --git a/ethexe/db/src/migrations/v5.rs b/ethexe/db/src/migrations/v5.rs new file mode 100644 index 00000000000..38af4e757f8 --- /dev/null +++ b/ethexe/db/src/migrations/v5.rs @@ -0,0 +1,71 @@ +// This file is part of Gear. +// +// Copyright (C) 2026 Gear Technologies Inc. +// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +use super::InitConfig; +use crate::RawDatabase; +use anyhow::{Context as _, Result}; +use ethexe_common::db::DBConfig; +use gprimitives::H256; + +pub const VERSION: u32 = 5; + +const _: () = const { + assert!( + crate::VERSION == VERSION, + "Check migration code for types changing in case of version change: DBConfig" + ); +}; + +/// v4 → v5: drop every `InstrumentedCode` entry. `ethexe_runtime_common::VERSION` +/// was bumped, so all prior entries are unreachable through the new key. +/// +/// There is no re-instrumentation mechanism: the compute pipeline only +/// produces `InstrumentedCode` for codes it observes from +/// `RouterEvent::CodeUploaded`. Affected programs surface +/// `MissingInstrumentedCodeForProgram` on dispatch until their `OriginalCode` +/// is uploaded again. `OriginalCode` itself stays in CAS. +pub async fn migration_from_v4(_: &InitConfig, db: &RawDatabase) -> Result<()> { + // Matches `database::Key::InstrumentedCode`'s u64 discriminant (= 8). The + // accompanying test in `database.rs` pins this value. + const INSTRUMENTED_CODE_DISCRIMINANT: u64 = 8; + + let prefix = H256::from_low_u64_be(INSTRUMENTED_CODE_DISCRIMINANT); + + let stale_keys: Vec> = db + .kv + .iter_prefix(prefix.as_bytes()) + .map(|(k, _)| k) + .collect(); + + let deleted = stale_keys.len(); + for key in stale_keys { + // `KVDatabase::take` is unsafe purely for the data-loss risk — that's + // exactly the intent here. + let _ = unsafe { db.kv.take(&key) }; + } + + log::info!("migration v4→v5: dropped {deleted} stale InstrumentedCode entries"); + + let config = db.kv.config().context("Cannot find db config")?; + db.kv.set_config(DBConfig { + version: VERSION, + ..config + }); + + Ok(()) +} diff --git a/ethexe/processor/src/tests.rs b/ethexe/processor/src/tests.rs index 8f664618ab5..60679a82db0 100644 --- a/ethexe/processor/src/tests.rs +++ b/ethexe/processor/src/tests.rs @@ -29,7 +29,7 @@ use ethexe_common::{ }, mock::*, }; -use ethexe_runtime_common::{RUNTIME_ID, WAIT_UP_TO_SAFE_DURATION, state::MessageQueue}; +use ethexe_runtime_common::{VERSION, WAIT_UP_TO_SAFE_DURATION, state::MessageQueue}; use gear_core::{ ids::prelude::CodeIdExt, message::{ErrorReplyReason, ReplyCode, SuccessReplyReason}, @@ -92,7 +92,7 @@ mod utils { let db = &processor.db; db.set_original_code(&code); - db.set_instrumented_code(RUNTIME_ID, code_id, instrumented_code); + db.set_instrumented_code(VERSION, code_id, instrumented_code); db.set_code_metadata(code_id, code_metadata); db.set_code_valid(code_id, true); @@ -300,6 +300,56 @@ async fn handle_new_code_valid() { ); } +#[tokio::test] +async fn instrumented_code_strips_custom_sections() { + init_logger(); + + // Build a valid gear program and inject a `sails:idl` custom section + // into its original bytes — simulating what sails tooling emits. + let (_orig_code_id, base_bytes) = utils::wat_to_wasm(utils::VALID_PROGRAM); + let idl_payload: Vec = (0..128u8).collect(); + + let module = gear_wasm_instrument::Module::new(&base_bytes) + .expect("VALID_PROGRAM must parse as a Module"); + let mut builder = gear_wasm_instrument::ModuleBuilder::from_module(module); + builder.push_custom_section("sails:idl", idl_payload.clone()); + let code_with_idl = builder.build().serialize().expect("serialize must succeed"); + let code_id = CodeId::generate(&code_with_idl); + + // Run through the ethexe processor pipeline. + let mut processor = Processor::new(Database::memory()).expect("failed to create processor"); + let info = processor + .process_code(CodeAndIdUnchecked { + code: code_with_idl.clone(), + code_id, + }) + .await + .expect("process_code failed") + .valid + .expect("code must be valid"); + + // OriginalCode keeps the IDL — RPC readers depend on this. + let original = gear_wasm_instrument::Module::new(&info.code).expect("original code must parse"); + assert!( + original + .custom_sections + .as_ref() + .is_some_and(|cs| cs.iter().any(|(n, _)| n == "sails:idl")), + "processor must not strip custom sections from OriginalCode" + ); + + // InstrumentedCode has no custom sections at all. + let instrumented = gear_wasm_instrument::Module::new(info.instrumented_code.bytes()) + .expect("instrumented code must parse"); + assert!( + instrumented + .custom_sections + .as_ref() + .is_none_or(|cs| cs.is_empty()), + "InstrumentedCode must have no custom sections after the strip" + ); +} + #[tokio::test] async fn handle_new_code_invalid() { init_logger(); diff --git a/ethexe/rpc/src/apis/code.rs b/ethexe/rpc/src/apis/code.rs index 800e194d2fd..15a3e846cc2 100644 --- a/ethexe/rpc/src/apis/code.rs +++ b/ethexe/rpc/src/apis/code.rs @@ -56,9 +56,12 @@ impl CodeServer for CodeApi { .ok_or_else(|| errors::db("Failed to get code by supplied id")) } - async fn get_instrumented_code(&self, runtime_id: u32, code_id: H256) -> RpcResult { + async fn get_instrumented_code(&self, _runtime_id: u32, code_id: H256) -> RpcResult { + // The `runtime_id` parameter is preserved for backward compatibility but + // ignored: the DB is keyed by `ethexe_runtime_common::VERSION` only, and + // the RPC always returns "whatever this node has for this code right now". self.db - .instrumented_code(runtime_id, code_id.into()) + .instrumented_code(ethexe_runtime_common::VERSION, code_id.into()) .map(|bytes| bytes.encode().into()) .ok_or_else(|| errors::db("Failed to get code by supplied id")) } diff --git a/ethexe/runtime/common/Cargo.toml b/ethexe/runtime/common/Cargo.toml index 3ed8140ecf3..78e4f9deaf1 100644 --- a/ethexe/runtime/common/Cargo.toml +++ b/ethexe/runtime/common/Cargo.toml @@ -28,6 +28,9 @@ serde = { workspace = true, features = ["derive"], optional = true } gear-workspace-hack.workspace = true delegate.workspace = true +[dev-dependencies] +ethexe-common = { workspace = true, features = ["mock", "std"] } + [features] default = ["std"] std = [ diff --git a/ethexe/runtime/common/src/lib.rs b/ethexe/runtime/common/src/lib.rs index 98aedbbdfba..9f1acd244fb 100644 --- a/ethexe/runtime/common/src/lib.rs +++ b/ethexe/runtime/common/src/lib.rs @@ -63,9 +63,10 @@ mod schedule; mod transitions; // TODO: consider format. -/// Version of the runtime. -pub const VERSION: u32 = 1; -pub const RUNTIME_ID: u32 = 1; +/// Instrumentation pipeline version. Used by `Code::try_new` and as the +/// `InstrumentedCode` DB key discriminator; bumping it invalidates cached +/// instrumented bytes. +pub const VERSION: u32 = 2; /// Maximum number of outgoing messages per execution of one dispatch. pub const MAX_OUTGOING_MESSAGES_PER_EXECUTION: u32 = 4; @@ -476,3 +477,18 @@ pub const fn unpack_i64_to_u32(val: i64) -> (u32, u32) { let low = val as u32; (low, high) } + +#[cfg(test)] +mod tests { + use super::*; + + /// Guards against drift between `ethexe-common`'s mock `MOCK_VERSION` and the + /// real `VERSION` exported here. The mock can't `use` this constant directly + /// because `ethexe-runtime-common` depends on `ethexe-common`, so a future + /// bump of `VERSION` would silently leave the mock stale. This test fails + /// loudly instead. + #[test] + fn mock_constants_match_runtime_constants() { + assert_eq!(VERSION, ethexe_common::mock::MOCK_VERSION); + } +} diff --git a/ethexe/service/src/tests/mod.rs b/ethexe/service/src/tests/mod.rs index bdf90bc9b44..017b30890fc 100644 --- a/ethexe/service/src/tests/mod.rs +++ b/ethexe/service/src/tests/mod.rs @@ -53,7 +53,10 @@ use ethexe_ethereum::{ use ethexe_observer::ObserverEvent; use ethexe_processor::Processor; use ethexe_rpc::InjectedClient; -use ethexe_runtime_common::state::{Expiring, MailboxMessage, PayloadLookup, Storage}; +use ethexe_runtime_common::{ + VERSION, + state::{Expiring, MailboxMessage, PayloadLookup, Storage}, +}; use futures::StreamExt; use gear_core::{ ids::prelude::*, @@ -164,7 +167,7 @@ async fn write_memory_to_last_byte() { let _ = node .db - .instrumented_code(1, code_id) + .instrumented_code(VERSION, code_id) .expect("After approval, instrumented code is guaranteed to be in the database"); let res = env .create_program(code_id, 500_000_000_000_000) @@ -219,7 +222,7 @@ async fn ping() { let _ = node .db - .instrumented_code(1, code_id) + .instrumented_code(VERSION, code_id) .expect("After approval, instrumented code is guaranteed to be in the database"); let res = env .create_program(code_id, 500_000_000_000_000) @@ -3694,7 +3697,7 @@ async fn reply_callback() { let _ = node .db - .instrumented_code(1, code_id) + .instrumented_code(VERSION, code_id) .expect("After approval, instrumented code is guaranteed to be in the database"); let res = env .create_program(code_id, 500_000_000_000_000) diff --git a/gsdk/vara_runtime.scale b/gsdk/vara_runtime.scale index 2b15c08a133..caf2e9b793e 100644 Binary files a/gsdk/vara_runtime.scale and b/gsdk/vara_runtime.scale differ diff --git a/pallets/gear/src/schedule.rs b/pallets/gear/src/schedule.rs index 805215d2a9a..5e46df51279 100644 --- a/pallets/gear/src/schedule.rs +++ b/pallets/gear/src/schedule.rs @@ -899,7 +899,7 @@ impl Default for InstructionWeights { // See below for the assembly listings of the mentioned instructions. type W = ::WeightInfo; Self { - version: 1900, + version: 1910, i64const: cost_i64const::(), i64load: cost_instr::(W::::instr_i64load, 0), i32load: cost_instr::(W::::instr_i32load, 0), diff --git a/pallets/gear/src/tests.rs b/pallets/gear/src/tests.rs index 9565ec13c3d..2a3616deff4 100644 --- a/pallets/gear/src/tests.rs +++ b/pallets/gear/src/tests.rs @@ -4972,6 +4972,83 @@ fn test_code_submission_pass() { }) } +#[test] +fn stripping_reduces_instrumented_code_len() { + init_logger(); + new_test_ext().execute_with(|| { + let base = ProgramCodeKind::Default.to_bytes(); + let base_len = base.len(); + + let idl_payload: Vec = (0..4096).map(|i| (i & 0xff) as u8).collect(); + let idl_len = idl_payload.len(); + let module = Module::new(&base).expect("Default program must parse"); + let mut builder = gear_wasm_instrument::ModuleBuilder::from_module(module); + builder.push_custom_section("sails:idl", idl_payload); + let code_with_idl = builder.build().serialize().expect("must serialize"); + let code_id = CodeId::generate(&code_with_idl); + + // Sanity: the constructed original carries sails:idl. + assert!( + has_sails_idl(&code_with_idl), + "fixture must contain sails:idl before upload" + ); + + assert_ok!(Gear::upload_code( + RuntimeOrigin::signed(USER_1), + code_with_idl.clone(), + )); + + // OriginalCode keeps the IDL (RPC readers depend on this). + let original = ::CodeStorage::get_original_code(code_id) + .expect("original code must be stored"); + assert!( + has_sails_idl(&original), + "OriginalCode must retain the sails:idl custom section" + ); + + // InstrumentedCode must not contain any custom sections at all. + let instrumented = ::CodeStorage::get_instrumented_code(code_id) + .expect("instrumented code must be stored"); + let parsed = + Module::new(instrumented.bytes()).expect("instrumented bytes must be a valid module"); + assert!( + parsed + .custom_sections + .as_ref() + .is_none_or(|cs| cs.is_empty()), + "InstrumentedCode must have no custom sections after strip" + ); + + // Guard the stated contract of this test: stripping must shave at + // least (idl_len / 2) bytes off the instrumented artifact relative + // to the raw upload. Instrumentation itself adds some bytes, so we + // can't assert an exact equality — the half-payload floor catches + // any regression that re-embeds the section under a different name. + let grown = code_with_idl.len().saturating_sub(base_len); + assert!( + grown >= idl_len, + "fixture sanity: injecting {idl_len} bytes grew upload by only {grown}" + ); + assert!( + instrumented.bytes().len() + idl_len / 2 < code_with_idl.len(), + "stripping must remove at least idl_len/2 = {} bytes; instrumented={}, original={}", + idl_len / 2, + instrumented.bytes().len(), + code_with_idl.len() + ); + }) +} + +fn has_sails_idl(wasm: &[u8]) -> bool { + let Ok(module) = Module::new(wasm) else { + return false; + }; + module + .custom_sections + .as_ref() + .is_some_and(|cs| cs.iter().any(|(n, _)| n == "sails:idl")) +} + #[test] fn test_same_code_submission_fails() { init_logger(); diff --git a/utils/wasm-instrument/src/module.rs b/utils/wasm-instrument/src/module.rs index 66f73ce36b9..0e4763ac426 100644 --- a/utils/wasm-instrument/src/module.rs +++ b/utils/wasm-instrument/src/module.rs @@ -1441,6 +1441,18 @@ impl Module { }) } + /// Strips all WASM custom sections from the module. + /// + /// The `name` section is **preserved** to keep Wasmer/Wasmtime trap + /// backtraces readable in production logs. This differs from + /// `wasm_optimizer::Optimizer::strip_custom_sections`, which clears both. + /// + /// Custom sections (`sails:idl`, `producers`, etc.) are not consumed + /// at sandbox execution time; IDL readers pull from `OriginalCode`. + pub fn strip_custom_sections(&mut self) { + self.custom_sections = None; + } + pub fn serialize(&self) -> Result> { let mut module = wasm_encoder::Module::new(); @@ -1689,4 +1701,44 @@ mod tests { let parsed_wat = wasmprinter::print_bytes(&parsed_module_bytes).unwrap(); assert_eq!(wat, parsed_wat); } + + #[test] + fn strip_custom_sections_clears_custom_but_keeps_name() { + let mut builder = ModuleBuilder::default(); + builder.push_custom_section("sails:idl", [0xAA, 0xBB, 0xCC]); + builder.push_custom_section("producers", [0xDE, 0xAD]); + let mut module = builder.build(); + // Simulate a preserved name section. + module.name_section = Some(Vec::new()); + + module.strip_custom_sections(); + + assert!( + module.custom_sections.is_none(), + "custom_sections must be cleared" + ); + assert!( + module.name_section.is_some(), + "name_section must be preserved across strip" + ); + + // Round-trip through serialize/parse: custom sections must not + // reappear in the serialized bytes. + let bytes = module.serialize().unwrap(); + let reparsed = Module::new(&bytes).unwrap(); + assert!( + reparsed.custom_sections.is_none() + || reparsed.custom_sections.as_ref().unwrap().is_empty(), + "serialized module must not contain custom sections after strip" + ); + } + + #[test] + fn strip_custom_sections_on_empty_module_is_noop() { + let mut module = ModuleBuilder::default().build(); + // No custom sections, no name section: must not panic, stays None. + assert!(module.custom_sections.is_none()); + module.strip_custom_sections(); + assert!(module.custom_sections.is_none()); + } }