Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] reference impl of mocked corss-contract calls in ink unit tests #136

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from

Conversation

h4x3rotab
Copy link

@h4x3rotab h4x3rotab commented Jun 21, 2022

This is a reference PR to enable cross-contract call in unit tests. I will try to improve it and finally integrate to openbrush in the future.

Usage

  1. Only call contract via openbrush::trait_definition. Calling via ink!'s ContractRef or the CallBuiler is not supported.

  2. When declaring the trait_definition, add a mock = <your contract> attribute to the macro:

    #[openbrush::trait_definition(mock = fat_badges::FatBadges)]
    pub trait Issuable {
        #[ink(message)]
        fn issue(&mut self, id: u32, dest: AccountId) -> fat_badges::Result<()>;
    }
    #[openbrush::wrapper]
    pub type IssuableRef = dyn Issuable;

    In this case, fat_badges::FatBadges is the real ink contract struct with the message fn issue(...) implemented. We defined a trait called Issuable because we want to call fn issue() in our contract. The mock object must implement all the message you declared in the trait_definition. Otherwise the code generated by the macro will not compile.

  3. Call the contract ref as usual:

            let badges: &IssuableRef = address;
            badges
                .issue(*id, data.account_id)
                .or(Err(Error::FailedToIssueBadge))
  4. In your unit test, you need to restructure your code as below:

    use crate::issuable::mock_issuable;
    use openbrush::traits::mock::{Addressable, ManagedCallStack};
    
    // 1. Create a call stack (setting Alice as the default caller)
    let stack = ManagedCallStack::create_shared(accounts.alice);
    // 2. Load the stack to the mock module, and only run contract calls inside the lambda
    mock_issuable::using(stack.clone(), || {
        // 3. Deploy a mock contract. It automatically allocate you an address
        let badges = mock_issuable::deploy(fat_badges::FatBadges::new());
    
        // 4. Construct our contract
        let contract = Addressable::create_native(1, EasyOracle::new(), stack);
    
        // Now you can test your contract as usual. The address in the contract should be handled properly.
    }

    Note that each #[openbrush::trait_definition] will generate a corresponding mock_<trait-name> module. If there are more traits, you will need to call nested using multiple times. Here mock_issuable is generated by trait Issuable.

  5. Your contracts are either deployed by mock_<trait-name>::deploy() or Addressable::create_native. In both case you get an Addressable<T> object that allows you to call the contract with the call stack managed properly:

    // A mutable call
    badges.call_mut().new_badge("test-badge".to_string())
    // A immutable call
    contract.call().attest_gist("https://gist.githubusercontent.com/...".to_string())
  6. If you define the #[openbrush::trait_definition] in a remote crate, you need to enable feature = mockable in that crate.

Full Example

Check trait Issuable and fn end_to_end():

How it works

ink! doesn't support cross-contract call in unit tests because:

  1. It doesn't handle contract address at all. All the contracts are just plain rust objects without any address.
  2. It doesn't track the call stack at all. All the function calls are just plain rust call, leaving no room to inject any code to manage the call stack

Therefore the idea is to:

  1. Make a contract register to store a map between the contract address and the contract object
  2. Inject code to wherever a cross-contract call is being made to properly handle the call stack

It turns out #[openbrush::trait_definition] is a perfect place to inject code. It generates the stub functions to do the actual corss-contract calls as you defined. These functions invoke the ink syscall to initiate the cross-contract calls. However, in a unit test, we just want to call the target contract object. To simulate the cross-contract call, we also want the caller and callee updated properly. This can be easily done via a #[cfg(test)] switch.

Another problem is to manage the contract address register and the call states. This is as easy as creating a map from the contract address and the object, and a call stack in the unit test function. However, to make it easily accessible in the contract and the trait_definition stub functions, we need to use environmental crate. It loads the states to a global variable safely, and allows the access in the very deep functions.

To make everything easy, we have created a few types:

  • Addressable<T>: A wrapper struct around a contract. It stores the address of the contract, and a reference to the call stack. Whenever you invoke the contract, you will call a.call() or a.call_mut(), which pushes the contract address to the stack and pop it out in the lifecycle of the returned reference object.
  • ManagedCallStack: Implements a call stack, and updates the ink! test env (caller and callee) accordingly

We generate a mod mock_<trait-name> for each defined trait and provides the following functions:

  • deploy(contract): Deploy a plain contract, allocate an address, and insert it to the register map. Finally it returns you a Addressable<T>.
  • using(call_stack, lambda): Load a ManagedCallStack to the inner global variable in the scope of lambda. Only within the lambda the cross-contract call are allowed.

@xgreenx
Copy link
Contributor

xgreenx commented Jun 27, 2022

I thought about that idea and came up with the following solution:

We will create a separate repository with a forked extracted ink_env crate from ink!. There we will implement an improved engine for unit tests that will support deploying smart contracts, instantiation, and cross-contract calls.

The engine will have several traits that will be automatically implemented by OpenBrush(via an additional feature aka "advanced-test-engine" or a separate argument of #[opnbrush::contract]). Those traits will allow simulating the behavior of a real contract and will pull/push contracts from the storage and use selectors for cross-contract calls.

If someone wants to do a cross-contract call, he should register(deploy or instantiate) a contract first via util functions provided by the new engine. The contract only should implement those new traits to be able to be registered. The calls will be done in the same way via CallBuilder. On the engine side, we will call call method of the contract(all contracts will implement that method). The call method will pull the contract from the storage and run the method based on the selector.

To use a new ink_env the user should override dependency via [patch.crates-io] like described here.

@h4x3rotab
Copy link
Author

The engine will have several traits that will be automatically implemented by OpenBrush(via an additional feature aka "advanced-test-engine" or a separate argument of #[opnbrush::contract]). Those traits will allow simulating the behavior of a real contract and will pull/push contracts from the storage and use selectors for cross-contract calls.

Sounds perfect.

If someone wants to do a cross-contract call, he should register(deploy or instantiate) a contract first via util functions provided by the new engine. The contract only should implement those new traits to be able to be registered. The calls will be done in the same way via CallBuilder. On the engine side, we will call call method of the contract(all contracts will implement that method). The call method will pull the contract from the storage and run the method based on the selector.

I'm not sure if the ink macro generates the the dispatch function (given selector and call data, call the corresponding rust functions). If yes, then we can even keep the CallBuilder as is, and let the env to determine how to maintian the call stack and forward the call to the dispatch function of the target contract. However, if it's not the case, I think it's better to somehow emulate the dispatch function than my approach (naively generate the function calls).

To use a new ink_env the user should override dependency via [patch.crates-io] like described use-ink/ink#1303.

Agree.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants