Repository: stellar/rs-soroban-env
Pull request #1348 (2024-02-14 03:17:51)

Host function protocol gating
### What

Allow host functions to specify its supported protocol bounds, e.g. 

```diff
        {
            "name": "test",
            "export": "t",
            "functions": [
                {
                    "export": "_",
                    "name": "dummy0",
                    "args": [],
                    "return": "Val",
                    "docs": "A dummy function taking 0 arguments and performs no-op.                
+                  "min_supported_protocol": 20,
+                   "max_supported_protocol": 21
                }                
            ]
        },
```

And adds logic (mostly macro generated) that automatically checks and enforces these protocol bounds in various paths: 
- Host running as native. Covered by `impl Env for VmCallerEnv`.
- Host running from the guest Wasm contract. Covered by Vm link-time check, as well as the dispatch functions (as a redundant safe guard). 

Tests are added in `tests/protocol_gate.rs`.

### Why

[TODO: Why this change is being made. Include any context required to understand the why.]

### Known limitations

[TODO or N/A]



Patch:
diff --git a/soroban-env-common/env.json b/soroban-env-common/env.json
index df7d5c4f..4c16db5c 100644
--- a/soroban-env-common/env.json
+++ b/soroban-env-common/env.json
@@ -2042,7 +2042,16 @@
                     "args": [],
                     "return": "Val",
                     "docs": "A dummy function taking 0 arguments and performs no-op. This function is for test purpose only, for measuring the roundtrip cost of invoking a host function, i.e. host->Vm->host."
