diff --git a/.config/cargo_spellcheck.dic b/.config/cargo_spellcheck.dic index 777960d2632..aaf325fb8a3 100644 --- a/.config/cargo_spellcheck.dic +++ b/.config/cargo_spellcheck.dic @@ -40,6 +40,7 @@ dereferencing deserialize/S deserialization dispatchable/S +E2E encodable evaluable fuzzer @@ -97,6 +98,7 @@ layout/JG namespace/S parameterize/SD runtime/S +storable struct/S vec/S vector/S @@ -107,4 +109,4 @@ natively payability unpayable initializer -storable +WebSocket/S diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 9d554c6d560..d34066f611f 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -86,6 +86,9 @@ workflow: tags: - kubernetes-parity-build +.start-substrate-contracts-node: &start-substrate-contracts-node + - substrate-contracts-node -linfo,runtime::contracts=debug 2>&1 | tee /tmp/contracts-node.log & + #### stage: lint # # Note: For all of these lints we `allow_failure` so that the rest of the build can @@ -280,11 +283,21 @@ docs: paths: - ./crate-docs/ script: - - cargo doc --no-deps --all-features - -p scale-info -p ink_metadata -p ink_env - -p ink_storage -p ink_storage_traits - -p ink_primitives -p ink_prelude - -p ink -p ink_macro -p ink_ir -p ink_codegen + # All crate docs currently need to be built separately. The reason + # is that `smart-bench-macro` is a dependency now in a number of places. + # This crate uses e.g. `ink_metadata`, but in its published form. So if + # e.g. the `-p ink_metadata` is added to the `ink_lang` command this + # results in the cargo failure "multiple packages with same spec, ambiguous". + - cargo doc --no-deps --all-features -p ink_env + - cargo doc --no-deps --all-features -p ink_storage + - cargo doc --no-deps --all-features -p ink_storage_traits + - cargo doc --no-deps --all-features -p ink_primitives + - cargo doc --no-deps --all-features -p ink_prelude + - cargo doc --no-deps --all-features -p ink + - cargo doc --no-deps --all-features -p ink_macro + - cargo doc --no-deps --all-features -p ink_ir + - cargo doc --no-deps --all-features -p ink_codegen + - cargo doc --no-deps --all-features -p ink_metadata - mv ${CARGO_TARGET_DIR}/doc ./crate-docs # FIXME: remove me after CI image gets nonroot - chown -R nonroot:nonroot ./crate-docs @@ -347,6 +360,7 @@ examples-test: - job: clippy-std artifacts: false script: + - *start-substrate-contracts-node - for example in examples/*/; do if [ "$example" = "examples/upgradeable-contracts/" ]; then continue; fi; cargo test --verbose --manifest-path ${example}/Cargo.toml; diff --git a/crates/env/Cargo.toml b/crates/env/Cargo.toml index b6b556ae94d..68fb266c36d 100644 --- a/crates/env/Cargo.toml +++ b/crates/env/Cargo.toml @@ -50,6 +50,26 @@ secp256k1 = { version = "0.24", features = ["recovery", "global-context"], optio rand = { version = "0.8", default-features = false, features = ["alloc"], optional = true } scale-info = { version = "2", default-features = false, features = ["derive"], optional = true } +contract-metadata = "2.0.0-alpha.2" +impl-serde = { version = "0.3.1", default-features = false } +jsonrpsee = { version = "0.14.0", features = ["ws-client"] } +pallet-contracts-primitives = "6.0.0" +serde = { version = "1.0.137", default-features = false, features = ["derive"] } +serde_json = "1.0.81" +tokio = { version = "1.18.2", features = ["rt-multi-thread"] } +log = "0.4" +env_logger = "0.8" +subxt = "0.24.0" + +# Substrate +sp-rpc = "6.0.0" +sp-core = "6.0.0" +sp-keyring = "6.0.0" +sp-runtime = "6.0.0" + +# TODO(#xxx) `smart-bench_macro` needs to be forked. +smart-bench-macro = { git = "https://github.com/paritytech/smart-bench", branch = "cmichi-ink-e2e-test-mvp", package = "smart-bench-macro"} + [features] default = ["std"] std = [ diff --git a/crates/env/metadata/contracts-node.scale b/crates/env/metadata/contracts-node.scale new file mode 100644 index 00000000000..45ea661dfdb Binary files /dev/null and b/crates/env/metadata/contracts-node.scale differ diff --git a/crates/env/src/engine/e2e/client.rs b/crates/env/src/engine/e2e/client.rs new file mode 100644 index 00000000000..2e50701c4fa --- /dev/null +++ b/crates/env/src/engine/e2e/client.rs @@ -0,0 +1,528 @@ +// Copyright 2018-2022 Parity Technologies (UK) Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use super::{ + client::api::runtime_types::{ + frame_system::AccountInfo, + pallet_balances::AccountData, + }, + log_error, + log_info, + sr25519, + xts::{ + self, + api, + Call, + InstantiateWithCode, + }, + ContractExecResult, + ContractInstantiateResult, + ContractsApi, + InkConstructor, + InkMessage, + Signer, +}; +use crate::Environment; +use std::path::PathBuf; + +use sp_runtime::traits::{ + IdentifyAccount, + Verify, +}; +use subxt::{ + ext::bitvec::macros::internal::funty::Fundamental, + metadata::DecodeStaticType, + storage::address::{ + StorageHasher, + StorageMapKey, + Yes, + }, + tx::{ + ExtrinsicParams, + TxEvents, + }, +}; + +/// An encoded `#[ink(message)]`. +#[derive(Clone)] +pub struct EncodedMessage(Vec); + +impl EncodedMessage { + fn new(call: &M) -> Self { + let mut call_data = M::SELECTOR.to_vec(); + ::encode_to(call, &mut call_data); + Self(call_data) + } +} + +impl From for EncodedMessage +where + M: InkMessage, +{ + fn from(msg: M) -> Self { + EncodedMessage::new(&msg) + } +} + +/// Result of a contract instantiation. +pub struct InstantiationResult { + /// The account id at which the contract was instantiated. + pub account_id: C::AccountId, + /// The result of the dry run, contains debug messages + /// if there were any. + pub dry_run: ContractInstantiateResult, + /// Events that happened with the contract instantiation. + pub events: TxEvents, +} + +/// We implement a custom `Debug` here, as to avoid requiring the trait +/// bound `Debug` for `E`. +// TODO(#xxx) Improve the `Debug` implementation. +impl core::fmt::Debug for InstantiationResult +where + C: subxt::Config, + E: Environment, + ::Balance: core::fmt::Debug, +{ + fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result { + f.debug_struct("CallResult") + .field("account_id", &self.account_id) + .field("dry_run", &self.dry_run) + .field("events", &self.events) + .finish() + } +} + +/// Result of a contract call. +pub struct CallResult { + /// The result of the dry run, contains debug messages + /// if there were any. + pub dry_run: ContractExecResult, + /// Events that happened with the contract instantiation. + pub events: TxEvents, +} + +/// We implement a custom `Debug` here, as to avoid requiring the trait +/// bound `Debug` for `E`. +// TODO(#xxx) Improve the `Debug` implementation. +impl core::fmt::Debug for CallResult +where + C: subxt::Config, + E: Environment, + ::Balance: core::fmt::Debug, +{ + fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result { + f.debug_struct("CallResult") + .field("dry_run", &self.dry_run) + .field("events", &self.events) + .finish() + } +} + +/// An error occurred while interacting with the Substrate node. +/// +/// We only convey errors here that are caused by the contract's +/// testing logic. For anything concerning the node (like inability +/// to communicate with it, fetch the nonce, account info, etc.) we +/// panic. +pub enum Error +where + C: subxt::Config, + E: Environment, + ::Balance: core::fmt::Debug, +{ + /// The `instantiate_with_code` dry run failed. + InstantiateDryRun(ContractInstantiateResult), + /// The `instantiate_with_code` extrinsic failed. + InstantiateExtrinsic(subxt::error::DispatchError), + /// The `call` dry run failed. + CallDryRun(ContractExecResult), + /// The `call` extrinsic failed. + CallExtrinsic(subxt::error::DispatchError), +} + +// We implement a custom `Debug` here, as to avoid requiring the trait +// bound `Debug` for `C`. +impl core::fmt::Debug for Error +where + C: subxt::Config, + E: Environment, + ::Balance: core::fmt::Debug, +{ + fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result { + match &self { + Error::InstantiateDryRun(_) => f.write_str("InstantiateDryRun"), + Error::InstantiateExtrinsic(_) => f.write_str("InstantiateExtrinsic"), + Error::CallDryRun(_) => f.write_str("CallDryRun"), + Error::CallExtrinsic(_) => f.write_str("CallExtrinsic"), + } + } +} + +/// A contract was successfully instantiated. +#[derive(Debug, scale::Decode, scale::Encode)] +struct ContractInstantiatedEvent { + /// Account id of the deployer. + pub deployer: C::AccountId, + /// Account id where the contract was instantiated to. + pub contract: C::AccountId, +} + +impl subxt::events::StaticEvent for ContractInstantiatedEvent +where + C: subxt::Config, +{ + const PALLET: &'static str = "Contracts"; + const EVENT: &'static str = "Instantiated"; +} + +/// The `Client` takes care of communicating with the node. +/// +/// This node's RPC interface will be used for instantiating the contract +/// and interacting with it . +pub struct Client +where + C: subxt::Config, + E: Environment, +{ + api: ContractsApi, + node_log: String, + contract_path: PathBuf, +} + +impl Client +where + C: subxt::Config, + C::AccountId: Into + serde::de::DeserializeOwned, + C::Address: From, + C::Signature: From, + ::Signer: From, + >::OtherParams: Default, + ::Signer: + From + IdentifyAccount, + sr25519::Signature: Into, + + E: Environment, + E::Balance: core::fmt::Debug + scale::Encode, + + Call: scale::Encode, + InstantiateWithCode: scale::Encode, +{ + /// Creates a new [`Client`] instance. + pub async fn new(contract_path: &str, url: &str, node_log: &str) -> Self { + let client = subxt::OnlineClient::from_url(url) + .await + .unwrap_or_else(|err| { + log_error( + "Unable to create client! Please check that your node is running.", + ); + panic!("Unable to create client: {:?}", err); + }); + + Self { + api: ContractsApi::new(client, url).await, + contract_path: PathBuf::from(contract_path), + node_log: node_log.to_string(), + } + } + + /// This function extracts the metadata of the contract at the file path + /// `target/ink/$contract_name.contract`. + /// + /// The function subsequently uploads and instantiates an instance of the contract. + /// + /// Calling this function multiple times is idempotent, the contract is + /// newly instantiated each time using a unique salt. No existing contract + /// instance is reused! + pub async fn instantiate( + &mut self, + signer: &mut Signer, + // TODO(#xxx) It has to be possible to supply a contact bundle path directly here. + // Otherwise cross-contract testing is not possible. Currently we instantiate just + // by default the contract for which the test is executed. + // contract_path: Option, + constructor: CO, + value: E::Balance, + storage_deposit_limit: Option, + ) -> Result, Error> + where + CO: InkConstructor, + { + let reader = std::fs::File::open(&self.contract_path).unwrap_or_else(|err| { + panic!("contract path cannot be opened: {:?}", err); + }); + let contract: contract_metadata::ContractMetadata = + serde_json::from_reader(reader).map_err(|err| { + panic!("error reading metadata: {:?}", err); + })?; + let code = contract + .source + .wasm + .expect("contract bundle is missing `source.wasm`"); + + log_info(&format!( + "{:?} has {} KiB", + self.contract_path, + code.0.len() / 1024 + )); + + let nonce = self + .api + .client + .rpc() + .system_account_next_index(signer.account_id()) + .await + .unwrap_or_else(|err| { + panic!( + "error getting next index for {:?}: {:?}", + signer.account_id(), + err + ); + }); + log_info(&format!("nonce: {:?}", nonce)); + signer.set_nonce(nonce); + + let ret = self + .exec_instantiate(signer, value, storage_deposit_limit, code.0, &constructor) + .await?; + log_info(&format!("instantiated contract at {:?}", ret.account_id)); + + Ok(ret) + } + + /// Executes an `instantiate_with_code` call and captures the resulting events. + async fn exec_instantiate( + &mut self, + signer: &mut Signer, + value: E::Balance, + storage_deposit_limit: Option, + code: Vec, + constructor: &CO, + ) -> Result, Error> { + let mut data = CO::SELECTOR.to_vec(); + log_info(&format!("instantiating with selector: {:?}", CO::SELECTOR)); + ::encode_to(constructor, &mut data); + + let salt = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("unable to get unix time") + .as_millis() + .as_u128() + .to_le_bytes() + .to_vec(); + + // dry run the instantiate to calculate the gas limit + let dry_run = self + .api + .instantiate_with_code_dry_run( + value, + storage_deposit_limit, + code.clone(), + data.clone(), + salt.clone(), + signer, + ) + .await; + log_info(&format!( + "instantiate dry run debug message: {:?}", + String::from_utf8_lossy(&dry_run.debug_message) + )); + log_info(&format!("instantiate dry run result: {:?}", dry_run.result)); + if dry_run.result.is_err() { + return Err(Error::InstantiateDryRun(dry_run)) + } + + let tx_events = self + .api + .instantiate_with_code( + value, + dry_run.gas_required, + storage_deposit_limit, + code, + data.clone(), + salt, + signer, + ) + .await; + signer.increment_nonce(); + + let mut account_id = None; + for evt in tx_events.iter() { + let evt = evt.unwrap_or_else(|err| { + panic!("unable to unwrap event: {:?}", err); + }); + + if let Some(instantiated) = evt + .as_event::>() + .unwrap_or_else(|err| { + panic!("event conversion to `Instantiated` failed: {:?}", err); + }) + { + log_info(&format!( + "contract was instantiated at {:?}", + instantiated.contract + )); + account_id = Some(instantiated.contract); + break + } else if evt + .as_event::() + .unwrap_or_else(|err| { + panic!("event conversion to `ExtrinsicFailed` failed: {:?}", err) + }) + .is_some() + { + let metadata = self.api.client.metadata(); + let dispatch_error = subxt::error::DispatchError::decode_from( + evt.field_bytes(), + &metadata, + ); + log_error(&format!( + "extrinsic for instantiate failed: {:?}", + dispatch_error + )); + return Err(Error::InstantiateExtrinsic(dispatch_error)) + } + } + + Ok(InstantiationResult { + dry_run, + // The `account_id` must exist at this point. If the instantiation fails + // the dry-run must already return that. + account_id: account_id.expect("cannot extract account_id from events"), + events: tx_events, + }) + } + + /// Executes a `call` for the contract at `account_id`. + /// + /// Returns when the transaction is included in a block. The return value + /// contains all events that are associated with this transaction. + pub async fn call( + &self, + signer: &mut Signer, + account_id: C::AccountId, + contract_call: EncodedMessage, + value: E::Balance, + storage_deposit_limit: Option, + ) -> Result, Error> { + let dry_run = self + .api + .call_dry_run(account_id.clone(), value, None, contract_call.0.clone()) + .await; + log_info(&format!("call dry run: {:?}", &dry_run.result)); + log_info(&format!( + "call dry run debug message: {}", + String::from_utf8_lossy(&dry_run.debug_message) + )); + if dry_run.result.is_err() { + return Err(Error::CallDryRun(dry_run)) + } + + let tx_events = self + .api + .call( + sp_runtime::MultiAddress::Id(account_id), + value, + dry_run.gas_required, + storage_deposit_limit, + contract_call.0.clone(), + signer, + ) + .await; + signer.increment_nonce(); + + for evt in tx_events.iter() { + let evt = evt.unwrap_or_else(|err| { + panic!("unable to unwrap event: {:?}", err); + }); + + if evt + .as_event::() + .unwrap_or_else(|err| { + panic!("event conversion to `ExtrinsicFailed` failed: {:?}", err) + }) + .is_some() + { + let metadata = self.api.client.metadata(); + let dispatch_error = subxt::error::DispatchError::decode_from( + evt.field_bytes(), + &metadata, + ); + log_error(&format!("extrinsic for call failed: {:?}", dispatch_error)); + return Err(Error::InstantiateExtrinsic(dispatch_error)) + } + } + + Ok(CallResult { + dry_run, + events: tx_events, + }) + } + + /// Returns the balance of `account_id`. + pub async fn balance( + &self, + account_id: C::AccountId, + ) -> Result> { + let account_addr = subxt::storage::StaticStorageAddress::< + DecodeStaticType>>, + Yes, + Yes, + (), + >::new( + "System", + "Account", + vec![StorageMapKey::new( + account_id.clone(), + StorageHasher::Blake2_128Concat, + )], + Default::default(), + ) + .unvalidated(); + + let alice_pre: AccountInfo> = self + .api + .client + .storage() + .fetch_or_default(&account_addr, None) + .await + .unwrap_or_else(|err| { + panic!("unable to fetch balance: {:?}", err); + }); + log_info(&format!( + "balance of contract {:?} is {:?}", + account_id, alice_pre + )); + Ok(alice_pre.data.free) + } + + /// Returns true if the `substrate-contracts-node` log under + /// `/tmp/contracts-node.log` contains `msg`. + /// TODO(#xxx) Matches on any log entry currently, even if done + /// by a different test. + pub fn node_log_contains(&self, msg: &str) -> bool { + let output = std::process::Command::new("grep") + .arg("-q") + .arg(msg) + .arg(&self.node_log) + .spawn() + .map_err(|err| { + format!("ERROR while executing `grep` with {:?}: {:?}", msg, err) + }) + .expect("failed to execute process") + .wait_with_output() + .expect("failed to receive output"); + output.status.success() + } +} diff --git a/crates/env/src/engine/e2e/default_accounts.rs b/crates/env/src/engine/e2e/default_accounts.rs new file mode 100644 index 00000000000..1e0b542411a --- /dev/null +++ b/crates/env/src/engine/e2e/default_accounts.rs @@ -0,0 +1,53 @@ +// Copyright 2018-2022 Parity Technologies (UK) Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Default accounts provided by [`sp_keyring::sr25519::Keyring`]. + +use super::{ + AccountKeyring, + IdentifyAccount, + PairSigner, + Verify, +}; +use sp_core::sr25519; + +#[rustfmt::skip] +macro_rules! default_account { + ($fn_name:ident $keyring_fn_name:ident) => { + #[doc = concat!( + "Returns the default [`sp_keyring::sr25519::Keyring`] for `//", + stringify!($keyring_fn_name), + "`." + )] + pub fn $fn_name() -> PairSigner + where + C: subxt::Config, + ::Signer: From, + C::Signature: From, + ::Signer: IdentifyAccount, + { + PairSigner::new(AccountKeyring::$keyring_fn_name.pair()) + } + }; +} + +// The following accounts are pre-defined in [`sp-keyring::sr25519`]. +default_account!(alice Alice); +default_account!(bob Bob); +default_account!(charlie Charlie); +default_account!(dave Dave); +default_account!(eve Eve); +default_account!(ferdie Ferdie); +default_account!(one One); +default_account!(two Two); diff --git a/crates/env/src/engine/e2e/mod.rs b/crates/env/src/engine/e2e/mod.rs new file mode 100644 index 00000000000..4f862cb8756 --- /dev/null +++ b/crates/env/src/engine/e2e/mod.rs @@ -0,0 +1,121 @@ +// Copyright 2018-2022 Parity Technologies (UK) Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Module for the logic behind ink!'s End-to-End testing framework. + +mod client; +mod default_accounts; +mod xts; + +pub use client::{ + Client, + Error, +}; +pub use default_accounts::*; +// TODO(#xxx) `smart-bench_macro` needs to be forked. +use pallet_contracts_primitives::{ + ContractExecResult, + ContractInstantiateResult, +}; +pub use smart_bench_macro; +use xts::ContractsApi; + +pub use env_logger; +pub use sp_keyring::AccountKeyring; +pub use subxt::tx::PairSigner; +pub use tokio; + +use log; +use sp_core::sr25519; +use sp_runtime::traits::{ + IdentifyAccount, + Verify, +}; +use std::{ + cell::RefCell, + sync::Once, +}; + +/// Default set of commonly used types by Substrate runtimes. +#[cfg(feature = "std")] +pub enum SubstrateConfig {} + +#[cfg(feature = "std")] +impl subxt::Config for SubstrateConfig { + type Index = u32; + type BlockNumber = u32; + type Hash = sp_core::H256; + type Hashing = sp_runtime::traits::BlakeTwo256; + type AccountId = sp_runtime::AccountId32; + type Address = sp_runtime::MultiAddress; + type Header = + sp_runtime::generic::Header; + type Signature = sp_runtime::MultiSignature; + type Extrinsic = sp_runtime::OpaqueExtrinsic; + type ExtrinsicParams = subxt::tx::SubstrateExtrinsicParams; +} + +/// Default set of commonly used types by Polkadot nodes. +#[cfg(feature = "std")] +pub type PolkadotConfig = subxt::config::WithExtrinsicParams< + SubstrateConfig, + subxt::tx::PolkadotExtrinsicParams, +>; + +/// Signer that is used throughout the E2E testing. +/// +/// The E2E testing can only be used with nodes that support `sr25519` +/// cryptography. +pub type Signer = PairSigner; + +/// Trait for contract constructors. +// TODO(#xxx) Merge this with `InkMessage` to be just `InkSelector`. Requires forking `smart-bench-macro`. +pub trait InkConstructor: scale::Encode { + /// An ink! selector consists of four bytes. + const SELECTOR: [u8; 4]; +} + +/// Trait for contract messages. +pub trait InkMessage: scale::Encode { + /// An ink! selector consists of four bytes. + const SELECTOR: [u8; 4]; +} + +/// We use this to only initialize `env_logger` once. +pub static INIT: Once = Once::new(); + +// We save the name of the currently executing test here as a mean +// of prefixing log entries to make it easier pinning them to tests. +thread_local! { + /// This prefix will be used for log output. It is set by each + /// `#[ink::e2e_test]` with the function name as String. + /// This way it is possible to distinguish the lines in stdout + /// and stderr, to still know which line belongs to which test. + pub static LOG_PREFIX: RefCell = RefCell::new(String::from("no prefix set")); +} + +/// Returns the name of the test which is currently executed. +pub fn log_prefix() -> String { + LOG_PREFIX.with(|log_prefix| log_prefix.borrow().clone()) +} + +/// Writes `msg` to stdout. +pub fn log_info(msg: &str) { + log::info!("[{}] {}", log_prefix(), msg); +} + +/// Writes `msg` to stderr. +pub fn log_error(msg: &str) { + log::error!("[{}] {}", log_prefix(), msg); +} diff --git a/crates/env/src/engine/e2e/xts.rs b/crates/env/src/engine/e2e/xts.rs new file mode 100644 index 00000000000..17e4b9e288b --- /dev/null +++ b/crates/env/src/engine/e2e/xts.rs @@ -0,0 +1,338 @@ +// Copyright 2018-2022 Parity Technologies (UK) Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use super::{ + log_info, + sr25519, + ContractExecResult, + ContractInstantiateResult, + IdentifyAccount, + Signer, + Verify, +}; +use crate::Environment; + +use core::marker::PhantomData; +use jsonrpsee::{ + core::client::ClientT, + rpc_params, + ws_client::{ + WsClient, + WsClientBuilder, + }, +}; +use sp_core::{ + Bytes, + H256, +}; +use subxt::{ + tx::{ + ExtrinsicParams, + TxEvents, + }, + OnlineClient, +}; + +/// The gas limit for contract instantiate and call dry runs. +const DRY_RUN_GAS_LIMIT: u64 = 500_000_000_000; + +// TODO(#xxx) Should be fetched automatically. +#[subxt::subxt(runtime_metadata_path = "metadata/contracts-node.scale")] +pub(super) mod api {} + +/// A raw call to `pallet-contracts`'s `instantiate_with_code`. +#[derive(Debug, scale::Encode, scale::Decode)] +pub struct InstantiateWithCode { + #[codec(compact)] + value: B, + #[codec(compact)] + gas_limit: crate::types::Gas, + storage_deposit_limit: Option, + code: Vec, + data: Vec, + salt: Vec, +} + +/// A raw call to `pallet-contracts`'s `call`. +#[derive(Debug, scale::Encode, scale::Decode)] +pub struct Call { + dest: ::subxt::ext::sp_runtime::MultiAddress, + #[codec(compact)] + value: B, + #[codec(compact)] + gas_limit: crate::types::Gas, + storage_deposit_limit: Option, + data: Vec, +} + +/// A struct that encodes RPC parameters required to instantiate a new smart contract. +#[derive(serde::Serialize, scale::Encode)] +#[serde(rename_all = "camelCase")] +struct InstantiateRequest { + origin: C::AccountId, + value: E::Balance, + gas_limit: crate::types::Gas, + storage_deposit_limit: Option, + code: Code, + data: Vec, + salt: Vec, +} + +/// Reference to an existing code hash or a new Wasm module. +#[derive(serde::Serialize, scale::Encode)] +#[serde(rename_all = "camelCase")] +enum Code { + /// A Wasm module as raw bytes. + Upload(Vec), + #[allow(unused)] + /// The code hash of an on-chain Wasm blob. + Existing(H256), +} + +/// A struct that encodes RPC parameters required for a call to a smart contract. +/// +/// Copied from [`pallet-contracts-rpc`]. +#[derive(serde::Serialize, scale::Encode)] +#[serde(rename_all = "camelCase")] +struct RpcCallRequest { + origin: C::AccountId, + dest: C::AccountId, + value: E::Balance, + gas_limit: crate::types::Gas, + storage_deposit_limit: Option, + input_data: Vec, +} + +/// Provides functions for interacting with the `pallet-contracts` API. +pub struct ContractsApi { + pub client: OnlineClient, + ws_client: WsClient, + _phantom: PhantomData (C, E)>, +} + +impl ContractsApi +where + C: subxt::Config, + C::AccountId: Into + serde::de::DeserializeOwned, + >::OtherParams: Default, + + C::Signature: From, + ::Signer: From, + ::Signer: + From + IdentifyAccount, + sr25519::Signature: Into, + + E: Environment, + E::Balance: scale::Encode, + + Call: scale::Encode, + InstantiateWithCode: scale::Encode, +{ + /// Creates a new [`ContractsApi`] instance. + pub async fn new(client: OnlineClient, url: &str) -> Self { + let ws_client = + WsClientBuilder::default() + .build(&url) + .await + .unwrap_or_else(|err| { + panic!("error on ws request: {:?}", err); + }); + + Self { + client, + ws_client, + _phantom: Default::default(), + } + } + + /// Dry runs the instantiation of the given `code`. + pub async fn instantiate_with_code_dry_run( + &self, + value: E::Balance, + storage_deposit_limit: Option, + code: Vec, + data: Vec, + salt: Vec, + signer: &Signer, + ) -> ContractInstantiateResult { + let code = Code::Upload(code); + let call_request = InstantiateRequest:: { + origin: signer.account_id().clone(), + value, + gas_limit: DRY_RUN_GAS_LIMIT, + storage_deposit_limit, + code, + data, + salt, + }; + let func = "ContractsApi_instantiate"; + let params = rpc_params![func, Bytes(scale::Encode::encode(&call_request))]; + let bytes: Bytes = self + .ws_client + .request("state_call", params) + .await + .unwrap_or_else(|err| { + panic!("error on ws request `contracts_instantiate`: {:?}", err); + }); + scale::Decode::decode(&mut bytes.as_ref()).expect("decoding failed") + } + + /// Submits an extrinsic to instantiate a contract with the given code. + /// + /// Returns when the transaction is included in a block. The return value + /// contains all events that are associated with this transaction. + #[allow(clippy::too_many_arguments)] + pub async fn instantiate_with_code( + &self, + value: E::Balance, + gas_limit: crate::types::Gas, + storage_deposit_limit: Option, + code: Vec, + data: Vec, + salt: Vec, + signer: &Signer, + ) -> TxEvents { + let call = subxt::tx::StaticTxPayload::new( + "Contracts", + "instantiate_with_code", + InstantiateWithCode:: { + value, + gas_limit, + storage_deposit_limit, + code, + data, + salt, + }, + Default::default(), + ) + .unvalidated(); + + self.client + .tx() + .sign_and_submit_then_watch_default(&call, signer) + .await + .map(|tx_progress| { + log_info(&format!( + "signed and submitted tx with hash {:?}", + tx_progress.extrinsic_hash() + )); + tx_progress + }) + .unwrap_or_else(|err| { + panic!( + "error on call `sign_and_submit_then_watch_default`: {:?}", + err + ); + }) + .wait_for_in_block() + .await + .unwrap_or_else(|err| { + panic!("error on call `wait_for_in_block`: {:?}", err); + }) + .fetch_events() + .await + .unwrap_or_else(|err| { + panic!("error on call `fetch_events`: {:?}", err); + }) + } + + /// Dry runs a call of the contract at `contract` with the given parameters. + pub async fn call_dry_run( + &self, + contract: C::AccountId, + value: E::Balance, + storage_deposit_limit: Option, + input_data: Vec, + ) -> ContractExecResult { + let call_request = RpcCallRequest:: { + origin: contract.clone(), + dest: contract, + value, + gas_limit: DRY_RUN_GAS_LIMIT, + storage_deposit_limit, + input_data, + }; + let func = "ContractsApi_call"; + let params = rpc_params![func, Bytes(scale::Encode::encode(&call_request))]; + let bytes: Bytes = self + .ws_client + .request("state_call", params) + .await + .unwrap_or_else(|err| { + panic!("error on ws request `contracts_call`: {:?}", err); + }); + scale::Decode::decode(&mut bytes.as_ref()).expect("decoding failed") + } + + /// Submits an extrinsic to call a contract with the given parameters. + /// + /// Returns when the transaction is included in a block. The return value + /// contains all events that are associated with this transaction. + pub async fn call( + &self, + contract: sp_runtime::MultiAddress, + value: E::Balance, + gas_limit: crate::types::Gas, + storage_deposit_limit: Option, + data: Vec, + signer: &Signer, + ) -> TxEvents { + let call = subxt::tx::StaticTxPayload::new( + "Contracts", + "call", + Call:: { + dest: contract, + value, + gas_limit, + storage_deposit_limit, + data, + }, + Default::default(), + ) + .unvalidated(); + + self + .client + .tx() + .sign_and_submit_then_watch_default(&call, signer) + .await + .map(|tx_progress| { + log_info(&format!("signed and submitted call with extrinsic hash {:?}", tx_progress.extrinsic_hash())); + tx_progress + }) + .unwrap_or_else(|err| { + panic!( + "error on call `sign_and_submit_then_watch_default`: {:?}", + err + ); + }) + // TODO(#xxx) It should be configurable to use `.wait_for_finalized_success` instead. + .wait_for_in_block() + .await + .unwrap_or_else(|err| { + panic!( + "error on call `wait_for_in_block`: {:?}", + err + ); + }) + .fetch_events() + .await + .unwrap_or_else(|err| { + panic!( + "error on call `fetch_events`: {:?}", + err + ); + }) + } +} diff --git a/crates/env/src/engine/mod.rs b/crates/env/src/engine/mod.rs index 2e74d582e12..811776663c5 100644 --- a/crates/env/src/engine/mod.rs +++ b/crates/env/src/engine/mod.rs @@ -29,6 +29,7 @@ cfg_if! { mod on_chain; pub use self::on_chain::EnvInstance; } else if #[cfg(feature = "std")] { + pub mod e2e; pub mod off_chain; pub use self::off_chain::EnvInstance; } else { diff --git a/crates/env/src/lib.rs b/crates/env/src/lib.rs index f02ca57ca78..f53f6fd7137 100644 --- a/crates/env/src/lib.rs +++ b/crates/env/src/lib.rs @@ -84,6 +84,10 @@ mod tests; #[doc(inline)] pub use self::engine::off_chain::test_api as test; +#[cfg(any(feature = "std", test, doc))] +#[doc(inline)] +pub use self::engine::e2e; + use self::backend::{ EnvBackend, TypedEnvBackend, diff --git a/crates/ink/codegen/Cargo.toml b/crates/ink/codegen/Cargo.toml index 261c4d50d0c..33ff74ca805 100644 --- a/crates/ink/codegen/Cargo.toml +++ b/crates/ink/codegen/Cargo.toml @@ -31,6 +31,12 @@ heck = "0.4.0" scale = { package = "parity-scale-codec", version = "3", default-features = false, features = ["derive", "full"] } impl-serde = "0.4.0" +serde = { version = "1.0.137", default-features = false, features = ["derive"] } +serde_json = "1.0.81" + +log = "0.4" +env_logger = "0.8" + [features] default = ["std"] std = [ diff --git a/crates/ink/codegen/src/generator/ink_e2e_test.rs b/crates/ink/codegen/src/generator/ink_e2e_test.rs new file mode 100644 index 00000000000..897946d0fee --- /dev/null +++ b/crates/ink/codegen/src/generator/ink_e2e_test.rs @@ -0,0 +1,188 @@ +// Copyright 2018-2022 Parity Technologies (UK) Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use crate::GenerateCode; +use core::cell::RefCell; +use derive_more::From; +use proc_macro2::TokenStream as TokenStream2; +use quote::quote; +use std::{ + path::PathBuf, + sync::Once, +}; + +/// We use this to only build the contract once for all tests. +static BUILD_ONCE: Once = Once::new(); + +// We save the name of the currently executing test here. +thread_local! { + pub static CONTRACT_PATH: RefCell> = RefCell::new(None); +} + +/// Returns the path to the contract bundle of the contract for which a test +/// is currently executed. +pub fn contract_path() -> Option { + CONTRACT_PATH.with(|metadata_path| metadata_path.borrow().clone()) +} + +/// Generates code for the `[ink::e2e_test]` macro. +#[derive(From)] +pub struct InkE2ETest<'a> { + /// The test function to generate code for. + test: &'a ir::InkE2ETest, +} + +impl GenerateCode for InkE2ETest<'_> { + /// Generates the code for `#[ink:e2e_test]`. + fn generate_code(&self) -> TokenStream2 { + #[cfg(clippy)] + if true { + return quote! {} + } + + let item_fn = &self.test.item_fn.item_fn; + let fn_name = &item_fn.sig.ident; + let block = &item_fn.block; + let fn_return_type = &item_fn.sig.output; + let vis = &item_fn.vis; + let attrs = &item_fn.attrs; + let ret = match fn_return_type { + syn::ReturnType::Default => quote! {}, + syn::ReturnType::Type(rarrow, ret_type) => quote! { #rarrow #ret_type }, + }; + + let ws_url = &self.test.config.ws_url(); + let node_log = &self.test.config.node_log(); + let skip_build = &self.test.config.skip_build(); + + // This path will only be used in case `skip_build` is activated + // and no path was specified for it. + // TODO(#xxx) we should require specifying a path for `skip_build`. + let mut path = PathBuf::from("./target/ink/metadata.json".to_string()); + + // If a prior test did already build the contract and set the path + // to the metadata file. + if let Some(metadata_path) = contract_path() { + path = metadata_path; + } + + if !skip_build.value && contract_path().is_none() { + BUILD_ONCE.call_once(|| { + env_logger::init(); + use std::process::{ + Command, + Stdio, + }; + let output = Command::new("cargo") + // TODO(#xxx) Add possibility of configuring `skip_linting` in attributes. + .args(["+stable", "contract", "build", "--skip-linting", "--output-json"]) + .env("RUST_LOG", "") + .stderr(Stdio::inherit()) + .output() + .expect("failed to execute `cargo-contract` build process"); + + log::info!("`cargo-contract` returned status: {}", output.status); + eprintln!("`cargo-contract` returned status: {}", output.status); + log::info!( + "`cargo-contract` stdout: {}", + String::from_utf8_lossy(&output.stdout) + ); + eprintln!( + "`cargo-contract` stdout: {}", + String::from_utf8_lossy(&output.stdout) + ); + if !output.status.success() { + log::info!( + "`cargo-contract` stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + eprintln!( + "`cargo-contract` stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + assert!(output.status.success()); + + let json = String::from_utf8_lossy(&output.stdout); + let metadata: serde_json::Value = + serde_json::from_str(&json).expect("cannot convert json to utf8"); + let mut dest_metadata = + metadata["metadata_result"]["dest_bundle"].to_string(); + dest_metadata = dest_metadata.trim_matches('"').to_string(); + path = PathBuf::from(dest_metadata); + log::info!("extracted metadata path: {}", path.display()); + + CONTRACT_PATH.with(|metadata_path| { + *metadata_path.borrow_mut() = Some(path.clone()); + }); + }); + } else { + BUILD_ONCE.call_once(|| { + env_logger::init(); + }); + } + + log::info!("using metadata path: {:?}", path); + + path.try_exists().unwrap_or_else(|err| { + panic!("path {:?} does not exist: {:?}", path, err); + }); + let os_path = path + .as_os_str() + .to_str() + .expect("converting path to str failed"); + let path = syn::LitStr::new(os_path, proc_macro2::Span::call_site()); + + quote! { + #( #attrs )* + #[ink::env::e2e::tokio::test] + async #vis fn #fn_name () #ret { + use ink::env::e2e::log_info; + ink::env::e2e::LOG_PREFIX.with(|log_prefix| { + let str = format!("test: {}", stringify!(#fn_name)); + *log_prefix.borrow_mut() = String::from(str); + }); + log_info("setting up e2e test"); + + ink::env::e2e::INIT.call_once(|| { + ink::env::e2e::env_logger::init(); + }); + + log_info("extracting metadata"); + // TODO(#xxx) `smart-bench_macro` needs to be forked. + ink::env::e2e::smart_bench_macro::contract!(#path); + + log_info("creating new client"); + + // TODO(#xxx) Make those two generic environments customizable. + let mut client = ink::env::e2e::Client::< + ink::env::e2e::PolkadotConfig, + ink::env::DefaultEnvironment + >::new(&#path, &#ws_url, &#node_log).await; + + let __ret = { + #block + }; + __ret + } + } + } +} + +impl GenerateCode for ir::InkE2ETest { + fn generate_code(&self) -> TokenStream2 { + InkE2ETest::from(self).generate_code() + } +} diff --git a/crates/ink/codegen/src/generator/mod.rs b/crates/ink/codegen/src/generator/mod.rs index 960a1b58dd7..eec4fab2044 100644 --- a/crates/ink/codegen/src/generator/mod.rs +++ b/crates/ink/codegen/src/generator/mod.rs @@ -34,6 +34,7 @@ mod contract; mod dispatch; mod env; mod events; +mod ink_e2e_test; mod ink_test; mod item_impls; mod metadata; @@ -59,6 +60,7 @@ pub use self::{ dispatch::Dispatch, env::Env, events::Events, + ink_e2e_test::InkE2ETest, ink_test::InkTest, item_impls::ItemImpls, metadata::Metadata, diff --git a/crates/ink/codegen/src/lib.rs b/crates/ink/codegen/src/lib.rs index df7d2148ff0..a61efef20ec 100644 --- a/crates/ink/codegen/src/lib.rs +++ b/crates/ink/codegen/src/lib.rs @@ -60,6 +60,10 @@ impl<'a> CodeGenerator for &'a ir::InkTraitDefinition { type Generator = generator::TraitDefinition<'a>; } +impl<'a> CodeGenerator for &'a ir::InkE2ETest { + type Generator = generator::InkE2ETest<'a>; +} + impl<'a> CodeGenerator for &'a ir::InkTest { type Generator = generator::InkTest<'a>; } diff --git a/crates/ink/ir/src/ir/e2e_config.rs b/crates/ink/ir/src/ir/e2e_config.rs new file mode 100644 index 00000000000..ff00a8bff0c --- /dev/null +++ b/crates/ink/ir/src/ir/e2e_config.rs @@ -0,0 +1,214 @@ +// Copyright 2018-2022 Parity Technologies (UK) Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use crate::{ + ast, + utils::{ + duplicate_config_err, + WhitelistedAttributes, + }, +}; + +/// The End-to-End test configuration. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct E2EConfig { + /// The path where the node writes its log. + node_log: Option, + /// The WebSocket URL where to connect with the node. + ws_url: Option, + /// Denotes if the contract should be build before executing the test. + skip_build: Option, + /// The set of attributes that can be passed to call builder in the codegen. + whitelisted_attributes: WhitelistedAttributes, +} + +impl TryFrom for E2EConfig { + type Error = syn::Error; + + fn try_from(args: ast::AttributeArgs) -> Result { + let mut node_log: Option<(syn::LitStr, ast::MetaNameValue)> = None; + let mut ws_url: Option<(syn::LitStr, ast::MetaNameValue)> = None; + let mut skip_build: Option<(syn::LitBool, ast::MetaNameValue)> = None; + let mut whitelisted_attributes = WhitelistedAttributes::default(); + + for arg in args.into_iter() { + if arg.name.is_ident("node_log") { + if let Some((_, ast)) = node_log { + return Err(duplicate_config_err(ast, arg, "node_log", "e2e test")) + } + if let ast::PathOrLit::Lit(syn::Lit::Str(lit_str)) = &arg.value { + node_log = Some((lit_str.clone(), arg)) + } else { + return Err(format_err_spanned!( + arg, + "expected a path for `node_log` ink! e2e test configuration argument", + )) + } + } else if arg.name.is_ident("ws_url") { + if let Some((_, ast)) = ws_url { + return Err(duplicate_config_err(ast, arg, "ws_url", "e2e test")) + } + if let ast::PathOrLit::Lit(syn::Lit::Str(lit_str)) = &arg.value { + ws_url = Some((lit_str.clone(), arg)) + } else { + return Err(format_err_spanned!( + arg, + "expected a string literal for `ws_url` ink! e2e test configuration argument", + )) + } + } else if arg.name.is_ident("skip_build") { + if let Some((_, ast)) = skip_build { + return Err(duplicate_config_err(ast, arg, "skip_build", "e2e test")) + } + if let ast::PathOrLit::Lit(syn::Lit::Bool(lit_bool)) = &arg.value { + skip_build = Some((lit_bool.clone(), arg)) + } else { + return Err(format_err_spanned!( + arg, + "expected a bool literal for `skip_build` ink! e2e test configuration argument", + )) + } + } else if arg.name.is_ident("keep_attr") { + whitelisted_attributes.parse_arg_value(&arg)?; + } else { + return Err(format_err_spanned!( + arg, + "encountered unknown or unsupported ink! configuration argument", + )) + } + } + Ok(E2EConfig { + node_log: node_log.map(|(value, _)| value), + ws_url: ws_url.map(|(value, _)| value), + skip_build: skip_build.map(|(value, _)| value), + whitelisted_attributes, + }) + } +} + +impl E2EConfig { + /// Returns the path to the node log if specified. + /// Otherwise returns the default path `/tmp/contracts-node.log`. + pub fn node_log(&self) -> syn::LitStr { + let default_node_log = + syn::LitStr::new("/tmp/contracts-node.log", proc_macro2::Span::call_site()); + self.node_log.clone().unwrap_or(default_node_log) + } + + /// Returns the WebSocket URL where to connect to the RPC endpoint + /// of the node, if specified. Otherwise returns the default URL + /// `ws://localhost:9944`. + pub fn ws_url(&self) -> syn::LitStr { + let default_ws_url = + syn::LitStr::new("ws://0.0.0.0:9944", proc_macro2::Span::call_site()); + self.ws_url.clone().unwrap_or(default_ws_url) + } + + /// Returns `true` if `skip_build = true` was configured. + /// Otherwise returns `false`. + pub fn skip_build(&self) -> syn::LitBool { + let default_skip_build = syn::LitBool::new(false, proc_macro2::Span::call_site()); + self.skip_build.clone().unwrap_or(default_skip_build) + } + + /// Return set of attributes that can be passed to call builder in the codegen. + pub fn whitelisted_attributes(&self) -> &WhitelistedAttributes { + &self.whitelisted_attributes + } +} + +/// The environmental types definition. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Environment { + /// The underlying Rust type. + pub path: syn::Path, +} + +impl Default for Environment { + fn default() -> Self { + Self { + path: syn::parse_quote! { ::ink_env::DefaultEnvironment }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Asserts that the given input configuration attribute argument are converted + /// into the expected ink! configuration or yields the expected error message. + fn assert_try_from( + input: ast::AttributeArgs, + expected: Result, + ) { + assert_eq!( + >::try_from(input) + .map_err(|err| err.to_string()), + expected.map_err(ToString::to_string), + ); + } + + #[test] + fn empty_config_works() { + assert_try_from(syn::parse_quote! {}, Ok(E2EConfig::default())) + } + + #[test] + fn unknown_arg_fails() { + assert_try_from( + syn::parse_quote! { unknown = argument }, + Err("encountered unknown or unsupported ink! configuration argument"), + ); + } + + #[test] + fn duplicate_args_fails() { + assert_try_from( + syn::parse_quote! { + skip_build = true, + skip_build = true, + }, + Err( + "encountered duplicate ink! e2e test `skip_build` configuration argument", + ), + ); + } + + #[test] + fn keep_attr_works() { + let mut attrs = WhitelistedAttributes::default(); + attrs.0.insert("foo".to_string(), ()); + attrs.0.insert("bar".to_string(), ()); + assert_try_from( + syn::parse_quote! { + keep_attr = "foo, bar" + }, + Ok(E2EConfig { + node_log: None, + ws_url: None, + whitelisted_attributes: attrs, + skip_build: None, + }), + ) + } + + #[test] + fn keep_attr_invalid_value_fails() { + assert_try_from( + syn::parse_quote! { keep_attr = 1u16 }, + Err("expected a string with attributes separated by `,`"), + ); + } +} diff --git a/crates/ink/ir/src/ir/ink_e2e_test.rs b/crates/ink/ir/src/ir/ink_e2e_test.rs new file mode 100644 index 00000000000..ab1b512c5de --- /dev/null +++ b/crates/ink/ir/src/ir/ink_e2e_test.rs @@ -0,0 +1,58 @@ +// Copyright 2018-2022 Parity Technologies (UK) Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use crate::{ + ast, + ir, + ir::idents_lint, +}; +use proc_macro2::TokenStream as TokenStream2; + +/// The End-to-End test with all required information. +pub struct InkE2ETest { + /// The function which was annotated. + pub item_fn: E2EFn, + /// The specified configuration. + pub config: ir::E2EConfig, +} + +/// The End-to-End test with all required information. +pub struct E2EFn { + /// The function which was annotated. + pub item_fn: syn::ItemFn, +} + +impl TryFrom for E2EFn { + type Error = syn::Error; + + fn try_from(item_fn: syn::ItemFn) -> Result { + idents_lint::ensure_no_ink_identifiers(&item_fn)?; + Ok(E2EFn { item_fn }) + } +} + +impl InkE2ETest { + /// Returns `Ok` if the test matches all requirements for an + /// ink! E2E test definition. + pub fn new(attrs: TokenStream2, input: TokenStream2) -> Result { + let config = syn::parse2::(attrs)?; + let e2e_config = ir::E2EConfig::try_from(config)?; + let item_fn = syn::parse2::(input)?; + let e2e_fn = E2EFn::try_from(item_fn)?; + Ok(Self { + item_fn: e2e_fn, + config: e2e_config, + }) + } +} diff --git a/crates/ink/ir/src/ir/mod.rs b/crates/ink/ir/src/ir/mod.rs index 1f09ca5f7af..bf255dea4ff 100644 --- a/crates/ink/ir/src/ir/mod.rs +++ b/crates/ink/ir/src/ir/mod.rs @@ -19,7 +19,9 @@ mod blake2; mod chain_extension; mod config; mod contract; +mod e2e_config; mod idents_lint; +mod ink_e2e_test; mod ink_test; mod item; mod item_impl; @@ -67,6 +69,8 @@ pub use self::{ }, config::Config, contract::Contract, + e2e_config::E2EConfig, + ink_e2e_test::InkE2ETest, ink_test::InkTest, item::{ Event, diff --git a/crates/ink/ir/src/lib.rs b/crates/ink/ir/src/lib.rs index 229f7c0b39c..cd43d5d4511 100644 --- a/crates/ink/ir/src/lib.rs +++ b/crates/ink/ir/src/lib.rs @@ -51,6 +51,7 @@ pub use self::{ Event, ExtensionId, ImplItem, + InkE2ETest, InkItem, InkItemTrait, InkTest, diff --git a/crates/ink/macro/src/ink_e2e_test.rs b/crates/ink/macro/src/ink_e2e_test.rs new file mode 100644 index 00000000000..7cb58319bd3 --- /dev/null +++ b/crates/ink/macro/src/ink_e2e_test.rs @@ -0,0 +1,29 @@ +// Copyright 2018-2022 Parity Technologies (UK) Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use ink_codegen::generate_code; +use proc_macro2::TokenStream as TokenStream2; +use syn::Result; + +pub fn generate(attr: TokenStream2, input: TokenStream2) -> TokenStream2 { + match generate_or_err(attr, input) { + Ok(tokens) => tokens, + Err(err) => err.to_compile_error(), + } +} + +pub fn generate_or_err(attr: TokenStream2, input: TokenStream2) -> Result { + let test_definition = ink_ir::InkE2ETest::new(attr, input)?; + Ok(generate_code(&test_definition)) +} diff --git a/crates/ink/macro/src/lib.rs b/crates/ink/macro/src/lib.rs index 3794d761119..b5b7e181302 100644 --- a/crates/ink/macro/src/lib.rs +++ b/crates/ink/macro/src/lib.rs @@ -17,6 +17,7 @@ extern crate proc_macro; mod blake2b; mod chain_extension; mod contract; +mod ink_e2e_test; mod ink_test; mod selector; mod storage; @@ -835,6 +836,136 @@ pub fn test(attr: TokenStream, item: TokenStream) -> TokenStream { ink_test::generate(attr.into(), item.into()).into() } +/// Defines an End-to-End test. +/// +/// The system requirements are: +/// +/// - A Substrate node with `pallet-contracts` running in the background. +/// You can e.g. use [`substrate-contracts-node`](https://github.com/paritytech/substrate-contracts-node) +/// and launch it with +/// `substrate-contracts-node -lerror,runtime::contracts=debug > /tmp/contracts-node.log 2>&1`. +/// - A `cargo-contract` installation that can build the contract. +/// +/// Before the test function is invoked the contract will have been build. Any errors +/// that occur during the contract build will prevent the test function from being +/// invoked. +/// +/// ## Header Arguments +/// +/// The `#[ink::e2e_test]` macro can be provided with some additional comma-separated +/// header arguments: +/// +/// - `ws_url: String` +/// +/// The `ws_url` denotes the WebSocket URL where to connect to the RPC +/// endpoint of the node. +/// +/// **Usage Example:** +/// ```no_compile +/// # // TODO(#xxx) Remove the `no_compile`. +/// type E2EResult = std::result::Result>; +/// #[ink::e2e_test(ws_url = "ws://localhost:9944")] +/// async fn e2e_contract_must_transfer_value_to_sender( +/// mut client: ink::env::e2e::Client, +/// ) -> E2EResult<()> { +/// Ok(()) +/// } +/// ``` +/// +/// **Default value:** `"ws://localhost:9944"`. +/// +/// - `node_log: String` +/// +/// The `node_log` denotes the path under which to find the node's log. +/// +/// **Usage Example:** +/// ```no_compile +/// # // TODO(#xxx) Remove the `no_compile`. +/// type E2EResult = std::result::Result>; +/// #[ink::e2e_test(ws_url = "ws://localhost:9944")] +/// async fn e2e_contract_must_transfer_value_to_sender( +/// mut client: ink::env::e2e::Client, +/// ) -> E2EResult<()> { +/// assert!(client.node_log_contains("requested value: 100000000000000\n")); +/// Ok(()) +/// } +/// ``` +/// +/// **Default value:** `"/tmp/contracts-node.log"`. +/// +/// - `skip_build: true` +/// +/// Skips building the contract as part of the test. This is handy for debugging +/// test logic, when one wants to avoid the overhead of building the contract. +/// +/// **Usage Example:** +/// ```no_compile +/// # // TODO(#xxx) Remove the `no_compile`. +/// type E2EResult = std::result::Result>; +/// #[ink::e2e_test(skip_build = true)] +/// async fn e2e_contract_must_transfer_value_to_sender( +/// mut client: ink::env::e2e::Client, +/// ) -> E2EResult<()> { +/// Ok(()) +/// } +/// ``` +/// +/// **Default value:** `false`. +/// +/// # Example +/// +/// ```no_compile +/// # // TODO(#xxx) Remove the `no_compile`. +/// #[cfg(test)] +/// mod tests { +/// use ink::env::e2e::*; +/// type E2EResult = std::result::Result>; +/// +/// #[ink::e2e_test(skip_build = true)] +/// async fn e2e_test_2(mut client: ink::env::e2e::Client) -> E2EResult<()> { +/// // given +/// let constructor = contract_transfer::constructors::new(); +/// let contract_acc_id = client.instantiate( +/// &mut ink::env::e2e::alice(), +/// constructor, +/// 1337, +/// None, +/// ) +/// .await +/// .expect("instantiating contract failed") +/// .account_id; +/// +/// // when +/// let transfer = contract_transfer::messages::give_me(120); +/// let call_res = client.call( +/// &mut ink::env::e2e::bob(), +/// contract_acc_id.clone(), +/// transfer.into(), +/// 10, +/// None, +/// ) +/// .await; +/// +/// // then +/// assert!(call_res.is_ok()); +/// Ok(()) +/// } +/// } +/// ``` +/// +/// You can also use build the `Signer` type yourself, without going through +/// the pre-defined functions: +/// +/// ```no_compile +/// let mut bob = ink::env::e2e::PairSigner::new( +/// ink::env::e2e::AccountKeyring::Bob.pair() +/// ); +/// ``` +#[proc_macro_attribute] +pub fn e2e_test(attr: TokenStream, item: TokenStream) -> TokenStream { + ink_e2e_test::generate(attr.into(), item.into()).into() +} + /// Defines the interface for a chain extension. /// /// # Structure diff --git a/crates/ink/src/lib.rs b/crates/ink/src/lib.rs index ab9ded05289..f4302d2c8a7 100644 --- a/crates/ink/src/lib.rs +++ b/crates/ink/src/lib.rs @@ -61,6 +61,7 @@ pub use ink_macro::{ blake2x256, chain_extension, contract, + e2e_test, selector_bytes, selector_id, storage_item, diff --git a/crates/ink/tests/ui/contract/fail/constructor-input-non-codec.stderr b/crates/ink/tests/ui/contract/fail/constructor-input-non-codec.stderr index ddc49758a51..e14e589058d 100644 --- a/crates/ink/tests/ui/contract/fail/constructor-input-non-codec.stderr +++ b/crates/ink/tests/ui/contract/fail/constructor-input-non-codec.stderr @@ -8,6 +8,7 @@ error[E0277]: the trait bound `NonCodecType: WrapperTypeDecode` is not satisfied Arc Box Rc + sp_core::Bytes = note: required because of the requirements on the impl of `parity_scale_codec::Decode` for `NonCodecType` note: required by a bound in `DispatchInput` --> src/codegen/dispatch/type_check.rs @@ -25,6 +26,7 @@ error[E0277]: the trait bound `NonCodecType: WrapperTypeDecode` is not satisfied Arc Box Rc + sp_core::Bytes = note: required because of the requirements on the impl of `parity_scale_codec::Decode` for `NonCodecType` error[E0277]: the trait bound `NonCodecType: WrapperTypeEncode` is not satisfied @@ -42,7 +44,7 @@ error[E0277]: the trait bound `NonCodecType: WrapperTypeEncode` is not satisfied Rc String Vec - parity_scale_codec::Ref<'a, T, U> + and 2 others = note: required because of the requirements on the impl of `Encode` for `NonCodecType` note: required by a bound in `ExecutionInput::>::push_arg` --> $WORKSPACE/crates/env/src/call/execution_input.rs diff --git a/crates/ink/tests/ui/contract/fail/constructor-self-receiver-03.stderr b/crates/ink/tests/ui/contract/fail/constructor-self-receiver-03.stderr index e836a609d24..2b9e18abcb5 100644 --- a/crates/ink/tests/ui/contract/fail/constructor-self-receiver-03.stderr +++ b/crates/ink/tests/ui/contract/fail/constructor-self-receiver-03.stderr @@ -32,4 +32,5 @@ error[E0277]: the trait bound `&'static Contract: WrapperTypeDecode` is not sati Arc Box Rc + sp_core::Bytes = note: required because of the requirements on the impl of `parity_scale_codec::Decode` for `&'static Contract` diff --git a/crates/ink/tests/ui/contract/fail/message-input-non-codec.stderr b/crates/ink/tests/ui/contract/fail/message-input-non-codec.stderr index f2bd868f224..c0134ae7cc4 100644 --- a/crates/ink/tests/ui/contract/fail/message-input-non-codec.stderr +++ b/crates/ink/tests/ui/contract/fail/message-input-non-codec.stderr @@ -8,6 +8,7 @@ error[E0277]: the trait bound `NonCodecType: WrapperTypeDecode` is not satisfied Arc Box Rc + sp_core::Bytes = note: required because of the requirements on the impl of `parity_scale_codec::Decode` for `NonCodecType` note: required by a bound in `DispatchInput` --> src/codegen/dispatch/type_check.rs @@ -25,6 +26,7 @@ error[E0277]: the trait bound `NonCodecType: WrapperTypeDecode` is not satisfied Arc Box Rc + sp_core::Bytes = note: required because of the requirements on the impl of `parity_scale_codec::Decode` for `NonCodecType` error[E0277]: the trait bound `NonCodecType: WrapperTypeEncode` is not satisfied @@ -42,7 +44,7 @@ error[E0277]: the trait bound `NonCodecType: WrapperTypeEncode` is not satisfied Rc String Vec - parity_scale_codec::Ref<'a, T, U> + and 2 others = note: required because of the requirements on the impl of `Encode` for `NonCodecType` note: required by a bound in `ExecutionInput::>::push_arg` --> $WORKSPACE/crates/env/src/call/execution_input.rs diff --git a/crates/ink/tests/ui/contract/fail/message-returns-non-codec.stderr b/crates/ink/tests/ui/contract/fail/message-returns-non-codec.stderr index 64be580cf02..113b0cc1915 100644 --- a/crates/ink/tests/ui/contract/fail/message-returns-non-codec.stderr +++ b/crates/ink/tests/ui/contract/fail/message-returns-non-codec.stderr @@ -13,7 +13,7 @@ error[E0277]: the trait bound `NonCodecType: WrapperTypeEncode` is not satisfied Rc String Vec - parity_scale_codec::Ref<'a, T, U> + and 2 others = note: required because of the requirements on the impl of `Encode` for `NonCodecType` note: required by a bound in `DispatchOutput` --> src/codegen/dispatch/type_check.rs @@ -36,7 +36,7 @@ error[E0277]: the trait bound `NonCodecType: WrapperTypeEncode` is not satisfied Rc String Vec - parity_scale_codec::Ref<'a, T, U> + and 2 others = note: required because of the requirements on the impl of `Encode` for `NonCodecType` note: required by a bound in `return_value` --> $WORKSPACE/crates/env/src/api.rs diff --git a/crates/ink/tests/ui/storage_item/fail/packed_is_not_derived_automatically.stderr b/crates/ink/tests/ui/storage_item/fail/packed_is_not_derived_automatically.stderr index 37f41696eac..ede532fa136 100644 --- a/crates/ink/tests/ui/storage_item/fail/packed_is_not_derived_automatically.stderr +++ b/crates/ink/tests/ui/storage_item/fail/packed_is_not_derived_automatically.stderr @@ -8,6 +8,7 @@ error[E0277]: the trait bound `NonPacked: WrapperTypeDecode` is not satisfied Arc Box Rc + sp_core::Bytes = note: required because of the requirements on the impl of `parity_scale_codec::Decode` for `NonPacked` = note: required because of the requirements on the impl of `Packed` for `NonPacked` note: required by a bound in `consume_packed` @@ -31,7 +32,7 @@ error[E0277]: the trait bound `NonPacked: WrapperTypeEncode` is not satisfied Rc String Vec - parity_scale_codec::Ref<'a, T, U> + and 2 others = note: required because of the requirements on the impl of `Encode` for `NonPacked` = note: required because of the requirements on the impl of `Packed` for `NonPacked` note: required by a bound in `consume_packed` diff --git a/crates/ink/tests/ui/trait_def/fail/message_input_non_codec.stderr b/crates/ink/tests/ui/trait_def/fail/message_input_non_codec.stderr index 1cc4417dedf..f5ed42f793a 100644 --- a/crates/ink/tests/ui/trait_def/fail/message_input_non_codec.stderr +++ b/crates/ink/tests/ui/trait_def/fail/message_input_non_codec.stderr @@ -8,6 +8,7 @@ error[E0277]: the trait bound `NonCodec: WrapperTypeDecode` is not satisfied Arc Box Rc + sp_core::Bytes = note: required because of the requirements on the impl of `parity_scale_codec::Decode` for `NonCodec` note: required by a bound in `DispatchInput` --> src/codegen/dispatch/type_check.rs @@ -30,7 +31,7 @@ error[E0277]: the trait bound `NonCodec: WrapperTypeEncode` is not satisfied Rc String Vec - parity_scale_codec::Ref<'a, T, U> + and 2 others = note: required because of the requirements on the impl of `Encode` for `NonCodec` note: required by a bound in `ExecutionInput::>::push_arg` --> $WORKSPACE/crates/env/src/call/execution_input.rs diff --git a/crates/ink/tests/ui/trait_def/fail/message_output_non_codec.stderr b/crates/ink/tests/ui/trait_def/fail/message_output_non_codec.stderr index 96a3827b390..9891d5dd4f6 100644 --- a/crates/ink/tests/ui/trait_def/fail/message_output_non_codec.stderr +++ b/crates/ink/tests/ui/trait_def/fail/message_output_non_codec.stderr @@ -13,7 +13,7 @@ error[E0277]: the trait bound `NonCodec: WrapperTypeEncode` is not satisfied Rc String Vec - parity_scale_codec::Ref<'a, T, U> + and 2 others = note: required because of the requirements on the impl of `Encode` for `NonCodec` note: required by a bound in `DispatchOutput` --> src/codegen/dispatch/type_check.rs diff --git a/examples/contract-transfer/lib.rs b/examples/contract-transfer/lib.rs index 0e159e9b159..7667911d251 100644 --- a/examples/contract-transfer/lib.rs +++ b/examples/contract-transfer/lib.rs @@ -179,4 +179,89 @@ pub mod give_me { .expect("Cannot get account balance") } } + + #[cfg(test)] + mod e2e_tests { + use super::*; + use ink::env::e2e::*; + + type E2EResult = std::result::Result>; + + #[ink::e2e_test] + async fn e2e_sending_value_to_give_me_must_fail( + mut client: ink::env::e2e::Client, + ) -> E2EResult<()> { + // given + let constructor = contract_transfer::constructors::new(); + let contract_acc_id = client + .instantiate(&mut ink::env::e2e::alice(), constructor, 1000, None) + .await + .expect("instantiate failed") + .account_id; + + // when + let transfer = contract_transfer::messages::give_me(120); + let call_res = client + .call( + &mut ink::env::e2e::bob(), + contract_acc_id.clone(), + transfer.into(), + 10, + None, + ) + .await; + + // then + assert!(call_res.is_err()); + let contains_err_msg = match call_res.unwrap_err() { + ink::env::e2e::Error::CallDryRun(dry_run) => { + String::from_utf8_lossy(&dry_run.debug_message) + .contains("paid an unpayable message") + } + _ => false, + }; + assert!(contains_err_msg); + Ok(()) + } + + #[ink::e2e_test] + async fn e2e_contract_must_transfer_value_to_sender( + mut client: ink::env::e2e::Client, + ) -> E2EResult<()> { + // given + let constructor = contract_transfer::constructors::new(); + let contract_acc_id = client + .instantiate(&mut ink::env::e2e::bob(), constructor, 1337, None) + .await + .expect("instantiate failed") + .account_id; + let balance_before: Balance = client + .balance(contract_acc_id.clone()) + .await + .expect("getting balance failed"); + + // when + let transfer = contract_transfer::messages::give_me(120); + let _ = client + .call( + &mut ink::env::e2e::eve(), + contract_acc_id.clone(), + transfer.into(), + 0, + None, + ) + .await + .expect("call failed"); + + // then + let balance_after: Balance = client + .balance(contract_acc_id) + .await + .expect("getting balance failed"); + assert_eq!(balance_before - balance_after, 120); + assert!(client.node_log_contains("requested value: 100000000000000\n")); + + Ok(()) + } + } }