13 releases (4 stable)
4.0.0-pre.9 | May 12, 2022 |
---|---|
4.0.0-pre.7 | Feb 3, 2022 |
4.0.0-pre.5 | Dec 24, 2021 |
4.0.0-pre.4 | Oct 15, 2021 |
3.0.1 | Mar 30, 2021 |
#91 in #near
612 downloads per month
Used in near-accounts-plugins-wra…
1MB
20K
SLoC
NEAR Simulator & cross-contract testing library
When writing NEAR contracts, with Rust or other Wasm-compiled languages like AssemblyScript, the default testing approach for your language of choice (such as mod test
in your Rust project's src/lib.rs
file) is great for testing the behavior of a single contract in isolation.
But the true power of blockchains & smart contracts comes from cross-contract calls. How do you make sure your cross-contract code works as you expect?
As a first step, you can use this library! With it, you can:
- Test cross-contract calls
- Profile gas & storage usage for your contract, establishing lower bounds for costs of deployed contracts and rapidly identifying problematic areas prior to deploying.
- Inspect intermediate state of all calls in a complicated chain of transactions
To view this documentation locally, clone this repo and from this folder run cargo doc --open
.
Changelog
3.2.0
- Introduce
block_prod_time
duration in nanoseconds toGenesisConfig
that defines the duration between produced blocks. - Expose
cur_block
andgenesis_config
fromRuntimeStandalone
. This allows to manipulate block time. - Use
RuntimeConfig::from_protocol_version
that fixes storage costs issue. - Set root account balance to one billion tokens.
Getting started
This section will guide you through our suggested approach to adding simulation tests to your project. Want an example? Check out the Fungible Token Example.
Dependency versions
Currently this crate depends on a the GitHub repo of nearcore, so this crate must be a git dependency too. Furthermore, this crate's dependencies conflict with building the Wasm smart contract, so you must add it under the following:
[dev-dependencies]
near-sdk-sim = "4.0.0-pre.9"
And update near-sdk
too:
[dependencies]
near-sdk = "4.0.0-pre.9"
Note that you need to add the tag
(or commit
) of the version.
Workspace setup
If you want to check gas & storage usage of one Rust contract, you can add the above dependencies to Cargo.toml
in the root of your project. If you want to test cross-contract calls, we recommend setting up a cargo workspace. Here's how it works:
Let's say you have an existing contract project in a folder called contract
.
Go ahead and make a subfolder within it called contract
, and move the original contents of contract
into this subfolder. Now you'll have contract/contract
. You can rename the root folder to something like contracts
or contract-wrap
, if you want. Some bash commands to do this:
mkdir contract-wrap
mv contract contract-wrap
Now in the root of the project (contract-wrap
), create a new Cargo.toml
. You'll have to add the normal [package]
section, but unlike most projects you won't have any dependencies
, only dev-dependencies
and a workspace
:
[dev-dependencies]
near-sdk = "4.0.0-pre.9"
near-sdk-sim = "4.0.0-pre.9"
contract = { path = "./contract" }
[workspace]
members = [
"contract"
]
Now when you want to create test contracts, you can add a new subfolder to your project and add a line for it to both [dev-dependencies]
and [workspace]
in this root Cargo.toml
.
Other cleanup:
- You can move any
[profile.release]
settings from your nested project up to the root of the workspace, since workspace members inherit these settings from the workspace root. - You can remove the nested project's
target
, since all workspace members will be built to the root project'starget
directory - If you were building with
cargo build
, you can now build all workspace members at once withcargo build --all
Test files
In the root of your project (contract-wrap
in the example above), create a tests
directory with a Rust file inside. Anything in here will automatically be run by cargo test
.
Inside this folder, set up a new test crate for yourself by creating a tests/sim
directory with a tests/sim/main.rs
file. This file will glue together the other files (aka modules) in this folder. We'll add things to it soon. For now you can leave it empty.
Now create a tests/sim/utils.rs
file. This file will export common functions for all your tests. In it you need to include the bytes of the contract(s) you want to test:
near_sdk_sim::lazy_static_include::lazy_static_include_bytes! {
// update `contract.wasm` for your contract's name
CONTRACT_WASM_BYTES => "target/wasm32-unknown-unknown/release/contract.wasm",
// if you run `cargo build` without `--release` flag:
CONTRACT_WASM_BYTES => "target/wasm32-unknown-unknown/debug/contract.wasm",
}
Note that this means you must build
before you test
! Since cargo test
does not re-generate the wasm
files that your simulation tests rely on, you will need to cargo build --all --target wasm32-unknown-unknown
before running cargo test
. If you made contract changes and you swear it should pass now, try rebuilding!
Now you can make a function to initialize your simulator:
use near_sdk_sim::{init_simulator, to_yocto, STORAGE_AMOUNT};
const CONTRACT_ID: &str = "contract";
pub fn init() -> (UserAccount, UserAccount, UserAccount) {
// Use `None` for default genesis configuration; more info below
let root = init_simulator(None);
let contract = root.deploy(
&CONTRACT_WASM_BYTES,
CONTRACT_ID.to_string(),
STORAGE_AMOUNT // attached deposit
);
let alice = root.create_user(
"alice".parse().unwrap(),
to_yocto("100") // initial balance
);
(root, contract, alice)
}
Now you can add a test file that uses this init
function in tests/sim/first_tests.rs
. For every file you add to this directory, you'll need to add a line to tests/sim/main.rs
. Let's add one for both files so far:
// in tests/sim/main.rs
mod utils;
mod first_tests;
Now add some tests to first_tests.rs
:
use near_sdk::serde_json::json;
use near_sdk_sim::DEFAULT_GAS;
use crate::utils::init;
#[test]
fn simulate_some_view_function() {
let (root, contract, _alice) = init();
let actual: String = root.view(
contract.account_id(),
"view_something",
&json!({
"some_param": "some_value".to_string(),
}).to_string().into_bytes(),
).unwrap_json();
assert_eq!("expected".to_string(), actual);
}
#[test]
fn simulate_some_change_method() {
let (root, contract, _alice) = init();
let result = root.call(
contract.account_id(),
"change_something",
json!({
"some_param": "some_value".to_string(),
}).to_string().into_bytes(),
DEFAULT_GAS,
1, // deposit
);
assert!(result.is_ok());
}
Optional macros
The above approach is a good start, and will work even if your Wasm files are compiled from a language other than Rust.
But if your original files are Rust and you want better ergonomics while testing, near-sdk-sim
provides a nice bonus feature.
near-sdk-sim
modifies the near_bindgen
macro from near-sdk
to create an additional struct+implementation from your contract, with Contract
added to the end of the name, like xxxxxContract
. So if you have a contract with [package].name
set to token
with this in its src/lib.rs
:
#[near_bindgen]
struct Token {
...
}
#[near_bindgen]
impl Token {
...
}
Then in your simulation test you can import TokenContract
:
use token::TokenContract;
// or rename it maybe
use token::TokenContract as OtherNamedContract;
Now you can simplify the init
& test code from the previous section:
// in utils.rs
use near_sdk_sim::{deploy, init_simulator, to_yocto, STORAGE_AMOUNT};
use token::TokenContract;
const CONTRACT_ID: &str = "contract";
pub fn init() -> (UserAccount, ContractAccount<TokenContract>, UserAccount) {
let root = init_simulator(None);
let contract = deploy!(
contract: TokenContract,
contract_id: CONTRACT_ID,
bytes: &CONTRACT_WASM_BYTES,
signer_account: root
);
let alice = root.create_user(
"alice".parse().unwrap(),
to_yocto("100") // initial balance
);
(root, contract, alice)
}
// in first_tests.rs
use near_sdk_sim::{call, view};
use crate::utils::init;
#[test]
fn simulate_some_view_function() {
let (root, contract, _alice) = init();
let actual: String = view!(
contract.view_something("some_value".to_string()),
).unwrap_json();
assert_eq!("expected", actual);
}
#[test]
fn simulate_some_change_method() {
let (root, contract, _alice) = init();
// uses default gas amount
let result = call!(
root,
contract.change_something("some_value".to_string()),
deposit = 1,
);
assert!(result.is_ok());
}
Common patterns
Profile gas costs
For a chain of transactions kicked off by call
or call!
, you can check the gas_burnt
and tokens_burnt
, where tokens_burnt
will equal gas_burnt
multiplied by the gas_price
set in the genesis config. You can also print out profile_data
to see an in-depth gas-use breakdown.
let outcome = some_account.call(
"some_contract",
"method",
&json({
"some_param": "some_value",
}).to_string().into_bytes(),
DEFAULT_GAS,
0,
);
println!(
"profile_data: {:#?} \n\ntokens_burnt: {}Ⓝ",
outcome.profile_data(),
(outcome.tokens_burnt()) as f64 / 1e24
);
let expected_gas_ceiling = 5 * u64::pow(10, 12); // 5 TeraGas
assert!(outcome.gas_burnt() < expected_gas_ceiling);
TeraGas units are explained here.
Remember to run tests with --nocapture
to see output from println!
:
cargo test -- --nocapture
The output from this println!
might look something like this:
profile_data: ------------------------------
Total gas: 1891395594588
Host gas: 1595600369775 [84% total]
Action gas: 0 [0% total]
Wasm execution: 295795224813 [15% total]
------ Host functions --------
base -> 7678275219 [0% total, 0% host]
contract_compile_base -> 35445963 [0% total, 0% host]
contract_compile_bytes -> 48341969250 [2% total, 3% host]
read_memory_base -> 28708495200 [1% total, 1% host]
read_memory_byte -> 634822611 [0% total, 0% host]
write_memory_base -> 25234153749 [1% total, 1% host]
write_memory_byte -> 539306856 [0% total, 0% host]
read_register_base -> 20137321488 [1% total, 1% host]
read_register_byte -> 17938284 [0% total, 0% host]
write_register_base -> 25789702374 [1% total, 1% host]
write_register_byte -> 821137824 [0% total, 0% host]
utf8_decoding_base -> 3111779061 [0% total, 0% host]
utf8_decoding_byte -> 15162184908 [0% total, 0% host]
log_base -> 3543313050 [0% total, 0% host]
log_byte -> 686337132 [0% total, 0% host]
storage_write_base -> 192590208000 [10% total, 12% host]
storage_write_key_byte -> 1621105941 [0% total, 0% host]
storage_write_value_byte -> 2047223574 [0% total, 0% host]
storage_write_evicted_byte -> 2119742262 [0% total, 0% host]
storage_read_base -> 169070537250 [8% total, 10% host]
storage_read_key_byte -> 711908259 [0% total, 0% host]
storage_read_value_byte -> 370326330 [0% total, 0% host]
touching_trie_node -> 1046627135190 [55% total, 65% host]
------ Actions --------
------------------------------
tokens_burnt: 0.00043195379520539996Ⓝ
Profile storage costs
For a ContractAccount
created with deploy!
or a UserAccount
created with root.create_user
, you can call account()
to get the Account information stored in the simulated blockchain.
let account = root.account().unwrap();
let balance = account.amount;
let locked_in_stake = account.locked;
let storage_usage = account.storage_usage;
You can use this info to do detailed profiling of how contract calls alter the storage usage of accounts.
Inspect intermediate state of all calls in a complicated chain of transactions
Say you have a call
or call!
:
let outcome = some_account.call(
"some_contract",
"method",
&json({
"some_param": "some_value",
}).to_string().into_bytes(),
DEFAULT_GAS,
0,
);
If some_contract.method
here makes cross-contract calls, near-sdk-sim
will allow all of these calls to complete. You can then inspect the entire chain of calls via the outcome
struct. Some useful methods:
You can use these with println!
and pretty print interpolation:
println!("{:#?}", outcome.promise_results);
Remember to run your tests with --nocapture
to see the println!
output:
cargo test -- --nocapture
You might see something like this:
[
Some(
ExecutionResult {
outcome: ExecutionOutcome {
logs: [],
receipt_ids: [
`2bCDBfWgRkzGggXLuiXqhnVGbxwRz7RP3qa8WS5nNw8t`,
],
burnt_gas: 2428220615156,
tokens_burnt: 0,
status: SuccessReceiptId(2bCDBfWgRkzGggXLuiXqhnVGbxwRz7RP3qa8WS5nNw8t),
},
},
),
Some(
ExecutionResult {
outcome: ExecutionOutcome {
logs: [],
receipt_ids: [],
burnt_gas: 18841799405111,
tokens_burnt: 0,
status: Failure(Action #0: Smart contract panicked: panicked at 'Not an integer: ParseIntError { kind: InvalidDigit }', test-contract/src/lib.rs:85:56),
},
},
)
]
You can see it's a little hard to tell which call is which, since the ExecutionResult does not yet include the name of the contract or method. To help debug, you can use log!
in your contract methods. All log!
output will show up in the logs
arrays in the ExecutionOutcomes shown above.
Check expected transaction failures
If you want to check something in the logs
or status
of one of the transactions in one of these call chains mentioned above, you can use string matching. To check that the Failure above matches your expectations, you could:
use near_sdk_sim::transaction::ExecutionStatus;
#[test]
fn simulate_some_failure() {
let outcome = some_account.call(...);
assert_eq!(res.promise_errors().len(), 1);
if let ExecutionStatus::Failure(execution_error) =
&outcome.promise_errors().remove(0).unwrap().outcome().status
{
assert!(execution_error.to_string().contains("ParseIntError"));
} else {
unreachable!();
}
}
This promise_errors
returns a filtered version of the promise_results
method mentioned above.
Parsing logs
is much simpler, whether from get_receipt_results
or from logs
directly.
Tweaking the genesis config
For many simulation tests, using init_simulator(None)
is good enough. This uses the default genesis configuration settings:
GenesisConfig {
genesis_time: 0,
gas_price: 100_000_000,
gas_limit: std::u64::MAX,
genesis_height: 0,
epoch_length: 3,
runtime_config: RuntimeConfig::default(),
state_records: vec![],
validators: vec![],
}
If you want to override some of these values, for example to simulate how your contract will behave if gas price goes up 10x:
use near_sdk_sim::runtime::GenesisConfig;
pub fn init () {
let mut genesis = GenesisConfig::default();
genesis.gas_price = genesis.gas_price * 10;
let root = init_simulator(Some(genesis));
}
Dependencies
~65MB
~1M SLoC