-                }
+                },
+                {
+                    "export": "0",
+                    "name": "protocol_gated_dummy",
+                    "args": [],
+                    "return": "Val",
+                    "docs": "A dummy function for testing the protocol gating. Takes 0 arguments and performs no-op",
+                    "min_supported_protocol": 19,
+                    "max_supported_protocol": 19
+                }                
             ]
         },
         {
diff --git a/soroban-env-common/src/env.rs b/soroban-env-common/src/env.rs
index 9c8501ea..34d9fedc 100644
--- a/soroban-env-common/src/env.rs
+++ b/soroban-env-common/src/env.rs
@@ -225,6 +225,14 @@ pub trait EnvBase: Sized + Clone {
     /// events are enabled. When running on host, logs directly; when running on
     /// guest, redirects through log_from_linear_memory.
     fn log_from_slice(&self, msg: &str, vals: &[Val]) -> Result<Void, Self::Error>;
+
+    /// Check the current ledger protocol version against a provided lower
+    /// bound, error if protocol version is out-of-bound.
+    fn check_protocol_version_lower_bound(&self, lower_bound: u32) -> Result<(), Self::Error>;
+
+    /// Check the current ledger protocol version against a provided upper
+    /// bound, error if protocol version is out-of-bound.
+    fn check_protocol_version_upper_bound(&self, upper_bound: u32) -> Result<(), Self::Error>;
 }
 
 /// This trait is used by macro-generated dispatch and forwarding functions to
@@ -329,6 +337,7 @@ generate_call_macro_with_all_host_functions!("env.json");
 // trait.
 macro_rules! host_function_helper {
     {
+        $($min_proto:literal)?, $($max_proto:literal)?,
         $(#[$attr:meta])*
         fn $fn_id:ident($($arg:ident:$type:ty),*) -> $ret:ty}
     =>
@@ -359,7 +368,7 @@ macro_rules! generate_env_trait {
                     // pattern-repetition matcher so that it will match all such
                     // descriptions.
                     $(#[$fn_attr:meta])*
-                    { $fn_str:literal, fn $fn_id:ident $args:tt -> $ret:ty }
+                    { $fn_str:literal, $($min_proto:literal)?, $($max_proto:literal)?, fn $fn_id:ident $args:tt -> $ret:ty }
                 )*
             }
         )*
@@ -387,7 +396,7 @@ macro_rules! generate_env_trait {
                     // block repetition-level from the outer pattern in the
                     // expansion, flattening all functions from all 'mod' blocks
                     // into the Env trait.
-                    host_function_helper!{$(#[$fn_attr])* fn $fn_id $args -> $ret}
+                    host_function_helper!{$($min_proto)?, $($max_proto)?, $(#[$fn_attr])* fn $fn_id $args -> $ret}
                 )*
             )*
         }
diff --git a/soroban-env-common/src/vmcaller_env.rs b/soroban-env-common/src/vmcaller_env.rs
index 5a56cd9b..1b97d8bb 100644
--- a/soroban-env-common/src/vmcaller_env.rs
+++ b/soroban-env-common/src/vmcaller_env.rs
@@ -78,6 +78,7 @@ impl<'a, T> VmCaller<'a, T> {
 // trait.
 macro_rules! host_function_helper {
     {
+        $($min_proto:literal)?, $($max_proto:literal)?,
         $(#[$attr:meta])*
         fn $fn_id:ident($($arg:ident:$type:ty),*) -> $ret:ty
     }
@@ -109,7 +110,7 @@ macro_rules! generate_vmcaller_checked_env_trait {
                     // pattern-repetition matcher so that it will match all such
                     // descriptions.
                     $(#[$fn_attr:meta])*
-                    { $fn_str:literal, fn $fn_id:ident $args:tt -> $ret:ty }
+                    { $fn_str:literal, $($min_proto:literal)?, $($max_proto:literal)?, fn $fn_id:ident $args:tt -> $ret:ty }
                 )*
             }
         )*
@@ -146,7 +147,7 @@ macro_rules! generate_vmcaller_checked_env_trait {
                     // block repetition-level from the outer pattern in the
                     // expansion, flattening all functions from all 'mod' blocks
                     // into the VmCallerEnv trait.
-                    host_function_helper!{$(#[$fn_attr])* fn $fn_id $args -> $ret}
+                    host_function_helper!{$($min_proto)?, $($max_proto)?, $(#[$fn_attr])* fn $fn_id $args -> $ret}
                 )*
             )*
         }
@@ -169,7 +170,7 @@ call_macro_with_all_host_functions! { generate_vmcaller_checked_env_trait }
 // and produces the the corresponding method declaration to be used in the Env
 // trait.
 macro_rules! vmcaller_none_function_helper {
-    {fn $fn_id:ident($($arg:ident:$type:ty),*) -> $ret:ty}
+    {$($min_proto:literal)?, $($max_proto:literal)?, fn $fn_id:ident($($arg:ident:$type:ty),*) -> $ret:ty}
     =>
     {
         // We call `augment_err_result` here to give the Env a chance to attach
@@ -177,6 +178,13 @@ macro_rules! vmcaller_none_function_helper {
         // that didn't have an Env on hand when creating the error. This will at
         // least localize the error to a given Env call.
         fn $fn_id(&self, $($arg:$type),*) -> Result<$ret, Self::Error> {
+            // Check the ledger protocol version against the function-specified
+            // boundaries, this prevents calling a host function using the host
+            // directly as `Env` (i.e. native mode) when the protocol version is
+            // out of bound.
+            $( self.check_protocol_version_lower_bound($min_proto)?; )?
+            $( self.check_protocol_version_upper_bound($max_proto)?; )?
+
             #[cfg(all(not(target_family = "wasm"), feature = "tracy"))]
             let _span = tracy_span!(core::stringify!($fn_id));
             #[cfg(feature = "std")]
@@ -224,7 +232,7 @@ macro_rules! impl_env_for_vmcaller_env {
                     // pattern-repetition matcher so that it will match all such
                     // descriptions.
                     $(#[$fn_attr:meta])*
-                    { $fn_str:literal, fn $fn_id:ident $args:tt -> $ret:ty }
+                    { $fn_str:literal, $($min_proto:literal)?, $($max_proto:literal)?, fn $fn_id:ident $args:tt -> $ret:ty }
                 )*
             }
         )*
@@ -249,7 +257,7 @@ macro_rules! impl_env_for_vmcaller_env {
                     // block repetition-level from the outer pattern in the
                     // expansion, flattening all functions from all 'mod' blocks
                     // into the impl.
-                    vmcaller_none_function_helper!{fn $fn_id $args -> $ret}
+                    vmcaller_none_function_helper!{$($min_proto)?, $($max_proto)?, fn $fn_id $args -> $ret}
                 )*
             )*
         }
diff --git a/soroban-env-guest/src/guest.rs b/soroban-env-guest/src/guest.rs
index adeefc9d..3f928374 100644
--- a/soroban-env-guest/src/guest.rs
+++ b/soroban-env-guest/src/guest.rs
@@ -181,6 +181,14 @@ impl EnvBase for Guest {
         let vals_lm_len: U32Val = Val::from_u32(vals.len() as u32);
         self.log_from_linear_memory(msg_lm_pos, msg_lm_len, vals_lm_pos, vals_lm_len)
     }
+
+    fn check_protocol_version_lower_bound(&self, _lower_bound: u32) -> Result<(), Self::Error> {
+        core::arch::wasm32::unreachable()
+    }
+
+    fn check_protocol_version_upper_bound(&self, _upper_bound: u32) -> Result<(), Self::Error> {
+        core::arch::wasm32::unreachable()
+    }
 }
 
 ///////////////////////////////////////////////////////////////////////////////
@@ -228,7 +236,7 @@ macro_rules! impl_env_for_guest {
                     // pattern-repetition matcher so that it will match all such
                     // descriptions.
                     $(#[$fn_attr:meta])*
-                    { $fn_str:literal, fn $fn_id:ident $args:tt -> $ret:ty }
+                    { $fn_str:literal, $($min_proto:literal)?, $($max_proto:literal)?, fn $fn_id:ident $args:tt -> $ret:ty }
                 )*
             }
         )*
@@ -276,7 +284,9 @@ call_macro_with_all_host_functions! { impl_env_for_guest }
 // unsafe extern function).
 macro_rules! extern_function_helper {
     {
-        $fn_str:literal, $(#[$attr:meta])* fn $fn_id:ident($($arg:ident:$type:ty),*) -> $ret:ty
+        $fn_str:literal, $($min_proto:literal)?, $($max_proto:literal)?,
+        $(#[$attr:meta])*
+        fn $fn_id:ident($($arg:ident:$type:ty),*) -> $ret:ty
     }
     =>
     {
@@ -307,7 +317,7 @@ macro_rules! generate_extern_modules {
                     // pattern-repetition matcher so that it will match all such
                     // descriptions.
                     $(#[$fn_attr:meta])*
-                    { $fn_str:literal, fn $fn_id:ident $args:tt -> $ret:ty }
+                    { $fn_str:literal, $($min_proto:literal)?, $($max_proto:literal)?, fn $fn_id:ident $args:tt -> $ret:ty }
                 )*
             }
         )*
@@ -341,7 +351,7 @@ macro_rules! generate_extern_modules {
                         // one `$()*` pattern-repetition expander so that it
                         // repeats only for the part of each mod that the
                         // corresponding pattern-repetition matcher.
-                        extern_function_helper!{$fn_str, $(#[$fn_attr])* fn $fn_id $args -> $ret}
+                        extern_function_helper!{$fn_str, $($min_proto)?, $($max_proto)?, $(#[$fn_attr])* fn $fn_id $args -> $ret}
                     )*
                 }
             }
diff --git a/soroban-env-host/src/budget/dimension.rs b/soroban-env-host/src/budget/dimension.rs
index c9796fb9..326b9d66 100644
--- a/soroban-env-host/src/budget/dimension.rs
+++ b/soroban-env-host/src/budget/dimension.rs
@@ -4,6 +4,7 @@ use crate::{Error, HostError};
 use core::fmt::Debug;
 
 /// Helper types to annotate boolean function arguments
+#[allow(dead_code)]
 pub(crate) struct IsCpu(pub(crate) bool);
 pub(crate) struct IsShadowMode(pub(crate) bool);
 
diff --git a/soroban-env-host/src/host.rs b/soroban-env-host/src/host.rs
index 67180bfd..33b8aecc 100644
--- a/soroban-env-host/src/host.rs
+++ b/soroban-env-host/src/host.rs
@@ -956,6 +956,38 @@ impl EnvBase for Host {
         call_trace_env_ret!(self, res);
         res
     }
+
+    fn check_protocol_version_lower_bound(&self, lower: u32) -> Result<(), Self::Error> {
+        self.with_ledger_info(|li| {
+            let proto = li.protocol_version;
+            if proto < lower {
+                Err(self.err(
+                    ScErrorType::Context,
+                    ScErrorCode::IndexBounds,
+                    "ledger protocol {} is less than specified lower bound {}",
+                    &[Val::from_u32(proto).into(), Val::from_u32(lower).into()],
+                ))
+            } else {
+                Ok(())
+            }
+        })
+    }
+
+    fn check_protocol_version_upper_bound(&self, upper: u32) -> Result<(), Self::Error> {
+        self.with_ledger_info(|li| {
+            let proto = li.protocol_version;
+            if proto > upper {
+                Err(self.err(
+                    ScErrorType::Context,
+                    ScErrorCode::IndexBounds,
+                    "ledger protocol {} is larger than specified upper bound {}",
+                    &[Val::from_u32(proto).into(), Val::from_u32(upper).into()],
+                ))
+            } else {
+                Ok(())
+            }
+        })
+    }
 }
 
 impl VmCallerEnv for Host {
@@ -2706,6 +2738,13 @@ impl VmCallerEnv for Host {
         Ok(().into())
     }
 
+    fn protocol_gated_dummy(
+        &self,
+        _vmcaller: &mut VmCaller<Self::VmUserState>,
+    ) -> Result<Val, Self::Error> {
+        Ok(().into())
+    }
+
     // endregion: "test" module functions
     // region: "address" module functions
 
diff --git a/soroban-env-host/src/vm.rs b/soroban-env-host/src/vm.rs
index 2fa18d17..a469d1cb 100644
--- a/soroban-env-host/src/vm.rs
+++ b/soroban-env-host/src/vm.rs
@@ -14,6 +14,8 @@ mod func_info;
 
 #[cfg(feature = "bench")]
 pub(crate) use dispatch::dummy0;
+#[cfg(test)]
+pub(crate) use dispatch::protocol_gated_dummy;
 
 use crate::{
     budget::{get_wasmi_config, AsBudget, Budget},
@@ -151,7 +153,7 @@ impl Vm {
         Ok(())
     }
 
-    fn check_meta_section(host: &Host, m: &Module) -> Result<(), HostError> {
+    fn check_meta_section(host: &Host, m: &Module) -> Result<u64, HostError> {
         if let Some(env_meta) = Self::module_custom_section(m, meta::ENV_META_V0_SECTION_NAME) {
             let mut limits = DEFAULT_XDR_RW_LIMITS;
             limits.len = env_meta.len();
@@ -159,7 +161,8 @@ impl Vm {
             if let Some(env_meta_entry) = ScEnvMetaEntry::read_xdr_iter(&mut cursor).next() {
                 let ScEnvMetaEntry::ScEnvMetaKindInterfaceVersion(v) =
                     host.map_err(env_meta_entry)?;
-                Vm::check_contract_interface_version(host, v)
+                Vm::check_contract_interface_version(host, v)?;
+                Ok(v)
             } else {
                 Err(host.err(
                     ScErrorType::WasmVm,
@@ -216,7 +219,8 @@ impl Vm {
         };
 
         Self::check_max_args(host, &module)?;
-        Self::check_meta_section(host, &module)?;
+        let interface_version = Self::check_meta_section(host, &module)?;
+        let contract_proto = get_ledger_protocol_version(interface_version);
 
         let mut store = Store::new(&engine, host.clone());
         store.limiter(|host| host);
@@ -224,8 +228,39 @@ impl Vm {
         let mut linker = <Linker<Host>>::new(&engine);
 
         {
+            // We perform link-time protocol version gating here.
+            // Reasons for doing link-time instead of run-time check:
+            // 1. VM instantiation is performed in both contract upload and
+            //    execution, thus any errorous contract will be rejected at
+            //    upload time.
+            // 2. If a contract contains a call to an outdated host function,
+            //    i.e. `contract_protocol > hf.max_supported_protocol`, failing
+            //    early is preferred from resource usage perspective.
+            // 3. If a contract contains a call to an non-existent host
+            //    function, the current (correct) behavior is to return
+            //    `Wasmi::LinkerError::MissingDefinition` error (which gets
+            //    converted to a `(WasmVm, InvalidAction)`). If that host
+            //    function is defined in a later protocol, and we replay that
+            //    contract (in the earlier protocol where it belongs), not
+            //    linking the function preserves the right behavior and error
+            //    code.
             let _span0 = tracy_span!("define host functions");
+            let ledger_proto = host.with_ledger_info(|li| Ok(li.protocol_version))?;
             for hf in HOST_FUNCTIONS {
+                if let Some(min_proto) = hf.min_proto {
+                    if contract_proto < min_proto || ledger_proto < min_proto {
+                        // We skip linking this hf instead of returning an error
+                        // because we have to support old contracts during replay.
+                        continue;
+                    }
+                }
+                if let Some(max_proto) = hf.max_proto {
+                    if contract_proto > max_proto || ledger_proto > max_proto {
+                        // We skip linking this hf instead of returning an error
+                        // because we have to support old contracts during replay.
+                        continue;
+                    }
+                }
                 let func = (hf.wrap)(&mut store);
                 host.map_err(
                     linker
diff --git a/soroban-env-host/src/vm/dispatch.rs b/soroban-env-host/src/vm/dispatch.rs
index 0b16cf25..460c4c28 100644
--- a/soroban-env-host/src/vm/dispatch.rs
+++ b/soroban-env-host/src/vm/dispatch.rs
@@ -150,7 +150,7 @@ macro_rules! generate_dispatch_functions {
                     // pattern-repetition matcher so that it will match all such
                     // descriptions.
                     $(#[$fn_attr:meta])*
-                    { $fn_str:literal, fn $fn_id:ident ($($arg:ident:$type:ty),*) -> $ret:ty }
+                    { $fn_str:literal, $($min_proto:literal)?, $($max_proto:literal)?, fn $fn_id:ident ($($arg:ident:$type:ty),*) -> $ret:ty }
                 )*
             }
         )*
@@ -198,6 +198,14 @@ macro_rules! generate_dispatch_functions {
 
                     let host = caller.data().clone();
 
+                    // This is an additional protocol version guardrail that
+                    // should not be necessary. Any wasm contract containing a
+                    // call to an out-of-protocol-range host function should
+                    // have been rejected by the linker during VM instantiation.
+                    // This is just an additional guard rail for future proof.
+                    $( host.check_protocol_version_lower_bound($min_proto)?; )?
+                    $( host.check_protocol_version_upper_bound($max_proto)?; )?
+
                     if host.tracing_enabled()
                     {
                         #[allow(unused)]
diff --git a/soroban-env-host/src/vm/func_info.rs b/soroban-env-host/src/vm/func_info.rs
index f9bdc033..48123d70 100644
--- a/soroban-env-host/src/vm/func_info.rs
+++ b/soroban-env-host/src/vm/func_info.rs
@@ -15,6 +15,51 @@ pub(crate) struct HostFuncInfo {
     /// for this host function, with the specific type of the dispatch function,
     /// into a Func in the Store.
     pub(crate) wrap: fn(&mut Store<Host>) -> Func,
+
+    /// Minimal supported protocol version of this host function
+    pub(crate) min_proto: Option<u32>,
+
+    /// Maximum supported protocol version of this host function
+    pub(crate) max_proto: Option<u32>,
+}
+
+macro_rules! host_function_info_helper {
+    {$mod_str:literal, $fn_id:literal, $func_id:ident, $min_proto:literal, $max_proto:literal} => {
+        HostFuncInfo {
+            mod_str: $mod_str,
+            fn_str: $fn_id,
+            wrap: |store| Func::wrap(store, dispatch::$func_id),
+            min_proto: Some($min_proto),
+            max_proto: Some($max_proto),
+        }
+    };
+    {$mod_str:literal, $fn_id:literal, $func_id:ident, $min_proto:literal, } => {
+        HostFuncInfo {
+            mod_str: $mod_str,
+            fn_str: $fn_id,
+            wrap: |store| Func::wrap(store, dispatch::$func_id),
+            min_proto: Some($min_proto),
+            max_proto: None,
+        }
+    };
+    {$mod_str:literal, $fn_id:literal, $func_id:ident, , $max_proto:literal} => {
+        HostFuncInfo {
+            mod_str: $mod_str,
+            fn_str: $fn_id,
+            wrap: |store| Func::wrap(store, dispatch::$func_id),
+            min_proto: None,
+            max_proto: Some($max_proto),
+        }
+    };
+    {$mod_str:literal, $fn_id:literal, $func_id:ident, , } => {
+        HostFuncInfo {
+            mod_str: $mod_str,
+            fn_str: $fn_id,
+            wrap: |store| Func::wrap(store, dispatch::$func_id),
+            min_proto: None,
+            max_proto: None,
+        }
+    };
 }
 
 ///////////////////////////////////////////////////////////////////////////////
@@ -41,7 +86,7 @@ macro_rules! generate_host_function_infos {
                     // pattern-repetition matcher so that it will match all such
                     // descriptions.
                     $(#[$fn_attr:meta])*
-                    { $fn_id:literal, fn $func_id:ident $args:tt -> $ret:ty }
+                    { $fn_id:literal, $($min_proto:literal)?, $($max_proto:literal)?, fn $func_id:ident $args:tt -> $ret:ty }
                 )*
             }
         )*
@@ -74,11 +119,7 @@ macro_rules! generate_host_function_infos {
                     // block repetition-level from the outer pattern in the
                     // expansion, flattening all functions from all 'mod' blocks
                     // into the a single array of HostFuncInfo structs.
-                    HostFuncInfo {
-                        mod_str: $mod_str,
-                        fn_str: $fn_id,
-                        wrap: |store| Func::wrap(store, dispatch::$func_id),
-                    },
+                    host_function_info_helper!{$mod_str, $fn_id, $func_id, $($min_proto)?, $($max_proto)?},
                 )*
             )*
         ];
diff --git a/soroban-env-macros/src/call_macro_with_all_host_functions.rs b/soroban-env-macros/src/call_macro_with_all_host_functions.rs
index a1926297..8a0296c5 100644
--- a/soroban-env-macros/src/call_macro_with_all_host_functions.rs
+++ b/soroban-env-macros/src/call_macro_with_all_host_functions.rs
@@ -81,6 +81,8 @@ pub fn generate(file_lit: LitStr) -> Result<TokenStream, Error> {
             let docs = f.docs.as_deref().unwrap_or_default();
             let export = &f.export;
             let name = format_ident!("{}", &f.name);
+            let min_proto = f.min_supported_protocol;
+            let max_proto = f.max_supported_protocol;
 
             // Build the args for use within the 'fn'.
             let args = f.args.iter().map(|a| {
@@ -93,7 +95,7 @@ pub fn generate(file_lit: LitStr) -> Result<TokenStream, Error> {
 
             quote! {
                 #[doc = #docs]
-                { #export, fn #name(#(#args),*) -> #r#return }
+                { #export, #min_proto, #max_proto, fn #name(#(#args),*) -> #r#return }
             }
         });
 
@@ -120,7 +122,7 @@ pub fn generate(file_lit: LitStr) -> Result<TokenStream, Error> {
                     //
                     //  mod $mod_id:ident $mod_str:literal {
                     //     ...
-                    //     { $fn_str:literal, fn $fn_id:ident $args:tt -> $ret:ty }
+                    //     { $fn_str:literal, $min_proto:literal, $max_proto:literal, fn $fn_id:ident $args:tt -> $ret:ty }
                     //     ...
                     //  }
                     //
diff --git a/soroban-env-macros/src/lib.rs b/soroban-env-macros/src/lib.rs
index cf2072e7..6931da1b 100644
--- a/soroban-env-macros/src/lib.rs
+++ b/soroban-env-macros/src/lib.rs
@@ -99,6 +99,8 @@ pub(crate) struct Function {
     pub(crate) args: Vec<Arg>,
     pub(crate) r#return: String,
     pub(crate) docs: Option<String>,
+    pub(crate) min_supported_protocol: Option<u32>,
+    pub(crate) max_supported_protocol: Option<u32>,
 }
 
 #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
diff --git a/soroban-synth-wasm/src/host_funcs.rs b/soroban-synth-wasm/src/host_funcs.rs
index 61bb3b9c..7641c145 100644
--- a/soroban-synth-wasm/src/host_funcs.rs
+++ b/soroban-synth-wasm/src/host_funcs.rs
@@ -35,7 +35,7 @@ macro_rules! generate_call_emitter_functions {
                     // pattern-repetition matcher so that it will match all such
                     // descriptions.
                     $(#[$fn_attr:meta])*
-                    { $fn_str:literal, fn $fn_id:ident ($($arg:ident:$type:ty),*) -> $ret:ty }
+                    { $fn_str:literal, $($min_proto:literal)?, $($max_proto:literal)?, fn $fn_id:ident ($($arg:ident:$type:ty),*) -> $ret:ty }
                 )*
             }
         )*
@@ -44,8 +44,10 @@ macro_rules! generate_call_emitter_functions {
     =>  // The part of the macro above this line is a matcher; below is its expansion.
 
     {
-        // This macro expands to a single item: an impl block with a set of methods
-        // for emitting wasm that calls host functions.
+        // This macro expands to a single item: an impl block with a set of
+        // methods for emitting wasm that calls host functions. We do not
+        // perform any protocol version checks here, since this module is for
+        // synthesizing test wasms calling host functions.
         impl FuncEmitter
         {
         $(


Test patch:
diff --git a/soroban-env-host/observations/test__hostile__test_corrupt_custom_section.json b/soroban-env-host/observations/test__hostile__test_corrupt_custom_section.json
index 22654e10..78f4ae36 100644
--- a/soroban-env-host/observations/test__hostile__test_corrupt_custom_section.json
+++ b/soroban-env-host/observations/test__hostile__test_corrupt_custom_section.json
@@ -13,16 +13,16 @@
   "  11 call upload_wasm(Bytes(obj#5))": "",
   "  12 ret upload_wasm -> Err(Error(WasmVm, InvalidInput))": "cpu:1402288, mem:393618",
   "  13 call bytes_new_from_slice(40)": "cpu:1402335",
-  "  14 ret bytes_new_from_slice -> Ok(Bytes(obj#7))": "cpu:1403306, mem:393738, objs:-/4@c897768d",
+  "  14 ret bytes_new_from_slice -> Ok(Bytes(obj#7))": "cpu:1403306, mem:393738, objs:-/4@523eb858",
   "  15 call upload_wasm(Bytes(obj#7))": "",
   "  16 ret upload_wasm -> Err(Error(WasmVm, InvalidInput))": "cpu:1876278, mem:525489",
   "  17 call bytes_new_from_slice(40)": "cpu:1876325",
-  "  18 ret bytes_new_from_slice -> Ok(Bytes(obj#9))": "cpu:1877296, mem:525609, objs:-/5@685ce86b",
+  "  18 ret bytes_new_from_slice -> Ok(Bytes(obj#9))": "cpu:1877296, mem:525609, objs:-/5@a388daeb",
   "  19 call upload_wasm(Bytes(obj#9))": "",
   "  20 ret upload_wasm -> Err(Error(WasmVm, InvalidInput))": "cpu:2350268, mem:657360",
   "  21 call bytes_new_from_slice(40)": "cpu:2350315",
-  "  22 ret bytes_new_from_slice -> Ok(Bytes(obj#11))": "cpu:2351286, mem:657480, objs:-/6@b8b5123",
+  "  22 ret bytes_new_from_slice -> Ok(Bytes(obj#11))": "cpu:2351286, mem:657480, objs:-/6@6edee4ba",
   "  23 call upload_wasm(Bytes(obj#11))": "",
   "  24 ret upload_wasm -> Err(Error(WasmVm, InvalidInput))": "cpu:2824258, mem:789231",
-  "  25 end": "cpu:2824258, mem:789231, prngs:-/9b4a753, objs:-/6@b8b5123, vm:-/-, evt:-, store:-/-, foot:-, stk:-, auth:-/-"
+  "  25 end": "cpu:2824258, mem:789231, prngs:-/9b4a753, objs:-/6@6edee4ba, vm:-/-, evt:-, store:-/-, foot:-, stk:-, auth:-/-"
 }
\ No newline at end of file
diff --git a/soroban-env-host/observations/test__protocol_gate__ledger_protocol_greater_than_env_protocol_should_fail.json b/soroban-env-host/observations/test__protocol_gate__ledger_protocol_greater_than_env_protocol_should_fail.json
new file mode 100644
index 00000000..97e84740
--- /dev/null
+++ b/soroban-env-host/observations/test__protocol_gate__ledger_protocol_greater_than_env_protocol_should_fail.json
@@ -0,0 +1,8 @@
+{
+  "   0 begin": "cpu:14488, mem:0, prngs:-/9b4a753, objs:-/-, vm:-/-, evt:-, store:-/-, foot:-, stk:-, auth:-/-",
+  "   1 call bytes_new_from_slice(80)": "cpu:14535",
+  "   2 ret bytes_new_from_slice -> Ok(Bytes(obj#1))": "cpu:15516, mem:160, objs:-/1@6105f6e8",
+  "   3 call upload_wasm(Bytes(obj#1))": "",
+  "   4 ret upload_wasm -> Err(Error(Context, InternalError))": "cpu:504878, mem:133534",
+  "   5 end": "cpu:504878, mem:133534, prngs:-/9b4a753, objs:-/1@6105f6e8, vm:-/-, evt:-, store:-/-, foot:-, stk:-, auth:-/-"
+}
\ No newline at end of file
diff --git a/soroban-env-host/observations/test__protocol_gate__test_additional_protocol_guardrail_during_invocation.json b/soroban-env-host/observations/test__protocol_gate__test_additional_protocol_guardrail_during_invocation.json
new file mode 100644
index 00000000..d8a110ed
--- /dev/null
+++ b/soroban-env-host/observations/test__protocol_gate__test_additional_protocol_guardrail_during_invocation.json
@@ -0,0 +1,6 @@
+{
+  "   0 begin": "cpu:14488, mem:0, prngs:-/9b4a753, objs:-/-, vm:-/-, evt:-, store:-/-, foot:-, stk:-, auth:-/-",
+  "   1 call protocol_gated_dummy()": "",
+  "   2 ret protocol_gated_dummy -> Ok(Void)": "cpu:14810",
+  "   3 end": "cpu:14810, mem:0, prngs:-/9b4a753, objs:-/-, vm:-/-, evt:-, store:-/-, foot:-, stk:-, auth:-/-"
+}
\ No newline at end of file
diff --git a/soroban-env-host/observations/test__protocol_gate__test_host_protocol_gating_for_wasm.json b/soroban-env-host/observations/test__protocol_gate__test_host_protocol_gating_for_wasm.json
new file mode 100644
index 00000000..15919362
--- /dev/null
+++ b/soroban-env-host/observations/test__protocol_gate__test_host_protocol_gating_for_wasm.json
@@ -0,0 +1,38 @@
+{
+  "   0 begin": "cpu:14488, mem:0, prngs:-/9b4a753, objs:-/-, vm:-/-, evt:-, store:-/-, foot:-, stk:-, auth:-/-",
+  "   1 call bytes_new_from_slice(80)": "cpu:14535",
+  "   2 ret bytes_new_from_slice -> Ok(Bytes(obj#1))": "cpu:15516, mem:160, objs:-/1@f7fca391",
+  "   3 call upload_wasm(Bytes(obj#1))": "",
+  "   4 ret upload_wasm -> Err(Error(WasmVm, InvalidAction))": "cpu:504878, mem:133534",
+  "   5 call bytes_new_from_slice(80)": "cpu:504925",
+  "   6 ret bytes_new_from_slice -> Ok(Bytes(obj#3))": "cpu:505906, mem:133694, objs:-/2@3906a726",
+  "   7 call upload_wasm(Bytes(obj#3))": "",
+  "   8 ret upload_wasm -> Err(Error(WasmVm, InvalidAction))": "cpu:995268, mem:267068",
+  "   9 call bytes_new_from_slice(80)": "cpu:995315",
+  "  10 ret bytes_new_from_slice -> Ok(Bytes(obj#5))": "cpu:996296, mem:267228, objs:-/3@e4dbdc61",
+  "  11 call upload_wasm(Bytes(obj#5))": "",
+  "  12 ret upload_wasm -> Ok(Bytes(obj#7))": "cpu:1490848, mem:401372, objs:-/4@4ac3bfa9, store:-/1@1a0e9b81, foot:1@528fd006",
+  "  13 call bytes_new_from_slice(32)": "cpu:1491288, mem:401436, objs:-/5@6a4850f0",
+  "  14 ret bytes_new_from_slice -> Ok(Bytes(obj#11))": "cpu:1492257, mem:401548, objs:-/6@dc239eb6",
+  "  15 call create_contract(Address(obj#9), Bytes(obj#7), Bytes(obj#11))": "",
+  "  16 call obj_cmp(Address(obj#13), Address(obj#9))": "cpu:1493900, mem:401726, objs:-/7@cb0ab20f, auth:1@9f16c057/-",
+  "  17 ret obj_cmp -> Ok(0)": "cpu:1494192",
+  "  18 call get_ledger_network_id()": "cpu:1494242, auth:1@9f16c057/1@82b76fa7",
+  "  19 ret get_ledger_network_id -> Ok(Bytes(obj#15))": "cpu:1495272, mem:401838, objs:-/8@e6d133a3",
+  "  20 ret create_contract -> Ok(Address(obj#17))": "cpu:1513405, mem:404912, objs:-/9@992d388f, store:-/2@dc3a5709, foot:2@ed32f193, auth:-/1@aebeac48",
+  "  21 call call(Address(obj#17), Symbol(test), Vec(obj#19))": "cpu:1514366, mem:404992, objs:-/10@9c15842c, auth:-/-",
+  "  22 push VM:a98ace28:test()": "cpu:2006415, mem:539598, objs:-/11@3b5daa87, vm:-/1@32eec24c, stk:1@9ed5379c, auth:1@b1b428e/-",
+  "  23 call protocol_gated_dummy()": "cpu:2008884, mem:539628, vm:-/-",
+  "  24 ret protocol_gated_dummy -> Ok(Void)": "cpu:2009470",
+  "  25 pop VM:a98ace28:test -> Ok(Void)": " vm:-/1@32eec24c",
+  "  26 ret call -> Ok(Void)": " vm:-/-, stk:-, auth:-/-",
+  "  27 call bytes_new_from_slice(80)": "cpu:2009517",
+  "  28 ret bytes_new_from_slice -> Ok(Bytes(obj#23))": "cpu:2010498, mem:539788, objs:-/12@fbaeb755",
+  "  29 call upload_wasm(Bytes(obj#23))": "",
+  "  30 ret upload_wasm -> Err(Error(WasmVm, InvalidAction))": "cpu:2499860, mem:673162",
+  "  31 call bytes_new_from_slice(80)": "cpu:2499907",
+  "  32 ret bytes_new_from_slice -> Ok(Bytes(obj#25))": "cpu:2500888, mem:673322, objs:-/13@aa6c2723",
+  "  33 call upload_wasm(Bytes(obj#25))": "",
+  "  34 ret upload_wasm -> Err(Error(WasmVm, InvalidAction))": "cpu:2990250, mem:806696",
+  "  35 end": "cpu:2990250, mem:806696, prngs:-/9b4a753, objs:-/13@aa6c2723, vm:-/-, evt:-, store:-/2@dc3a5709, foot:2@ed32f193, stk:-, auth:-/-"
+}
\ No newline at end of file
diff --git a/soroban-env-host/observations/test__protocol_gate__test_native_mode_calling_protocol_gated_host_fn.json b/soroban-env-host/observations/test__protocol_gate__test_native_mode_calling_protocol_gated_host_fn.json
new file mode 100644
index 00000000..6fe8a562
--- /dev/null
+++ b/soroban-env-host/observations/test__protocol_gate__test_native_mode_calling_protocol_gated_host_fn.json
@@ -0,0 +1,6 @@
+{
+  "   0 begin": "cpu:14488, mem:0, prngs:-/9b4a753, objs:-/-, vm:-/-, evt:-, store:-/-, foot:-, stk:-, auth:-/-",
+  "   1 call protocol_gated_dummy()": "",
+  "   2 ret protocol_gated_dummy -> Ok(Void)": "",
+  "   3 end": "cpu:14488, mem:0, prngs:-/9b4a753, objs:-/-, vm:-/-, evt:-, store:-/-, foot:-, stk:-, auth:-/-"
+}
\ No newline at end of file
diff --git a/soroban-env-host/observations/test__protocol_gate__wasm_protocol_greater_than_ledger_protocol_should_fail.json b/soroban-env-host/observations/test__protocol_gate__wasm_protocol_greater_than_ledger_protocol_should_fail.json
new file mode 100644
index 00000000..4ddcb5d8
--- /dev/null
+++ b/soroban-env-host/observations/test__protocol_gate__wasm_protocol_greater_than_ledger_protocol_should_fail.json
@@ -0,0 +1,8 @@
+{
+  "   0 begin": "cpu:14488, mem:0, prngs:-/9b4a753, objs:-/-, vm:-/-, evt:-, store:-/-, foot:-, stk:-, auth:-/-",
+  "   1 call bytes_new_from_slice(80)": "cpu:14535",
+  "   2 ret bytes_new_from_slice -> Ok(Bytes(obj#1))": "cpu:15516, mem:160, objs:-/1@6105f6e8",
+  "   3 call upload_wasm(Bytes(obj#1))": "",
+  "   4 ret upload_wasm -> Err(Error(WasmVm, InvalidInput))": "cpu:504878, mem:133534",
+  "   5 end": "cpu:504878, mem:133534, prngs:-/9b4a753, objs:-/1@6105f6e8, vm:-/-, evt:-, store:-/-, foot:-, stk:-, auth:-/-"
+}
\ No newline at end of file
diff --git a/soroban-env-host/src/test.rs b/soroban-env-host/src/test.rs
index 080b3525..c8d5c0df 100644
--- a/soroban-env-host/src/test.rs
+++ b/soroban-env-host/src/test.rs
@@ -27,6 +27,7 @@ mod metering_benchmark;
 mod num;
 mod post_mvp;
 mod prng;
+mod protocol_gate;
 mod stellar_asset_contract;
 mod storage;
 mod str;
diff --git a/soroban-env-host/src/test/hostile.rs b/soroban-env-host/src/test/hostile.rs
index 6386abc1..e6873adf 100644
--- a/soroban-env-host/src/test/hostile.rs
+++ b/soroban-env-host/src/test/hostile.rs
@@ -3,16 +3,15 @@ use crate::{
     host_object::HostVec,
     meta,
     storage::Storage,
-    testutils::{generate_account_id, generate_bytes_array, wasm as wasm_util},
-    xdr::{
-        AccountId, ContractCostType, Limited, Limits, PublicKey, ScEnvMetaEntry, ScErrorCode,
-        ScErrorType, Uint256, WriteXdr,
+    testutils::{
+        generate_account_id, generate_bytes_array, interface_meta_with_custom_versions,
+        wasm as wasm_util,
     },
-    DiagnosticLevel, Env, EnvBase, Error, Host, HostError, Symbol, SymbolSmall, Tag, Val,
-    VecObject,
+    xdr::{AccountId, ContractCostType, PublicKey, ScErrorCode, ScErrorType, Uint256},
+    DiagnosticLevel, Env, EnvBase, Error, Host, HostError, Symbol, SymbolSmall, Tag, TryIntoVal,
+    Val, VecObject,
 };
 use expect_test::expect;
-use soroban_env_common::TryIntoVal;
 use soroban_test_wasms::HOSTILE;
 
 #[test]
@@ -730,7 +729,6 @@ fn test_integer_overflow() -> Result<(), HostError> {
 
 #[test]
 fn test_corrupt_custom_section() -> Result<(), HostError> {
-    // let wasm = wasm_util::wasm_module_with_custom_section("custom", vec![24; 7].as_slice());
     let host = observe_host!(Host::test_host_with_recording_footprint());
     host.enable_debug()?;
     host.as_budget().reset_unlimited()?;
@@ -773,22 +771,13 @@ fn test_corrupt_custom_section() -> Result<(), HostError> {
     ));
 
     let ledger_protocol = meta::get_ledger_protocol_version(meta::INTERFACE_VERSION);
-    let ledger_pre = meta::get_ledger_protocol_version(meta::INTERFACE_VERSION);
-
-    let interface_meta = |proto: u32, pre: u32| -> Vec<u8> {
-        let iv = (proto as u64) << 32 | pre as u64;
-        let entry = ScEnvMetaEntry::ScEnvMetaKindInterfaceVersion(iv);
-        let bytes = Vec::<u8>::new();
-        let mut w = Limited::new(bytes, Limits::none());
-        entry.write_xdr(&mut w).unwrap();
-        w.inner
-    };
+    let ledger_pre = meta::get_pre_release_version(meta::INTERFACE_VERSION);
 
     // invalid: protocol is future
     let res = host.register_test_contract_wasm_from_source_account(
         wasm_util::wasm_module_with_custom_section(
             "contractenvmetav0",
-            interface_meta(ledger_protocol + 1, ledger_pre).as_slice(),
+            interface_meta_with_custom_versions(ledger_protocol + 1, ledger_pre).as_slice(),
         )
         .as_slice(),
         generate_account_id(&host),
@@ -804,7 +793,7 @@ fn test_corrupt_custom_section() -> Result<(), HostError> {
         let res = host.register_test_contract_wasm_from_source_account(
             wasm_util::wasm_module_with_custom_section(
                 "contractenvmetav0",
-                interface_meta(ledger_protocol - 1, 1).as_slice(),
+                interface_meta_with_custom_versions(ledger_protocol - 1, 1).as_slice(),
             )
             .as_slice(),
             generate_account_id(&host),
@@ -819,7 +808,7 @@ fn test_corrupt_custom_section() -> Result<(), HostError> {
         let res = host.register_test_contract_wasm_from_source_account(
             wasm_util::wasm_module_with_custom_section(
                 "contractenvmetav0",
-                interface_meta(ledger_protocol, ledger_pre + 1).as_slice(),
+                interface_meta_with_custom_versions(ledger_protocol, ledger_pre + 1).as_slice(),
             )
             .as_slice(),
             generate_account_id(&host),
diff --git a/soroban-env-host/src/test/protocol_gate.rs b/soroban-env-host/src/test/protocol_gate.rs
new file mode 100644
index 00000000..9fc072a5
--- /dev/null
+++ b/soroban-env-host/src/test/protocol_gate.rs
@@ -0,0 +1,299 @@
+use crate::{
+    meta::{get_ledger_protocol_version, INTERFACE_VERSION},
+    testutils::{generate_account_id, generate_bytes_array, wasm as wasm_util},
+    xdr::{ScErrorCode, ScErrorType},
+    AddressObject, Env, Host, HostError, LedgerInfo, Symbol, Val, WasmiMarshal,
+};
+
+#[test]
+fn ledger_protocol_greater_than_env_protocol_should_fail() -> Result<(), HostError> {
+    let host = observe_host!(Host::test_host_with_recording_footprint());
+    host.enable_debug()?;
+    let mut li = LedgerInfo::default();
+    let env_proto = get_ledger_protocol_version(INTERFACE_VERSION);
+    li.protocol_version = env_proto + 1;
+    host.set_ledger_info(li)?;
+    let wasm = wasm_util::wasm_module_calling_protocol_gated_host_fn(env_proto);
+    let id = host.register_test_contract_wasm_from_source_account(
+        wasm.as_slice(),
+        generate_account_id(&host),
+        generate_bytes_array(&host),
+    );
+    assert!(HostError::result_matches_err(
+        id,
+        (ScErrorType::Context, ScErrorCode::InternalError)
+    ));
+    Ok(())
+}
+
+#[test]
+fn wasm_protocol_greater_than_ledger_protocol_should_fail() -> Result<(), HostError> {
+    let host = observe_host!(Host::test_host_with_recording_footprint());
+    host.enable_debug()?;
+    let mut li = LedgerInfo::default();
+    let env_proto = get_ledger_protocol_version(INTERFACE_VERSION);
+    li.protocol_version = env_proto - 1;
+    host.set_ledger_info(li)?;
+    let wasm = wasm_util::wasm_module_calling_protocol_gated_host_fn(env_proto);
+    let id = host.register_test_contract_wasm_from_source_account(
+        wasm.as_slice(),
+        generate_account_id(&host),
+        generate_bytes_array(&host),
+    );
+    assert!(HostError::result_matches_err(
+        id,
+        (ScErrorType::WasmVm, ScErrorCode::InvalidInput)
+    ));
+    Ok(())
+}
+
+fn configure_ledger_and_wasm_for_protocol_test(
+    host: &Host,
+    ledger_proto: u32,
+    wasm_proto: u32,
+) -> Result<AddressObject, HostError> {
+    host.enable_debug()?;
+    let mut li = LedgerInfo::default();
+    li.protocol_version = ledger_proto;
+    host.set_ledger_info(li)?;
+    let wasm = wasm_util::wasm_module_calling_protocol_gated_host_fn(wasm_proto);
+    host.register_test_contract_wasm_from_source_account(
+        wasm.as_slice(),
+        generate_account_id(&host),
+        generate_bytes_array(&host),
+    )
+}
+
+#[test]
+fn test_host_protocol_gating_for_wasm() -> Result<(), HostError> {
+    let host = observe_host!(Host::test_host_with_recording_footprint());
+
+    // 1. The contract calls a host function that is undefined for its protocol
+    //    (18), but defined in a future protocol (19). The contract is
+    //    **current** to the ledger protocol version. In this case a
+    //    `Wasmi::LinkerError::MissingDefinition` i.e. `(WasmVm, InvalidAction)`
+    //    should be returned.
+    //
+    // ----------|----------------|----------------|-------------------->
+    //           18               19               20(curr-env)
+    //                            ^ hostfn
+    //           ^ ledger
+    //           ^ wasm
+    //
+    let id = configure_ledger_and_wasm_for_protocol_test(&host, 18, 18);
+    assert!(HostError::result_matches_err(
+        id,
+        (ScErrorType::WasmVm, ScErrorCode::InvalidAction)
+    ));
+
+    // 2. Same as 1, except the contract's version is **previous** to the
+    //    ledger protocol version. This should behave exactly the same as 1.
+    //
+    // ----------|----------------|----------------|-------------------->
+    //           18               19               20(curr-env)
+    //                            ^ hostfn
+    //                            ^ ledger
+    //           ^ wasm
+    //
+    let id = configure_ledger_and_wasm_for_protocol_test(&host, 19, 18);
+    assert!(HostError::result_matches_err(
+        id,
+        (ScErrorType::WasmVm, ScErrorCode::InvalidAction)
+    ));
+
+    // 3. The contract is **current** to the ledger protocol version, both
+    //    matches the hostfn supported version (19). In this case the call
+    //    should be successful.
+    //
+    // ----------|----------------|----------------|-------------------->
+    //           18               19               20(curr-env)
+    //                            ^ hostfn
+    //                            ^ ledger
+    //                            ^ wasm
+    //
+    let id = configure_ledger_and_wasm_for_protocol_test(&host, 19, 19);
+    assert!(id.is_ok());
+    let res = host.call(
+        id?,
+        Symbol::try_from_small_str("test")?,
+        host.test_vec_obj::<u32>(&[])?,
+    );
+    assert!(res.is_ok());
+
+    // 4. The ledger protocol version has moved past the host function supported
+    //    protocol version. In this case, a
+    //    `Wasmi::LinkerError::MissingDefinition` i.e. `(WasmVm, InvalidAction)`
+    //    should be returned.
+    //
+    // ----------|----------------|----------------|-------------------->
+    //           18               19               20(curr-env)
+    //                            ^ hostfn
+    //                                            ^ ledger
+    //                            ^ wasm
+    //
+    let id = configure_ledger_and_wasm_for_protocol_test(&host, 20, 19);
+    assert!(HostError::result_matches_err(
+        id,
+        (ScErrorType::WasmVm, ScErrorCode::InvalidAction)
+    ));
+
+    // 5. The contract is built at a more advanced protocol (20), and it
+    //    includes call to a retired host function (valid at protocol 19). In
+    //    this case, a `Wasmi::LinkerError::MissingDefinition` i.e. `(WasmVm,
+    //    InvalidAction)` should be returned.
+    //
+    // ----------|----------------|----------------|-------------------->
+    //           18               19               20(curr-env)
+    //                            ^ hostfn
+    //                                            ^ ledger
+    //                                            ^ wasm
+    //
+    let id = configure_ledger_and_wasm_for_protocol_test(&host, 20, 20);
+    assert!(HostError::result_matches_err(
+        id,
+        (ScErrorType::WasmVm, ScErrorCode::InvalidAction)
+    ));
+
+    Ok(())
+}
+
+#[test]
+fn test_native_mode_calling_protocol_gated_host_fn() -> Result<(), HostError> {
+    let host = observe_host!(Host::test_host_with_recording_footprint());
+    host.enable_debug()?;
+    let mut li = LedgerInfo::default();
+
+    //
+    // ----------|----------------|----------------|-------------------->
+    //           18               19               20(curr-env)
+    //                            ^ hostfn
+    //           ^ ledger
+    //
+    li.protocol_version = 18;
+    host.set_ledger_info(li.clone())?;
+    let res = <Host as Env>::protocol_gated_dummy(&host);
+    assert!(HostError::result_matches_err(
+        res,
+        (ScErrorType::Context, ScErrorCode::IndexBounds)
+    ));
+
+    //
+    // ----------|----------------|----------------|-------------------->
+    //           18               19               20(curr-env)
+    //                            ^ hostfn
+    //                            ^ ledger
+    //
+    li.protocol_version = 19;
+    host.set_ledger_info(li.clone())?;
+    let res = <Host as Env>::protocol_gated_dummy(&host);
+    assert!(res.is_ok());
+
+    //
+    // ----------|----------------|----------------|-------------------->
+    //           18               19               20(curr-env)
+    //                            ^ hostfn
+    //                                            ^ ledger
+    //
+    li.protocol_version = 20;
+    host.set_ledger_info(li)?;
+    let res = <Host as Env>::protocol_gated_dummy(&host);
+    assert!(HostError::result_matches_err(
+        res,
+        (ScErrorType::Context, ScErrorCode::IndexBounds)
+    ));
+
+    Ok(())
+}
+
+fn configure_protocol_test_for_runtime_guardrail(
+    host: &Host,
+    ledger_proto: u32,
+) -> Result<wasmi::Value, wasmi::Error> {
+    host.enable_debug().unwrap();
+    let mut li = LedgerInfo::default();
+    li.protocol_version = ledger_proto;
+    host.set_ledger_info(li).unwrap();
+    let wasm = wasm_util::wasm_module_calling_protocol_gated_host_fn(ledger_proto);
+    register_and_invoke_custom_vm_no_linker_check(host, wasm.as_slice())
+}
+
+fn register_and_invoke_custom_vm_no_linker_check(
+    host: &Host,
+    wasm_code: &[u8],
+) -> Result<wasmi::Value, wasmi::Error> {
+    use crate::vm::protocol_gated_dummy;
+    use wasmi::{Engine, Func, Linker, Module, Store, Value};
+    let mut config = wasmi::Config::default();
+    config.consume_fuel(true);
+    let engine = Engine::new(&config);
+    let module = Module::new(&engine, wasm_code).unwrap();
+    let mut store = Store::new(&engine, host.clone());
+    store.add_fuel(10000000).unwrap();
+    store.limiter(|host| host);
+    let mut linker = <Linker<Host>>::new(&engine);
+
+    let wrap = |store| Func::wrap(store, protocol_gated_dummy);
+    let func = (wrap)(&mut store);
+    linker.define("t", "0", func).unwrap();
+    let not_started_instance = linker.instantiate(&mut store, &module).unwrap();
+    let instance = not_started_instance.ensure_no_start(&mut store).unwrap();
+
+    let ext = instance.get_export(&mut store, "test").unwrap();
+    let func = ext.into_func().unwrap();
+    let mut wasm_ret: [Value; 1] = [Value::I64(0)];
+    let res = func
+        .call(&mut store, &[], &mut wasm_ret)
+        .map(|_| wasm_ret[0].clone());
+    res
+}
+
+fn fish_host_error_from_wasm_trap(
+    host: &Host,
+    res: Result<wasmi::Value, wasmi::Error>,
+) -> Result<Val, HostError> {
+    res.map(|r| {
+        host.relative_to_absolute(Val::try_marshal_from_value(r).unwrap())
+            .unwrap()
+    })
+    .map_err(|e| match e {
+        wasmi::Error::Trap(t) => t.downcast().unwrap(),
+        _ => panic!(),
+    })
+}
+
+// Tests the additional runtime protocol guardrail during host function
+// dispatch. The runtime protocol gating are put in place as an additional
+// safeguard, so these tests create a VM with link-time checks intentionally
+// turned off.
+#[test]
+fn test_additional_protocol_guardrail_during_invocation() -> Result<(), HostError> {
+    let host = observe_host!(Host::test_host_with_recording_footprint());
+
+    // ----------|----------------|----------------|-------------------->
+    //           18               19               20(curr-env)
+    //                            ^ hostfn
+    //           ^ ledger
+    let res = configure_protocol_test_for_runtime_guardrail(&host, 18);
+    HostError::result_matches_err(
+        fish_host_error_from_wasm_trap(&host, res),
+        (ScErrorType::Context, ScErrorCode::IndexBounds),
+    );
+
+    // ----------|----------------|----------------|-------------------->
+    //           18               19               20(curr-env)
+    //                            ^ hostfn
+    //                            ^ ledger
+    let res = configure_protocol_test_for_runtime_guardrail(&host, 19);
+    assert!(res.is_ok());
+
+    // ----------|----------------|----------------|-------------------->
+    //           18               19               20(curr-env)
+    //                            ^ hostfn
+    //                                            ^ ledger
+    let res = configure_protocol_test_for_runtime_guardrail(&host, 20);
+    HostError::result_matches_err(
+        fish_host_error_from_wasm_trap(&host, res),
+        (ScErrorType::Context, ScErrorCode::IndexBounds),
+    );
+    Ok(())
+}
diff --git a/soroban-env-host/src/testutils.rs b/soroban-env-host/src/testutils.rs
index 047912b9..ec35f4e7 100644
--- a/soroban-env-host/src/testutils.rs
+++ b/soroban-env-host/src/testutils.rs
@@ -145,6 +145,17 @@ impl SnapshotSource for MockSnapshotSource {
     }
 }
 
+#[cfg(test)]
+pub(crate) fn interface_meta_with_custom_versions(proto: u32, pre: u32) -> Vec<u8> {
+    use crate::xdr::{Limited, Limits, ScEnvMetaEntry, WriteXdr};
+    let iv = (proto as u64) << 32 | pre as u64;
+    let entry = ScEnvMetaEntry::ScEnvMetaKindInterfaceVersion(iv);
+    let bytes = Vec::<u8>::new();
+    let mut w = Limited::new(bytes, Limits::none());
+    entry.write_xdr(&mut w).unwrap();
+    w.inner
+}
+
 impl Host {
     pub const TEST_PRNG_SEED: &'static [u8; 32] = b"12345678901234567890123456789012";
 
@@ -525,7 +536,9 @@ impl Host {
 
 #[cfg(test)]
 pub(crate) mod wasm {
+    use super::interface_meta_with_custom_versions;
     use crate::{Symbol, Tag, U32Val, Val};
+    use soroban_env_common::meta::{get_pre_release_version, INTERFACE_VERSION};
     use soroban_synth_wasm::{Arity, FuncRef, LocalRef, ModEmitter, Operand};
     use wasm_encoder::{ConstExpr, Elements, RefType};
 
@@ -1063,4 +1076,19 @@ pub(crate) mod wasm {
         fe.push(Symbol::try_from_small_str("pass").unwrap());
         fe.finish_and_export("test").finish()
     }
+
+    pub(crate) fn wasm_module_calling_protocol_gated_host_fn(wasm_proto: u32) -> Vec<u8> {
+        let mut me = ModEmitter::new();
+        let pre = get_pre_release_version(INTERFACE_VERSION);
+        me.custom_section(
+            &"contractenvmetav0",
+            interface_meta_with_custom_versions(wasm_proto, pre).as_slice(),
+        );
+        // protocol_gated_dummy
+        let f0 = me.import_func("t", "0", Arity(0));
+        // the caller
+        let mut fe = me.func(Arity(0), 0);
+        fe.call_func(f0);
+        fe.finish_and_export("test").finish()
+    }
 }