Environments
Overview
When discussing the unified transaction syntax, we identify four primary environments crucial for developing and deploying secure smart contracts on MultiversX. This design aims to cover all essential development stages, ensuring a seamless workflow. Notably, it streamlines the process for Rust developers by empowering them to solely concentrate on mastering Rust. As a result, they can effortlessly write, test, deploy, and interact with smart contracts without the burden of learning additional complementary technologies.
The environments:
- Smart contract - Rust framework, the syntax for writing smart contracts (
TxScEnv
) - Integration test - Rust testing framework, used for writing Rust tests against the Rust VM and Go VM (
ScenarioTxEnv
,ScenarioEnvExec
,ScenarioTxEnvData
,ScenarioEnvQuery
) - Parametric test - symbolic execution, safety of the smart contract code (coming soon)
- Interactor - Rust program, used for system testing and interaction with the smart contract on the real blockchain (
InteractorExecEnv
,InteractorQueryEnv
)
The Rust unified syntax is able to interact with all of these environments, meaning that the syntax remains the same for all purposes mentioned above. The only element that changes the route of the transaction (whether it's going to be run inside a smart contract
, test
, or interactor
) is the environment TxEnv
.
Creating a transaction with a specific environment gives the developer access to the specific functions used in said environment only.
Switching between environments is accessible for the developer and enforced by the Rust type system. Each environment is represented by a different type, and switching between environments can be easily done by specializing the TxEnv
generic of the transaction with a concrete value from the pool of available environments.
Each transaction starts from the environment, then multiple smaller bits of information are added in a modular way so that the generics of the Tx
object are instantiated one by one and the route of the transaction is defined. The developer is not forced to complete the transaction in any way, but the route of the transaction is narrowed down by the types used at every step.
For example, let's suppose we are in the smart contract environment of SomeContract
:
#[multiversx_sc::contract]
pub trait SomeContract {
#[endpoint]
fn deploy_another_contract(
&self,
source: ManagedAddress,
deploy_arg: Option<u64>
) {
self.tx() // TxScEnv - smart contract environment
.raw_deploy() // TxScEnv + DeployCall - deploy tx in a sc environment
.from_source(source) // deploy detail, further defines DeployCall
.argument(&deploy_arg) // deploy argument
.sync_call() // type of call
}
}
In this example, the endpoint deploy_another_contract
performs a smart contract deploy action with specific parameters. First, we create a transaction with self.tx()
, which automatically translates to a transaction with the TxScEnv
environment when called from inside the smart contract. Then, we add bits of information to the transaction, such as the type of action we are looking for: .raw_deploy()
. This narrows down our environment to TxScEnv
and also specializes our TxData
generic field as a DeployCall
, which now creates a route for our transaction (deploy) and gives us access to other functions that can help the deploy action only.
Once the environment is selected, developers have four methods to narrow down the route of the transaction:
.raw_call("function_name")
- creates a raw call to a smart contract (function name can also be empty).raw_deploy()
- creates a deploy call, can not have a recipient.raw_upgrade()
- creates an upgrade call.typed(proxy::ContractProxy)
- creates a typed call through a contract proxy, knows the expected return type of the endpoint
Apart from the environment, developers are not obliged to include any specific details within a transaction. They have the freedom to construct the transaction in any manner they see fit, provided that the transaction ultimately contains sufficient information to execute the intended action accurately.
Diagram
Environments are the first field that gets initialized, as soon as the transaction object is created.
Smart contract
A transaction with the smart contract environment can be created from inside the smart contract (annotated with #[multiversx_sc::contract]
), by simply calling self.tx()
. The returned transaction env is TxScEnv
by default, which helps us build transactions in this environment.
#[multiversx_sc::contract]
pub trait SomeContract {
#[endpoint]
fn send_async_call(
&self,
to: ManagedAddress,
function_name: ManagedBuffer,
args: ManagedArgBuffer<Self::Api>,
) {
self.tx() // tx with sc environment
.to(to) // recipient
.raw_call(function_name) // function call
.arguments_raw(args) // arguments
.async_call_and_exit() // type of call - async
}
}
Integration test
When building integration tests, we have to deal with the test environments such as ScenarioEnvExec
for sending transactions and ScenarioEnvQuery
for querying results from smart contracts. The easiest way to create transactions with the test environments is to create an instance of the ScenarioWorld
struct.
use multiversx_sc_scenario::imports::*;
mod proxy;
#[test]
fn blackbox_test() {
let mut world = ScenarioWorld::new(); // ScenarioWorld struct
let _query_env = world.query(); // tx with ScenarioEnvQuery
let _tx_env = world.tx(); // tx with ScenarioEnvExec
}
After defining the environment, we can further build transactions, as such:
use multiversx_sc_scenario::imports::*;
mod proxy;
const OWNER_ADDRESS: TestAddress = TestAddress::new("owner");
const CODE_PATH: MxscPath = MxscPath::new("output/some-contract.mxsc.json");
#[test]
fn blackbox_test() {
let mut world = ScenarioWorld::new(); // ScenarioWorld struct
let new_address = world
.tx() // tx with test exec environment
.from(OWNER_ADDRESS) // caller
.typed(proxy::SomeContractProxy) // typed call
.init() // calling init function => deploy
.code(CODE_PATH) // deploy detail
.returns(ReturnsNewAddress) // result handler - expected return type
.run(); // runs the step
let _result = world
.query() // tx with test query environment
.to(new_address) // newly deployed sc address
.typed(proxy::SomeContractProxy) // typed call
.sum() // smart contract view
.returns(ReturnsResult) // result handler - expected return type
.run(); // runs the step
}
Parametric tests
Not yet available, coming soon.
Interactor
The interactors are Rust programs, used for interacting with the smart contract on the real blockchain (deploy/upgrade, query, call) and system testing. Interactor environments such as InteractorExecEnv
and InteractorQueryEnv
are essential for transactions meant to be ran on the real blockchain, from a different environment than the smart contract. The easiest way to create transactions using the interactor environments is to create an instance of the Interactor
struct.
const GATEWAY: &str = "https://devnet-gateway.multiversx.com";
async fn example_scenario() {
// No chain simulator flag, as we are using the real blockchain for this test.
let mut interactor = Interactor::new(GATEWAY).await; // Interactor struct
let _query_env = interactor.query(); // tx with InteractorQueryEnv
let _tx_env = interactor.tx(); // tx with InteractorExecEnv
}
After creating the transaction with the interactor environment, we can start building transactions that will run on the real blockchain, as such:
use multiversx_sc_snippets::imports::*;
mod proxy;
const GATEWAY: &str = "https://devnet-gateway.multiversx.com";
async fn example_scenario() -> num_bigint::BigUint {
let mut interactor = Interactor::new(GATEWAY).await; // create Interactor struct
let wallet_address = interactor.register_wallet(test_wallets::mike()); // register a test wallet from the framework
let sc_address = Bech32Address::from_bech32_string(
"erd1qqqqqqqqqqqqqpgqtsw8s3evhhyqqa2j2tfn9yvufqskdv236n9s2a06h9".to_string(),
); // actual smart contract address on devnet
interactor
.tx() // tx with interactor exec environment
.from(&wallet_address) // caller is test wallet
.to(&sc_address) // recipient
.typed(proxy::SomeContractProxy) // typed call
.add(5u64) // contract endpoint
.prepare_async() // prepares tx data, async Rust (different from async blockchain tx)
.run() // runs the transaction
.await; // awaits response
interactor
.query() // tx with interactor query environment
.to(&sc_address) // recipient
.typed(proxy::SomeContractProxy) // typed call
.sum() // contract view, returns value
.returns(ReturnsResultUnmanaged) // result handler - expected return type, converted for ease of use
.prepare_async() // prepares tx data, async Rust (different from async blockchain tx)
.run() // runs the transaction
.await // awaits and returns response
}
For the moment, every interactor transaction needs to go through prepare_async
in order to convert tx data to other needed types. The function does not
create an async blockchain call, rather it refers to async Rust.
As seen above, the transaction syntax remains consistent over the various environments. The developer can further improve the interactor by including the scenario function into a CLI or writing system tests.
For example, a system test can look like this:
#[cfg(test)]
pub mod system_test {
use crate::example_scenario;
use multiversx_sc_snippets::tokio;
#[tokio::test]
async fn test_full_farm_scenario() {
let result = example_scenario().await;
println!("result {:#?}", result);
}
}
Running this test will call the add
endpoint of the contract, query the sum
view to get the result value (on the devnet, according to the gateway and test wallet), convert it into a similar type that doesn't require an API (in this case from multiversx_sc::types::Biguint
to num_bigint::BigUint
) and print it in the console.
For ease of use, a small interactor CLI built around a smart contract's endpoints can now be generated automatically
using sc-meta all snippets.