diff --git a/.github/workflows/rust-checks.yml b/.github/workflows/rust-checks.yml index d985042..3380162 100644 --- a/.github/workflows/rust-checks.yml +++ b/.github/workflows/rust-checks.yml @@ -59,6 +59,8 @@ jobs: - name: Run tests for examples shell: bash run: | + # todo: use loop xD + pushd examples/flipper cargo contract build --release cargo test --release @@ -73,3 +75,8 @@ jobs: cargo contract build --release cargo test --release popd + + pushd examples/mocking + cargo contract build --release + cargo test --release + popd diff --git a/Cargo.lock b/Cargo.lock index c98ef8e..66f3106 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1015,7 +1015,7 @@ checksum = "9ea835d29036a4087793836fa931b08837ad5e957da9e23886b29586fb9b6650" [[package]] name = "drink" -version = "0.4.1" +version = "0.5.0" dependencies = [ "contract-transcode", "frame-metadata", @@ -1037,7 +1037,7 @@ dependencies = [ [[package]] name = "drink-cli" -version = "0.4.1" +version = "0.5.0" dependencies = [ "anyhow", "clap", @@ -2478,9 +2478,9 @@ dependencies = [ [[package]] name = "pallet-contracts-for-drink" -version = "22.0.0" +version = "22.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ef4598e638732ef5487b972c68b3b521d2953436cb3dd51b575ed6cbd16783f" +checksum = "3f039ee77b4ff6ab8b4e427bab636dd23ba1257ed631616c98c0859a83a5e690" dependencies = [ "bitflags", "environmental", diff --git a/Cargo.toml b/Cargo.toml index 17368cf..df9e241 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ exclude = [ "examples/counter", "examples/flipper", "examples/cross-contract-call-tracing", + "examples/mocking", ] [workspace.package] @@ -19,7 +20,7 @@ homepage = "https://github.com/Cardinal-Cryptography/drink" license = "Apache-2.0" readme = "README.md" repository = "https://github.com/Cardinal-Cryptography/drink" -version = "0.4.1" +version = "0.5.0" [workspace.dependencies] anyhow = { version = "1.0.71" } @@ -40,7 +41,7 @@ frame-metadata = { version = "16.0.0" } frame-support = { version = "23.0.0" } frame-system = { version = "23.0.0" } pallet-balances = { version = "23.0.0" } -pallet-contracts = { package = "pallet-contracts-for-drink", version = "22.0.0" } +pallet-contracts = { package = "pallet-contracts-for-drink", version = "22.0.1" } pallet-contracts-primitives = { version = "26.0.0" } pallet-timestamp = { version = "22.0.0" } sp-core = { version = "23.0.0" } @@ -50,4 +51,4 @@ sp-runtime-interface = { version = "19.0.0" } # Local dependencies -drink = { version = "0.4.1", path = "drink" } +drink = { version = "0.5.0", path = "drink" } diff --git a/drink/Cargo.toml b/drink/Cargo.toml index 3cb6fb7..52a9097 100644 --- a/drink/Cargo.toml +++ b/drink/Cargo.toml @@ -26,8 +26,6 @@ sp-runtime-interface = { workspace = true } scale-info = { workspace = true } thiserror = { workspace = true } - -[dev-dependencies] wat = { workspace = true } [features] diff --git a/drink/src/error.rs b/drink/src/error.rs deleted file mode 100644 index f1fdcf2..0000000 --- a/drink/src/error.rs +++ /dev/null @@ -1,15 +0,0 @@ -use thiserror::Error; - -/// Main error type for the drink crate. -#[derive(Error, Debug)] -pub enum Error { - /// Externalities could not be initialized. - #[error("Failed to build storage: {0}")] - StorageBuilding(String), - /// Block couldn't have been initialized. - #[error("Failed to initialize block: {0}")] - BlockInitialize(String), - /// Block couldn't have been finalized. - #[error("Failed to finalize block: {0}")] - BlockFinalize(String), -} diff --git a/drink/src/errors.rs b/drink/src/errors.rs new file mode 100644 index 0000000..915126a --- /dev/null +++ b/drink/src/errors.rs @@ -0,0 +1,43 @@ +//! Module gathering common error and result types. + +use thiserror::Error; + +/// Main error type for the drink crate. +#[derive(Error, Debug)] +pub enum Error { + /// Externalities could not be initialized. + #[error("Failed to build storage: {0}")] + StorageBuilding(String), + /// Block couldn't have been initialized. + #[error("Failed to initialize block: {0}")] + BlockInitialize(String), + /// Block couldn't have been finalized. + #[error("Failed to finalize block: {0}")] + BlockFinalize(String), +} + +/// Every contract message wraps its return value in `Result`. This is the error +/// type. +/// +/// Copied from ink primitives. +#[non_exhaustive] +#[repr(u32)] +#[derive( + Debug, + Copy, + Clone, + PartialEq, + Eq, + parity_scale_codec::Encode, + parity_scale_codec::Decode, + scale_info::TypeInfo, + Error, +)] +pub enum LangError { + /// Failed to read execution input for the dispatchable. + #[error("Failed to read execution input for the dispatchable.")] + CouldNotReadInput = 1u32, +} + +/// The `Result` type for ink! messages. +pub type MessageResult = Result; diff --git a/drink/src/lib.rs b/drink/src/lib.rs index 90655a0..a7904ac 100644 --- a/drink/src/lib.rs +++ b/drink/src/lib.rs @@ -5,24 +5,38 @@ pub mod chain_api; pub mod contract_api; -mod error; +pub mod errors; +mod mock; pub mod runtime; #[cfg(feature = "session")] pub mod session; -use std::marker::PhantomData; -pub use error::Error; +use std::{ + marker::PhantomData, + sync::{Arc, Mutex}, +}; + +pub use errors::Error; use frame_support::sp_runtime::{traits::One, BuildStorage}; pub use frame_support::{ sp_runtime::{AccountId32, DispatchError}, weights::Weight, }; use frame_system::{pallet_prelude::BlockNumberFor, EventRecord, GenesisConfig}; +pub use mock::{mock_message, ContractMock, MessageMock, MockedCallResult, MockingApi, Selector}; +use pallet_contracts::debug::ExecResult; +use pallet_contracts_primitives::{ExecReturnValue, ReturnFlags}; +use parity_scale_codec::{Decode, Encode}; use sp_io::TestExternalities; use crate::{ - pallet_contracts_debugging::DebugExt, - runtime::{pallet_contracts_debugging::NoopDebugExt, *}, + errors::MessageResult, + mock::MockRegistry, + pallet_contracts_debugging::{InterceptingExt, TracingExt}, + runtime::{ + pallet_contracts_debugging::{InterceptingExtT, NoopExt}, + *, + }, }; /// Main result type for the drink crate. @@ -35,6 +49,8 @@ pub type EventRecordOf = /// A sandboxed runtime. pub struct Sandbox { externalities: TestExternalities, + mock_registry: Arc>>>, + mock_counter: usize, _phantom: PhantomData, } @@ -57,6 +73,8 @@ impl Sandbox { let mut sandbox = Self { externalities: TestExternalities::new(storage), + mock_registry: Arc::new(Mutex::new(MockRegistry::new())), + mock_counter: 0, _phantom: PhantomData, }; @@ -68,7 +86,9 @@ impl Sandbox { .map_err(Error::BlockInitialize)?; // We register a noop debug extension by default. - sandbox.override_debug_handle(DebugExt(Box::new(NoopDebugExt {}))); + sandbox.override_debug_handle(TracingExt(Box::new(NoopExt {}))); + + sandbox.setup_mock_extension(); Ok(sandbox) } @@ -77,7 +97,75 @@ impl Sandbox { /// /// By default, a new `Sandbox` instance is created with a noop debug extension. This method /// allows to override it with a custom debug extension. - pub fn override_debug_handle(&mut self, d: DebugExt) { + pub fn override_debug_handle(&mut self, d: TracingExt) { self.externalities.register_extension(d); } + + /// Registers the extension for intercepting calls to contracts. + fn setup_mock_extension(&mut self) { + self.externalities + .register_extension(InterceptingExt(Box::new(MockingExtension { + mock_registry: Arc::clone(&self.mock_registry), + }))); + } +} + +/// Runtime extension enabling contract call interception. +struct MockingExtension { + /// Mock registry, shared with the sandbox. + /// + /// Potentially the runtime is executed in parallel and thus we need to wrap the registry in + /// `Arc` instead of `Rc`. + mock_registry: Arc>>, +} + +impl InterceptingExtT for MockingExtension { + fn intercept_call( + &self, + contract_address: Vec, + _is_call: bool, + input_data: Vec, + ) -> Vec { + let contract_address = Decode::decode(&mut &contract_address[..]) + .expect("Contract address should be decodable"); + + match self + .mock_registry + .lock() + .expect("Should be able to acquire registry") + .get(&contract_address) + { + // There is no mock registered for this address, so we return `None` to indicate that + // the call should be executed normally. + None => None::<()>.encode(), + // We intercept the call and return the result of the mock. + Some(mock) => { + let (selector, call_data) = input_data.split_at(4); + let selector: Selector = selector + .try_into() + .expect("Input data should contain at least selector bytes"); + + let result = mock + .call(selector, call_data.to_vec()) + .expect("TODO: let the user define the fallback mechanism"); + + // Although we don't know the exact type, thanks to the SCALE encoding we know + // that `()` will always succeed (we only care about the `Ok`/`Err` distinction). + let decoded_result: MessageResult<()> = + Decode::decode(&mut &result[..]).expect("Mock result should be decodable"); + + let flags = match decoded_result { + Ok(_) => ReturnFlags::empty(), + Err(_) => ReturnFlags::REVERT, + }; + + let result: ExecResult = Ok(ExecReturnValue { + flags, + data: result, + }); + + Some(result).encode() + } + } + } } diff --git a/drink/src/mock.rs b/drink/src/mock.rs new file mode 100644 index 0000000..06bebea --- /dev/null +++ b/drink/src/mock.rs @@ -0,0 +1,36 @@ +mod contract; +mod error; +mod mocking_api; + +use std::collections::BTreeMap; + +pub use contract::{mock_message, ContractMock, MessageMock, Selector}; +use error::MockingError; +pub use mocking_api::MockingApi; + +/// Untyped result of a mocked call. +pub type MockedCallResult = Result, MockingError>; + +/// A registry of mocked contracts. +pub(crate) struct MockRegistry { + mocked_contracts: BTreeMap, +} + +impl MockRegistry { + /// Creates a new registry. + pub fn new() -> Self { + Self { + mocked_contracts: BTreeMap::new(), + } + } + + /// Registers `mock` for `address`. Returns the previous mock, if any. + pub fn register(&mut self, address: AccountId, mock: ContractMock) -> Option { + self.mocked_contracts.insert(address, mock) + } + + /// Returns the mock for `address`, if any. + pub fn get(&self, address: &AccountId) -> Option<&ContractMock> { + self.mocked_contracts.get(address) + } +} diff --git a/drink/src/mock/contract.rs b/drink/src/mock/contract.rs new file mode 100644 index 0000000..525585a --- /dev/null +++ b/drink/src/mock/contract.rs @@ -0,0 +1,64 @@ +use std::collections::BTreeMap; + +use parity_scale_codec::{Decode, Encode}; + +use crate::{ + errors::LangError, + mock::{error::MockingError, MockedCallResult}, +}; + +/// Alias for a 4-byte selector. +pub type Selector = [u8; 4]; +/// An untyped message mock. +/// +/// Notice that in the end, we cannot operate on specific argument/return types. Rust won't let us +/// have a collection of differently typed closures. Fortunately, we can assume that all types are +/// en/decodable, so we can use `Vec` as a common denominator. +pub type MessageMock = Box) -> MockedCallResult + Send + Sync>; + +/// A contract mock. +pub struct ContractMock { + messages: BTreeMap, +} + +impl ContractMock { + /// Creates a new mock without any message. + pub fn new() -> Self { + Self { + messages: BTreeMap::new(), + } + } + + /// Adds a message mock. + pub fn with_message(mut self, selector: Selector, message: MessageMock) -> Self { + self.messages.insert(selector, message); + self + } + + /// Try to call a message mock. Returns an error if there is no message mock for `selector`. + pub fn call(&self, selector: Selector, input: Vec) -> MockedCallResult { + match self.messages.get(&selector) { + None => Err(MockingError::MessageNotFound(selector)), + Some(message) => message(input), + } + } +} + +impl Default for ContractMock { + fn default() -> Self { + Self::new() + } +} + +/// A helper function to create a message mock out of a typed closure. +/// +/// In particular, it takes care of decoding the input and encoding the output. Also, wraps the +/// return value in a `Result`, which is normally done implicitly by ink!. +pub fn mock_message Ret + Send + Sync + 'static>( + body: Body, +) -> MessageMock { + Box::new(move |encoded_input| { + let input = Decode::decode(&mut &*encoded_input).map_err(MockingError::ArgumentDecoding)?; + Ok(Ok::(body(input)).encode()) + }) +} diff --git a/drink/src/mock/error.rs b/drink/src/mock/error.rs new file mode 100644 index 0000000..d526af1 --- /dev/null +++ b/drink/src/mock/error.rs @@ -0,0 +1,12 @@ +use thiserror::Error; + +use crate::Selector; + +/// Error type for mocking operations. +#[derive(Error, Debug)] +pub enum MockingError { + #[error("Message not found (unknown selector: {0:?})")] + MessageNotFound(Selector), + #[error("Decoding message arguments failed: {0:?}")] + ArgumentDecoding(parity_scale_codec::Error), +} diff --git a/drink/src/mock/mocking_api.rs b/drink/src/mock/mocking_api.rs new file mode 100644 index 0000000..92dbf04 --- /dev/null +++ b/drink/src/mock/mocking_api.rs @@ -0,0 +1,61 @@ +use crate::{ + contract_api::ContractApi, + mock::ContractMock, + runtime::{AccountIdFor, Runtime}, + Sandbox, DEFAULT_GAS_LIMIT, +}; + +/// Interface for basic mocking operations. +pub trait MockingApi { + /// Deploy `mock` as a standard contract. Returns the address of the deployed contract. + fn deploy(&mut self, mock: ContractMock) -> AccountIdFor; + + /// Mock part of an existing contract. In particular, allows to override real behavior of + /// deployed contract's messages. + fn mock_existing_contract(&mut self, mock: ContractMock, address: AccountIdFor); +} + +impl MockingApi for Sandbox { + fn deploy(&mut self, mock: ContractMock) -> AccountIdFor { + // We have to deploy some contract. We use a dummy contract for that. Thanks to that, we + // ensure that the pallet will treat our mock just as a regular contract, until we actually + // call it. + let mock_bytes = wat::parse_str(DUMMY_CONTRACT).expect("Dummy contract should be valid"); + let mock_address = self + .deploy_contract( + mock_bytes, + 0, + vec![], + // We have to use a different account ID for each contract. + vec![self.mock_counter as u8], + R::default_actor(), + DEFAULT_GAS_LIMIT, + None, + ) + .result + .expect("Deployment of a dummy contract should succeed") + .account_id; + + self.mock_counter += 1; + self.mock_registry + .lock() + .expect("Should be able to acquire lock on registry") + .register(mock_address.clone(), mock); + + mock_address + } + + fn mock_existing_contract(&mut self, _mock: ContractMock, _address: AccountIdFor) { + todo!("soon") + } +} + +/// A dummy contract that is used to deploy a mock. +/// +/// Has a single noop constructor and a single panicking message. +const DUMMY_CONTRACT: &str = r#" +(module + (import "env" "memory" (memory 1 1)) + (func (export "deploy")) + (func (export "call") (unreachable)) +)"#; diff --git a/drink/src/runtime/pallet_contracts_debugging.rs b/drink/src/runtime/pallet_contracts_debugging.rs index 6fc871e..d3027e6 100644 --- a/drink/src/runtime/pallet_contracts_debugging.rs +++ b/drink/src/runtime/pallet_contracts_debugging.rs @@ -16,107 +16,16 @@ //! //! # Passing objects between runtime and runtime extension //! -//! Unfortunately, runtime interface that lies between runtime and the end-user accepts only -//! very simple argument types and those that implement some specific traits. This means that -//! usually, complex objects will be passed in their encoded form (`Vec` obtained with scale -//! encoding). +//! Unfortunately, runtime interface that lies between runtime, and the end-user accepts only +//! simple argument types, and those that implement some specific traits. This means that usually, +//! complex objects will be passed in their encoded form (`Vec` obtained with scale encoding). -use pallet_contracts::debug::{CallInterceptor, CallSpan, ExecResult, ExportedFunction, Tracing}; -use pallet_contracts_primitives::ExecReturnValue; -use sp_externalities::{decl_extension, ExternalitiesExt}; -use sp_runtime_interface::runtime_interface; +mod intercepting; +mod runtime; +mod tracing; -use crate::runtime::{AccountIdFor, Runtime}; +pub use runtime::{InterceptingExt, InterceptingExtT, NoopExt, TracingExt, TracingExtT}; -/// The trait that allows injecting custom logic to handle contract debugging directly in the -/// contracts pallet. -pub trait DebugExtT { - /// Called after a contract call is made. - fn after_call( - &self, - _contract_address: Vec, - _is_call: bool, - _input_data: Vec, - _result: Vec, - ) { - } -} - -decl_extension! { - /// A wrapper type for the `DebugExtT` debug extension. - pub struct DebugExt(Box); -} - -/// The simplest debug extension - does nothing. -pub struct NoopDebugExt {} -impl DebugExtT for NoopDebugExt {} - -#[runtime_interface] -trait ContractCallDebugger { - fn after_call( - &mut self, - contract_address: Vec, - is_call: bool, - input_data: Vec, - result: Vec, - ) { - self.extension::() - .expect("Failed to find `DebugExt` extension") - .after_call(contract_address, is_call, input_data, result); - } -} - -/// Configuration parameter for the contracts pallet. Provides all the necessary trait -/// implementations. +/// Main configuration parameter for the contracts pallet debugging. Provides all the necessary +/// trait implementations. pub enum DrinkDebug {} - -impl CallInterceptor for DrinkDebug { - fn intercept_call( - _contract_address: &AccountIdFor, - _entry_point: &ExportedFunction, - _input_data: &[u8], - ) -> Option { - // We don't want to intercept any calls. At least for now. - None - } -} - -impl Tracing for DrinkDebug { - type CallSpan = DrinkCallSpan>; - - fn new_call_span( - contract_address: &AccountIdFor, - entry_point: ExportedFunction, - input_data: &[u8], - ) -> Self::CallSpan { - DrinkCallSpan { - contract_address: contract_address.clone(), - entry_point, - input_data: input_data.to_vec(), - } - } -} - -/// A contract's call span. -/// -/// It is created just before the call is made and `Self::after_call` is called after the call is -/// done. -pub struct DrinkCallSpan { - /// The address of the contract that has been called. - pub contract_address: AccountId, - /// The entry point that has been called (either constructor or call). - pub entry_point: ExportedFunction, - /// The input data of the call. - pub input_data: Vec, -} - -impl CallSpan for DrinkCallSpan { - fn after_call(self, output: &ExecReturnValue) { - contract_call_debugger::after_call( - self.contract_address.encode(), - matches!(self.entry_point, ExportedFunction::Call), - self.input_data.to_vec(), - output.data.clone(), - ); - } -} diff --git a/drink/src/runtime/pallet_contracts_debugging/intercepting.rs b/drink/src/runtime/pallet_contracts_debugging/intercepting.rs new file mode 100644 index 0000000..57aa90d --- /dev/null +++ b/drink/src/runtime/pallet_contracts_debugging/intercepting.rs @@ -0,0 +1,25 @@ +use pallet_contracts::debug::{CallInterceptor, ExecResult, ExportedFunction}; +use parity_scale_codec::{Decode, Encode}; + +use crate::runtime::{ + pallet_contracts_debugging::{runtime::contract_call_debugger, DrinkDebug}, + AccountIdFor, Runtime, +}; + +impl CallInterceptor for DrinkDebug { + fn intercept_call( + contract_address: &AccountIdFor, + entry_point: &ExportedFunction, + input_data: &[u8], + ) -> Option { + // Pass the data to the runtime interface. The data must be encoded (only simple types are + // supported). + let intercepting_result = contract_call_debugger::intercept_call( + contract_address.encode(), + matches!(*entry_point, ExportedFunction::Call), + input_data.to_vec(), + ); + + Decode::decode(&mut intercepting_result.as_slice()).expect("Decoding should succeed") + } +} diff --git a/drink/src/runtime/pallet_contracts_debugging/runtime.rs b/drink/src/runtime/pallet_contracts_debugging/runtime.rs new file mode 100644 index 0000000..9b31160 --- /dev/null +++ b/drink/src/runtime/pallet_contracts_debugging/runtime.rs @@ -0,0 +1,79 @@ +use parity_scale_codec::Encode; +use sp_externalities::{decl_extension, ExternalitiesExt}; +use sp_runtime_interface::runtime_interface; + +/// Contracts pallet outsources debug callbacks through this runtime interface. +/// +/// Essentially, in our case, it just exposes extensions to the runtime. +/// +/// At this level, data passed back/forth must be either primitive or implement some specific +/// traits. For simplicity, we just go with primitives and codec encoded data. +#[runtime_interface] +pub trait ContractCallDebugger { + fn after_call( + &mut self, + contract_address: Vec, + is_call: bool, + input_data: Vec, + result: Vec, + ) { + self.extension::() + .expect("Failed to find `DebugExt` extension") + .after_call(contract_address, is_call, input_data, result); + } + + fn intercept_call( + &mut self, + contract_address: Vec, + is_call: bool, + input_data: Vec, + ) -> Vec { + self.extension::() + .expect("Failed to find `InterceptingExt` extension") + .intercept_call(contract_address, is_call, input_data) + } +} + +/// This trait describes a runtime extension that can be used to debug contract calls. +pub trait TracingExtT { + /// Called after a contract call is made. + fn after_call( + &self, + _contract_address: Vec, + _is_call: bool, + _input_data: Vec, + _result: Vec, + ) { + } +} + +decl_extension! { + /// A wrapper type for the `TracingExtT` debug extension. + pub struct TracingExt(Box); +} + +/// This trait describes a runtime extension that can be used to intercept contract calls. +pub trait InterceptingExtT { + /// Called when a contract call is made. + /// + /// The returned value must be a valid codec encoding for `Option`. + fn intercept_call( + &self, + _contract_address: Vec, + _is_call: bool, + _input_data: Vec, + ) -> Vec { + // By default, do not intercept, continue with the standard procedure. + None::<()>.encode() + } +} + +decl_extension! { + /// A wrapper type for the `InterceptingExtT` debug extension. + pub struct InterceptingExt(Box); +} + +/// The simplest extension - uses default implementation. +pub struct NoopExt {} +impl TracingExtT for NoopExt {} +impl InterceptingExtT for NoopExt {} diff --git a/drink/src/runtime/pallet_contracts_debugging/tracing.rs b/drink/src/runtime/pallet_contracts_debugging/tracing.rs new file mode 100644 index 0000000..3acd7bb --- /dev/null +++ b/drink/src/runtime/pallet_contracts_debugging/tracing.rs @@ -0,0 +1,47 @@ +use pallet_contracts::{ + debug::{CallSpan, ExportedFunction}, + Tracing, +}; +use pallet_contracts_primitives::ExecReturnValue; + +use crate::runtime::{pallet_contracts_debugging::DrinkDebug, AccountIdFor, Runtime}; + +impl Tracing for DrinkDebug { + type CallSpan = DrinkCallSpan>; + + fn new_call_span( + contract_address: &AccountIdFor, + entry_point: ExportedFunction, + input_data: &[u8], + ) -> Self::CallSpan { + DrinkCallSpan { + contract_address: contract_address.clone(), + entry_point, + input_data: input_data.to_vec(), + } + } +} + +/// A contract's call span. +/// +/// It is created just before the call is made and `Self::after_call` is called after the call is +/// done. +pub struct DrinkCallSpan { + /// The address of the contract that has been called. + pub contract_address: AccountId, + /// The entry point that has been called (either constructor or call). + pub entry_point: ExportedFunction, + /// The input data of the call. + pub input_data: Vec, +} + +impl CallSpan for DrinkCallSpan { + fn after_call(self, output: &ExecReturnValue) { + crate::runtime::pallet_contracts_debugging::runtime::contract_call_debugger::after_call( + self.contract_address.encode(), + matches!(self.entry_point, ExportedFunction::Call), + self.input_data.to_vec(), + output.data.clone(), + ); + } +} diff --git a/drink/src/session.rs b/drink/src/session.rs index d8b9896..403d4f8 100644 --- a/drink/src/session.rs +++ b/drink/src/session.rs @@ -11,17 +11,17 @@ use parity_scale_codec::Decode; use crate::{ chain_api::ChainApi, contract_api::ContractApi, - pallet_contracts_debugging::DebugExt, + pallet_contracts_debugging::TracingExt, runtime::{AccountIdFor, HashFor, Runtime}, EventRecordOf, Sandbox, DEFAULT_GAS_LIMIT, }; -pub mod errors; +pub mod error; mod transcoding; -use errors::{MessageResult, SessionError}; +use error::SessionError; -use crate::session::transcoding::TranscoderRegistry; +use crate::{errors::MessageResult, mock::MockingApi, session::transcoding::TranscoderRegistry}; type Balance = u128; @@ -56,7 +56,7 @@ pub const NO_ARGS: &[String] = &[]; /// # fn contract_bytes() -> Vec { vec![] } /// # fn bob() -> AccountId32 { AccountId32::new([0; 32]) } /// -/// # fn main() -> Result<(), drink::session::errors::SessionError> { +/// # fn main() -> Result<(), drink::session::error::SessionError> { /// /// Session::::new()? /// .deploy_and(contract_bytes(), "new", NO_ARGS, vec![], None, &get_transcoder())? @@ -82,7 +82,7 @@ pub const NO_ARGS: &[String] = &[]; /// # fn contract_bytes() -> Vec { vec![] } /// # fn bob() -> AccountId32 { AccountId32::new([0; 32]) } /// -/// # fn main() -> Result<(), drink::session::errors::SessionError> { +/// # fn main() -> Result<(), drink::session::error::SessionError> { /// /// let mut session = Session::::new()?; /// let _address = session.deploy(contract_bytes(), "new", NO_ARGS, vec![], None, &get_transcoder())?; @@ -169,6 +169,11 @@ impl Session { &mut self.sandbox } + /// Returns a reference for mocking API. + pub fn mocking_api(&mut self) -> &mut impl MockingApi { + &mut self.sandbox + } + /// Deploys a contract with a given constructor, arguments, salt and endowment. In case of /// success, returns `self`. pub fn deploy_and + Debug>( @@ -386,7 +391,7 @@ impl Session { /// /// By default, a new `Session` instance will use a noop debug extension. This method allows to /// override it with a custom debug extension. - pub fn override_debug_handle(&mut self, d: DebugExt) { + pub fn override_debug_handle(&mut self, d: TracingExt) { self.sandbox.override_debug_handle(d); } } diff --git a/drink/src/session/errors.rs b/drink/src/session/error.rs similarity index 69% rename from drink/src/session/errors.rs rename to drink/src/session/error.rs index 346c0d2..5b94280 100644 --- a/drink/src/session/errors.rs +++ b/drink/src/session/error.rs @@ -37,29 +37,3 @@ pub enum SessionError { #[error("Missing transcoder")] NoTranscoder, } - -/// Every contract message wraps its return value in `Result`. This is the error -/// type. -/// -/// Copied from ink primitives. -#[non_exhaustive] -#[repr(u32)] -#[derive( - Debug, - Copy, - Clone, - PartialEq, - Eq, - parity_scale_codec::Encode, - parity_scale_codec::Decode, - scale_info::TypeInfo, - Error, -)] -pub enum LangError { - /// Failed to read execution input for the dispatchable. - #[error("Failed to read execution input for the dispatchable.")] - CouldNotReadInput = 1u32, -} - -/// The `Result` type for ink! messages. -pub type MessageResult = Result; diff --git a/examples/cross-contract-call-tracing/lib.rs b/examples/cross-contract-call-tracing/lib.rs index e32413c..bacb6a1 100644 --- a/examples/cross-contract-call-tracing/lib.rs +++ b/examples/cross-contract-call-tracing/lib.rs @@ -66,7 +66,7 @@ mod tests { use drink::{ runtime::{ - pallet_contracts_debugging::{DebugExt, DebugExtT}, + pallet_contracts_debugging::{TracingExt, TracingExtT}, MinimalRuntime, }, session::{ @@ -94,7 +94,7 @@ mod tests { } struct TestDebugger; - impl DebugExtT for TestDebugger { + impl TracingExtT for TestDebugger { fn after_call( &self, contract_address: Vec, @@ -143,7 +143,7 @@ mod tests { #[test] fn test() -> Result<(), Box> { let mut session = Session::::new()?; - session.override_debug_handle(DebugExt(Box::new(TestDebugger {}))); + session.override_debug_handle(TracingExt(Box::new(TestDebugger {}))); let outer_address = session.deploy(bytes(), "new", NO_ARGS, vec![1], None, &transcoder())?; diff --git a/examples/mocking/Cargo.toml b/examples/mocking/Cargo.toml new file mode 100755 index 0000000..63ec680 --- /dev/null +++ b/examples/mocking/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "mocking" +authors = ["Cardinal", "Aleph Zero Foundation"] +edition = "2021" +homepage = "https://alephzero.org" +repository = "https://github.com/Cardinal-Cryptography/drink" +version = "0.1.0" + +[dependencies] +ink = { version = "=4.2.1", default-features = false } + +scale = { package = "parity-scale-codec", version = "3", default-features = false, features = ["derive"] } +scale-info = { version = "2.6", default-features = false, features = ["derive"], optional = true } + +[dev-dependencies] +drink = { path = "../../drink", features = ["session"] } + +[lib] +path = "lib.rs" + +[features] +default = ["std"] +std = [ + "ink/std", + "scale/std", + "scale-info/std", +] +ink-as-dependency = [] diff --git a/examples/mocking/README.md b/examples/mocking/README.md new file mode 100644 index 0000000..b64a6d1 --- /dev/null +++ b/examples/mocking/README.md @@ -0,0 +1,19 @@ +# Mocking contracts + +This example shows how we can easily mock contracts with the `drink!` library. + +## Scenario + +Say we want to test a contract that simply delegates call to another contract (i.e. a _proxy_ pattern). +Our contract has a single message `delegate_call(AccountId) -> (u8, u8)`. +We want to test that this proxy correctly calls the callee (with some fixed selector) and returns the unchanged result (a pair of two `u8`). + +Normally, we would have to implement and build a mock contract that would be deployed alongside the tested contract. +With drink, we can simply mock the logic with some closures and test our contract in isolation. + +## Running + +```bash +cargo contract build --release +cargo test --release +``` diff --git a/examples/mocking/lib.rs b/examples/mocking/lib.rs new file mode 100755 index 0000000..4af36af --- /dev/null +++ b/examples/mocking/lib.rs @@ -0,0 +1,85 @@ +#![cfg_attr(not(feature = "std"), no_std, no_main)] + +/// This is a fixed selector of the `callee` message. +const CALLEE_SELECTOR: [u8; 4] = ink::selector_bytes!("callee"); + +#[ink::contract] +mod proxy { + use ink::env::{ + call::{build_call, ExecutionInput}, + DefaultEnvironment, + }; + + use crate::CALLEE_SELECTOR; + + #[ink(storage)] + pub struct Proxy {} + + impl Proxy { + #[ink(constructor)] + pub fn new() -> Self { + Self {} + } + + /// Calls `callee` with the selector `CALLEE_SELECTOR` and forwards the result. + #[ink(message)] + pub fn delegate_call(&self, callee: AccountId) -> (u8, u8) { + build_call::() + .call(callee) + .gas_limit(0) + .transferred_value(0) + .exec_input(ExecutionInput::new(CALLEE_SELECTOR.into())) + .returns::<(u8, u8)>() + .invoke() + } + } +} + +#[cfg(test)] +mod tests { + use std::{error::Error, fs, path::PathBuf, rc::Rc}; + + use drink::{ + mock_message, + runtime::MinimalRuntime, + session::{contract_transcode::ContractMessageTranscoder, Session, NO_ARGS}, + ContractMock, MockingApi, + }; + + use crate::CALLEE_SELECTOR; + + fn transcoder() -> Rc { + Rc::new( + ContractMessageTranscoder::load(PathBuf::from("./target/ink/mocking.json")) + .expect("Failed to create transcoder"), + ) + } + + fn bytes() -> Vec { + fs::read("./target/ink/mocking.wasm").expect("Failed to find or read contract file") + } + + #[test] + fn call_mocked_message() -> Result<(), Box> { + let mut session = Session::::new()?; + + // Firstly, we create the mocked contract. + const RETURN_VALUE: (u8, u8) = (4, 1); + let mocked_contract = + ContractMock::new().with_message(CALLEE_SELECTOR, mock_message(|()| RETURN_VALUE)); + + // Secondly, we deploy it, similarly to a standard deployment action. + let mock_address = session.mocking_api().deploy(mocked_contract); + + // Now, we can deploy our proper contract and verify its behavior. + let result: (u8, u8) = session + .deploy_and(bytes(), "new", NO_ARGS, vec![], None, &transcoder())? + .call_and("delegate_call", &[mock_address.to_string()], None)? + .last_call_return() + .expect("Call was successful, so there should be a return") + .expect("Call was successful"); + assert_eq!(result, RETURN_VALUE); + + Ok(()) + } +}