From 93ee7fbf40dfb08cb990ded8aa23ba29c7eea08c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Miko=C5=82ajczyk?= Date: Mon, 27 Feb 2023 14:36:20 +0100 Subject: [PATCH] Support `call_runtime` (#1641) * Basic contract calling runtime * Offchain setting * Onchain env: reach seal function * Feature-gating * Pass call * Revert - e2e framework doesn't support custom environment * Use just encodable argument * Pass decodable struct (however call is trapped :( ) * Works with 0 transfer * Fix * Docs, remove leftovers * Clippy * docline * Ignore test * typo * CHANGELOG.md, example README.md * Update CHANGELOG.md Co-authored-by: German * Update crates/env/Cargo.toml * Syntactic improvements * Comparison to chain extension * Indices explanation * Reason for sp-io in deps * Rename enum variant. Docs. Return result. Failure e2e * Env API docs * spellcheck * ... * Clean Cargo.toml * Note about unstable host * Remove offline test * Rephrase note * Review * Doc about panic in off-chain env * Add feature instead of ignoring * Add feature instead of ignoring * Error * Apply suggestions from code review Co-authored-by: Sasha Gryaznov Co-authored-by: Hernando Castano * Rename variant error * PAnics * Warnign * #[allow(clippy::enum_variant_names)] * uitest * uitest * Missing testcases * Update examples/call-runtime/lib.rs Co-authored-by: Hernando Castano * Update examples/call-runtime/lib.rs Co-authored-by: Hernando Castano * Bump example version * Fix some nitpicks * Rename error variant * Remove allowance macro * Note * Remove note * Remove example * As integration test * Versions * Fix changelog --------- Co-authored-by: Andrew Jones Co-authored-by: German Co-authored-by: Sasha Gryaznov Co-authored-by: Hernando Castano Co-authored-by: Hernando Castano --- CHANGELOG.md | 4 +- crates/env/Cargo.toml | 3 + crates/env/src/api.rs | 32 +++ crates/env/src/backend.rs | 6 + crates/env/src/engine/off_chain/impls.rs | 8 + crates/env/src/engine/on_chain/ext.rs | 14 +- crates/env/src/engine/on_chain/impls.rs | 12 + crates/env/src/error.rs | 2 + crates/ink/Cargo.toml | 6 + crates/ink/src/env_access.rs | 5 + crates/storage/src/lazy/mapping.rs | 6 +- integration-tests/call-runtime/.gitignore | 9 + integration-tests/call-runtime/Cargo.toml | 43 +++ integration-tests/call-runtime/README.md | 29 ++ integration-tests/call-runtime/lib.rs | 321 ++++++++++++++++++++++ 15 files changed, 497 insertions(+), 3 deletions(-) create mode 100644 integration-tests/call-runtime/.gitignore create mode 100644 integration-tests/call-runtime/Cargo.toml create mode 100644 integration-tests/call-runtime/README.md create mode 100644 integration-tests/call-runtime/lib.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 569af46974f..b551725f090 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Fixing `ManualKey<0>` to act properly - [#1670](https://github.com/paritytech/ink/pull/1670) +### Added +- Add `call-runtime` support - [#1641](https://github.com/paritytech/ink/pull/1641) + ## Version 4.0.0 The latest stable release of ink! is here 🥳 @@ -50,7 +53,6 @@ compatible with the ink! `4.0.0` release. For full compatibility requirements see the [migration guide](https://use.ink/faq/migrating-from-ink-3-to-4/#compatibility). -### Added - Add `Mapping::contains(key)` and `Mapping::insert_return_size(key, val)` ‒ [#1224](https://github.com/paritytech/ink/pull/1224) - Add [`payment-channel`](https://github.com/paritytech/ink/tree/master/examples/payment-channel) example ‒ [#1248](https://github.com/paritytech/ink/pull/1248) (thanks [@kanishkatn](https://github.com/kanishkatn)!) - Add `version` field to ink! metadata ‒ [#1313](https://github.com/paritytech/ink/pull/1313) diff --git a/crates/env/Cargo.toml b/crates/env/Cargo.toml index 09f7bc727fd..d27026de05e 100644 --- a/crates/env/Cargo.toml +++ b/crates/env/Cargo.toml @@ -71,6 +71,9 @@ std = [ "blake2", ] +# Enable direct call to a pallet dispatchable via `call_runtime()`. +call-runtime = [] + # Enable contract debug messages via `debug_print!` and `debug_println!`. ink-debug = [] diff --git a/crates/env/src/api.rs b/crates/env/src/api.rs index cb0d25d1812..1449e707388 100644 --- a/crates/env/src/api.rs +++ b/crates/env/src/api.rs @@ -700,3 +700,35 @@ where pub fn set_code_hash(code_hash: &[u8; 32]) -> Result<()> { ::on_instance(|instance| instance.set_code_hash(code_hash)) } + +/// Tries to trigger a runtime dispatchable, i.e. an extrinsic from a pallet. +/// +/// `call` (after SCALE encoding) should be decodable to a valid instance of `RuntimeCall` enum. +/// +/// For more details consult +/// [host function documentation](https://paritytech.github.io/substrate/master/pallet_contracts/api_doc/trait.Current.html#tymethod.call_runtime). +/// +/// # Errors +/// +/// - If the call cannot be properly decoded on the pallet contracts side. +/// - If the runtime doesn't allow for the contract unstable feature. +/// - If the runtime doesn't allow for dispatching this call from a contract. +/// +/// # Note +/// +/// The `call_runtime` host function is still part of `pallet-contracts`' unstable interface and +/// thus can be changed at anytime. +/// +/// # Panics +/// +/// Panics in the off-chain environment. +#[cfg(feature = "call-runtime")] +pub fn call_runtime(call: &Call) -> Result<()> +where + E: Environment, + Call: scale::Encode, +{ + ::on_instance(|instance| { + TypedEnvBackend::call_runtime::(instance, call) + }) +} diff --git a/crates/env/src/backend.rs b/crates/env/src/backend.rs index 93503d1b3ae..9f16631ff27 100644 --- a/crates/env/src/backend.rs +++ b/crates/env/src/backend.rs @@ -510,4 +510,10 @@ pub trait TypedEnvBackend: EnvBackend { fn own_code_hash(&mut self) -> Result where E: Environment; + + #[cfg(feature = "call-runtime")] + fn call_runtime(&mut self, call: &Call) -> Result<()> + where + E: Environment, + Call: scale::Encode; } diff --git a/crates/env/src/engine/off_chain/impls.rs b/crates/env/src/engine/off_chain/impls.rs index 979aea77475..ea7a2b4f674 100644 --- a/crates/env/src/engine/off_chain/impls.rs +++ b/crates/env/src/engine/off_chain/impls.rs @@ -541,4 +541,12 @@ impl TypedEnvBackend for EnvInstance { { unimplemented!("off-chain environment does not support `own_code_hash`") } + + #[cfg(feature = "call-runtime")] + fn call_runtime(&mut self, _call: &Call) -> Result<()> + where + E: Environment, + { + unimplemented!("off-chain environment does not support `call_runtime`") + } } diff --git a/crates/env/src/engine/on_chain/ext.rs b/crates/env/src/engine/on_chain/ext.rs index 7af0d5d4b67..59112f15c92 100644 --- a/crates/env/src/engine/on_chain/ext.rs +++ b/crates/env/src/engine/on_chain/ext.rs @@ -73,9 +73,11 @@ define_error_codes! { CodeNotFound = 7, /// The account that was called is no contract. NotCallable = 8, - /// The call to `debug_message` had no effect because debug message + /// The call to `debug_message` had no effect because debug message /// recording was disabled. LoggingDisabled = 9, + /// The call dispatched by `call_runtime` was executed but returned an error. + CallRuntimeFailed = 10, /// ECDSA public key recovery failed. Most probably wrong recovery id or signature. EcdsaRecoveryFailed = 11, } @@ -325,6 +327,9 @@ mod sys { out_ptr: Ptr32Mut<[u8]>, out_len_ptr: Ptr32Mut, ) -> ReturnCode; + + #[cfg(feature = "call-runtime")] + pub fn call_runtime(call_ptr: Ptr32<[u8]>, call_len: u32) -> ReturnCode; } #[link(wasm_import_module = "seal1")] @@ -639,6 +644,13 @@ pub fn return_value(flags: ReturnFlags, return_value: &[u8]) -> ! { } } +#[cfg(feature = "call-runtime")] +pub fn call_runtime(call: &[u8]) -> Result { + let ret_code = + unsafe { sys::call_runtime(Ptr32::from_slice(call), call.len() as u32) }; + ret_code.into() +} + macro_rules! impl_wrapper_for { ( $( $name:ident, )* ) => { $( diff --git a/crates/env/src/engine/on_chain/impls.rs b/crates/env/src/engine/on_chain/impls.rs index ae9fa3fdb1b..f49296d56b0 100644 --- a/crates/env/src/engine/on_chain/impls.rs +++ b/crates/env/src/engine/on_chain/impls.rs @@ -111,6 +111,7 @@ impl From for Error { ext::Error::CodeNotFound => Self::CodeNotFound, ext::Error::NotCallable => Self::NotCallable, ext::Error::LoggingDisabled => Self::LoggingDisabled, + ext::Error::CallRuntimeFailed => Self::CallRuntimeFailed, ext::Error::EcdsaRecoveryFailed => Self::EcdsaRecoveryFailed, } } @@ -575,4 +576,15 @@ impl TypedEnvBackend for EnvInstance { let hash = scale::Decode::decode(&mut &output[..])?; Ok(hash) } + + #[cfg(feature = "call-runtime")] + fn call_runtime(&mut self, call: &Call) -> Result<()> + where + E: Environment, + Call: scale::Encode, + { + let mut scope = self.scoped_buffer(); + let enc_call = scope.take_encoded(call); + ext::call_runtime(enc_call).map_err(Into::into) + } } diff --git a/crates/env/src/error.rs b/crates/env/src/error.rs index b8b613b0b08..6db524b9662 100644 --- a/crates/env/src/error.rs +++ b/crates/env/src/error.rs @@ -47,6 +47,8 @@ pub enum Error { /// The call to `debug_message` had no effect because debug message /// recording was disabled. LoggingDisabled, + /// The call dispatched by `call_runtime` was executed but returned an error. + CallRuntimeFailed, /// ECDSA pubkey recovery failed. Most probably wrong recovery id or signature. EcdsaRecoveryFailed, } diff --git a/crates/ink/Cargo.toml b/crates/ink/Cargo.toml index d4c11ce9db0..7819856605b 100644 --- a/crates/ink/Cargo.toml +++ b/crates/ink/Cargo.toml @@ -49,6 +49,12 @@ std = [ ink-debug = [ "ink_env/ink-debug", ] + +# Enable direct call to a pallet dispatchable via `call_runtime()`. +call-runtime = [ + "ink_env/call-runtime", +] + show-codegen-docs = [] # Disable the ink! provided global memory allocator. diff --git a/crates/ink/src/env_access.rs b/crates/ink/src/env_access.rs index 04c33f4c92c..46c94dc2497 100644 --- a/crates/ink/src/env_access.rs +++ b/crates/ink/src/env_access.rs @@ -969,4 +969,9 @@ where pub fn own_code_hash(self) -> Result { ink_env::own_code_hash::() } + + #[cfg(feature = "call-runtime")] + pub fn call_runtime(self, call: &Call) -> Result<()> { + ink_env::call_runtime::(call) + } } diff --git a/crates/storage/src/lazy/mapping.rs b/crates/storage/src/lazy/mapping.rs index a74d4e392c2..1d996891107 100644 --- a/crates/storage/src/lazy/mapping.rs +++ b/crates/storage/src/lazy/mapping.rs @@ -155,7 +155,11 @@ where /// Removes the `value` at `key`, returning the previous `value` at `key` from storage. /// /// Returns `None` if no `value` exists at the given `key`. - /// **WARNING**: this method uses the [unstable interface](https://github.com/paritytech/substrate/tree/master/frame/contracts#unstable-interfaces), + /// + /// # Warning + /// + /// This method uses the + /// [unstable interface](https://github.com/paritytech/substrate/tree/master/frame/contracts#unstable-interfaces), /// which is unsafe and normally is not available on production chains. #[inline] pub fn take(&self, key: Q) -> Option diff --git a/integration-tests/call-runtime/.gitignore b/integration-tests/call-runtime/.gitignore new file mode 100644 index 00000000000..bf910de10af --- /dev/null +++ b/integration-tests/call-runtime/.gitignore @@ -0,0 +1,9 @@ +# Ignore build artifacts from the local tests sub-crate. +/target/ + +# Ignore backup files creates by cargo fmt. +**/*.rs.bk + +# Remove Cargo.lock when creating an executable, leave it for libraries +# More information here http://doc.crates.io/guide.html#cargotoml-vs-cargolock +Cargo.lock \ No newline at end of file diff --git a/integration-tests/call-runtime/Cargo.toml b/integration-tests/call-runtime/Cargo.toml new file mode 100644 index 00000000000..72aa67835a9 --- /dev/null +++ b/integration-tests/call-runtime/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "call-runtime" +version = "4.0.0" +authors = ["Parity Technologies "] +edition = "2021" +publish = false + +[dependencies] +ink = { path = "../../crates/ink", default-features = false, features = ["call-runtime"] } + +scale = { package = "parity-scale-codec", version = "3", default-features = false, features = ["derive"] } +scale-info = { version = "2.3", default-features = false, features = ["derive"], optional = true } + +# Substrate +# +# We need to explicitly turn off some of the `sp-io` features, to avoid conflicts +# (especially for global allocator). +# +# See also: https://substrate.stackexchange.com/questions/4733/error-when-compiling-a-contract-using-the-xcm-chain-extension. +sp-io = { version = "18.0.0", default-features = false, features = ["disable_panic_handler", "disable_oom", "disable_allocator"] } +sp-runtime = { version = "19.0.0", default-features = false } + +[dev-dependencies] +ink_e2e = { path = "../../crates/e2e" } + +[lib] +path = "lib.rs" + +[features] +default = ["std"] +std = [ + "ink/std", + "scale/std", + "scale-info/std", + "sp-runtime/std", + "sp-io/std", +] +ink-as-dependency = [] +e2e-tests = [] + +# Assumes that the node used in E2E testing allows using the `call-runtime` API, including triggering +# `Balances::transfer` extrinsic. +permissive-node = [] diff --git a/integration-tests/call-runtime/README.md b/integration-tests/call-runtime/README.md new file mode 100644 index 00000000000..acf3c726f21 --- /dev/null +++ b/integration-tests/call-runtime/README.md @@ -0,0 +1,29 @@ +# `call-runtime` example + +## What is this example about? + +It demonstrates how to call a runtime dispatchable from an ink! contract. + +## Chain-side configuration + +To integrate this example into Substrate you need to adjust pallet contracts configuration in your runtime: + ```rust + // In your node's runtime configuration file (runtime.rs) + impl pallet_contracts::Config for Runtime { + … + // `Everything` or anything that will allow for the `Balances::transfer` extrinsic. + type CallFilter = frame_support::traits::Everything; + type UnsafeUnstableInterface = ConstBool; + … + } + ``` + +## Comparison to `ChainExtension` + +Just as a chain extension, `call_runtime` API allows contracts for direct calling to the runtime. +You can trigger any extrinsic that is not forbidden by `pallet_contracts::Config::CallFilter`. +Consider writing a chain extension if you need to perform one of the following tasks: +- Return data. +- Provide functionality **exclusively** to contracts. +- Provide custom weights. +- Avoid the need to keep the `Call` data structure stable. diff --git a/integration-tests/call-runtime/lib.rs b/integration-tests/call-runtime/lib.rs new file mode 100644 index 00000000000..4d57b0d57f9 --- /dev/null +++ b/integration-tests/call-runtime/lib.rs @@ -0,0 +1,321 @@ +#![cfg_attr(not(feature = "std"), no_std)] + +use ink::primitives::AccountId; +use sp_runtime::MultiAddress; + +/// A part of the runtime dispatchable API. +/// +/// For now, `ink!` doesn't provide any support for exposing the real `RuntimeCall` enum, which +/// fully describes the composed API of all the pallets present in runtime. Hence, in order to use +/// `call-runtime` functionality, we have to provide at least a partial object, which correctly +/// encodes the target extrinsic. +/// +/// You can investigate the full `RuntimeCall` definition by either expanding `construct_runtime!` +/// macro application or by using secondary tools for reading chain metadata, like `subxt`. +#[derive(scale::Encode)] +enum RuntimeCall { + /// This index can be found by investigating runtime configuration. You can check the pallet + /// order inside `construct_runtime!` block and read the position of your pallet (0-based). + /// + /// + /// [See here for more.](https://substrate.stackexchange.com/questions/778/how-to-get-pallet-index-u8-of-a-pallet-in-runtime) + #[codec(index = 4)] + Balances(BalancesCall), +} + +#[derive(scale::Encode)] +enum BalancesCall { + /// This index can be found by investigating the pallet dispatchable API. In your pallet code, + /// look for `#[pallet::call]` section and check `#[pallet::call_index(x)]` attribute of the + /// call. If these attributes are missing, use source-code order (0-based). + #[codec(index = 0)] + Transfer { + dest: MultiAddress, + #[codec(compact)] + value: u128, + }, +} + +#[ink::contract] +mod runtime_call { + use crate::{ + BalancesCall, + RuntimeCall, + }; + + use ink::env::Error as EnvError; + + /// A trivial contract with a single message, that uses `call-runtime` API for performing + /// native token transfer. + #[ink(storage)] + #[derive(Default)] + pub struct RuntimeCaller; + + #[derive(Debug, PartialEq, Eq, scale::Encode, scale::Decode)] + #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] + pub enum RuntimeError { + CallRuntimeFailed, + } + + impl From for RuntimeError { + fn from(e: EnvError) -> Self { + match e { + EnvError::CallRuntimeFailed => RuntimeError::CallRuntimeFailed, + _ => panic!("Unexpected error from `pallet-contracts`."), + } + } + } + + impl RuntimeCaller { + /// The constructor is `payable`, so that during instantiation it can be given some tokens + /// that will be further transferred with `transfer_through_runtime` message. + #[ink(constructor, payable)] + pub fn new() -> Self { + Default::default() + } + + /// Tries to transfer `value` from the contract's balance to `receiver`. + /// + /// Fails if: + /// - called in the off-chain environment + /// - the chain doesn't allow `call-runtime` API (`UnsafeUnstableInterface` is turned off) + /// - the chain forbids contracts to call `Balances::transfer` (`CallFilter` is too + /// restrictive) + /// - after the transfer, `receiver` doesn't have at least existential deposit + /// - the contract doesn't have enough balance + #[ink(message)] + pub fn transfer_through_runtime( + &mut self, + receiver: AccountId, + value: Balance, + ) -> Result<(), RuntimeError> { + self.env() + .call_runtime(&RuntimeCall::Balances(BalancesCall::Transfer { + dest: receiver.into(), + value, + })) + .map_err(Into::into) + } + + /// Tries to trigger `call_runtime` API with rubbish data. + /// + /// # Note + /// + /// This message is for testing purposes only. + #[ink(message)] + pub fn call_nonexistent_extrinsic(&mut self) -> Result<(), RuntimeError> { + self.env().call_runtime(&()).map_err(Into::into) + } + } + + #[cfg(all(test, feature = "e2e-tests"))] + mod e2e_tests { + use super::*; + + use ink::{ + env::{ + test::default_accounts, + DefaultEnvironment, + }, + primitives::AccountId, + }; + use ink_e2e::build_message; + + type E2EResult = Result>; + + /// The base number of indivisible units for balances on the `substrate-contracts-node`. + const UNIT: Balance = 1_000_000_000_000; + + /// The contract will be given 1000 tokens during instantiation. + #[cfg(feature = "permissive-node")] + const CONTRACT_BALANCE: Balance = 1_000 * UNIT; + + /// The receiver will get enough funds to have the required existential deposit. + /// + /// If your chain has this threshold higher, increase the transfer value. + const TRANSFER_VALUE: Balance = 1 / 10 * UNIT; + + /// An amount that is below the existential deposit, so that a transfer to an empty account + /// fails. + /// + /// Must not be zero, because such an operation would be a successful no-op. + #[cfg(feature = "permissive-node")] + const INSUFFICIENT_TRANSFER_VALUE: Balance = 1; + + /// Positive case scenario: + /// - `call_runtime` is enabled + /// - the call is valid + /// - the call execution succeeds + #[cfg(feature = "permissive-node")] + #[ink_e2e::test] + async fn transfer_with_call_runtime_works( + mut client: Client, + ) -> E2EResult<()> { + // given + let constructor = RuntimeCallerRef::new(); + let contract_acc_id = client + .instantiate( + "call-runtime", + &ink_e2e::alice(), + constructor, + CONTRACT_BALANCE, + None, + ) + .await + .expect("instantiate failed") + .account_id; + + let receiver: AccountId = default_accounts::().bob; + + let contract_balance_before = client + .balance(contract_acc_id) + .await + .expect("Failed to get account balance"); + let receiver_balance_before = client + .balance(receiver) + .await + .expect("Failed to get account balance"); + + // when + let transfer_message = build_message::(contract_acc_id) + .call(|caller| caller.transfer_through_runtime(receiver, TRANSFER_VALUE)); + + let call_res = client + .call(&ink_e2e::alice(), transfer_message, 0, None) + .await + .expect("call failed"); + + assert!(call_res.return_value().is_ok()); + + // then + let contract_balance_after = client + .balance(contract_acc_id) + .await + .expect("Failed to get account balance"); + let receiver_balance_after = client + .balance(receiver) + .await + .expect("Failed to get account balance"); + + assert_eq!( + contract_balance_before, + contract_balance_after + TRANSFER_VALUE + ); + assert_eq!( + receiver_balance_before, + receiver_balance_after - TRANSFER_VALUE + ); + + Ok(()) + } + + /// Negative case scenario: + /// - `call_runtime` is enabled + /// - the call is valid + /// - the call execution fails + #[cfg(feature = "permissive-node")] + #[ink_e2e::test] + async fn transfer_with_call_runtime_fails_when_execution_fails( + mut client: Client, + ) -> E2EResult<()> { + // given + let constructor = RuntimeCallerRef::new(); + let contract_acc_id = client + .instantiate( + "call-runtime", + &ink_e2e::alice(), + constructor, + CONTRACT_BALANCE, + None, + ) + .await + .expect("instantiate failed") + .account_id; + + let receiver: AccountId = default_accounts::().bob; + + // when + let transfer_message = build_message::(contract_acc_id) + .call(|caller| { + caller.transfer_through_runtime(receiver, INSUFFICIENT_TRANSFER_VALUE) + }); + + let call_res = client + .call_dry_run(&ink_e2e::alice(), &transfer_message, 0, None) + .await + .return_value(); + + // then + assert!(matches!(call_res, Err(RuntimeError::CallRuntimeFailed))); + + Ok(()) + } + + /// Negative case scenario: + /// - `call_runtime` is enabled + /// - the call is invalid + #[cfg(feature = "permissive-node")] + #[ink_e2e::test] + async fn transfer_with_call_runtime_fails_when_call_is_invalid( + mut client: Client, + ) -> E2EResult<()> { + // given + let constructor = RuntimeCallerRef::new(); + let contract_acc_id = client + .instantiate( + "call-runtime", + &ink_e2e::alice(), + constructor, + CONTRACT_BALANCE, + None, + ) + .await + .expect("instantiate failed") + .account_id; + + // when + let transfer_message = build_message::(contract_acc_id) + .call(|caller| caller.call_nonexistent_extrinsic()); + + let call_res = client + .call_dry_run(&ink_e2e::alice(), &transfer_message, 0, None) + .await; + + // then + assert!(call_res.is_err()); + + Ok(()) + } + + /// Negative case scenario: + /// - `call_runtime` is disabled + #[cfg(not(feature = "permissive-node"))] + #[ink_e2e::test] + async fn call_runtime_fails_when_forbidden( + mut client: Client, + ) -> E2EResult<()> { + // given + let constructor = RuntimeCallerRef::new(); + let contract_acc_id = client + .instantiate("call-runtime", &ink_e2e::alice(), constructor, 0, None) + .await + .expect("instantiate failed") + .account_id; + + let receiver: AccountId = default_accounts::().bob; + + let transfer_message = build_message::(contract_acc_id) + .call(|caller| caller.transfer_through_runtime(receiver, TRANSFER_VALUE)); + + // when + let call_res = client + .call(&ink_e2e::alice(), transfer_message, 0, None) + .await; + + // then + assert!(matches!(call_res, Err(ink_e2e::Error::CallExtrinsic(_)))); + + Ok(()) + } + } +}