Skip to main content

Tutorial: Testing a DEX

Let's see a real-world example by testing wasmswap. We are going to swap the native token cosm with the Cw20 token pica.

Setting up

We are using a slightly modified version of wasmswap. The only difference is our VM doesn't alter the data field because it is supposed to be up to user to use any data in any format. So we chose to follow the spec there. Let's clone the template for our tutorial:

git clone https://github.com/ComposableFi/cw-toolkit
cd cw-toolkit
git checkout tags/wasmswap-template
cd orchestrate-tutorial

Add the latest cosmwasm-orchestrate as a dev-dependency:

# In Cargo.toml

[dev-dependencies]
cosmwasm-orchestrate = { git = "https://github.com/ComposableFi/cosmwasm-vm" }

And let's create a file for the integration tests:

mkdir tests
touch tests/integration.rs

Setting up the state for the test

Code

// In `integration.rs`

use cosmwasm_orchestrate::{
block,
cosmwasm_std::{Coin, MessageInfo},
env, info,
vm::*,
Direct, JunoApi, StateBuilder, WasmLoader,
};
use cosmwasm_std::{Addr, Decimal, Uint128};
use cw20::{BalanceResponse, Cw20Coin, Cw20ExecuteMsg, Cw20QueryMsg, Denom};
use cw20_base::msg::InstantiateMsg as Cw20InstantiateMsg;
use wasmswap::msg::{ExecuteMsg, InstantiateMsg, TokenSelect};

#[test]
fn swap_works() {
// Compile and load the wasmswap contract
let wasmswap_code = WasmLoader::new(env!("CARGO_PKG_NAME")).load().unwrap();
let cw20_code = include_bytes!("../scripts/cw20_base.wasm");
// Create a VM state by providing the codes that will be executed.
let mut state = StateBuilder::new()
.add_code(&wasmswap_code)
.add_code(cw20_code)
.build();
}

If you were to build your test right now, it will fail but we'll shortly make it work, no worries.

Analyze

let cw20_code = include_bytes!("../scripts/cw20_base.wasm");

For loading cw20_base contract, we just used include_bytes because we have the binary locally. But if you need to get this binary from a (reliable) remote source, you could also use:

  • CosmosFetcher: To fetch the code from a Cosmos chain.
  • FileFetcher: To fetch the code from a server. (Like using wget)

let wasmswap_code = WasmLoader::new(env!("CARGO_PKG_NAME")).load().unwrap();

For the wasmswap contract, we used a special type of loader which is WasmLoader. The thing is our VM works with wasm binaries just like any other chain. So we need to make sure that we are feeding the VM with the latest compiled wasm binary every time we run the tests. We could manually compile the binary and just do include_bytes but believe me, you will forget to do it very often and get confused.

WasmLoader gets the package name and compiles and loads the contract for you. Note that the default configuration assumes the rust package name is the same as the contract name. So wasmswap as the package name means that the contract's name is also wasmswap and the full name will be wasmswap.wasm. The second assumption of the default loader is the target directory is target/wasm32-unknown-unknown/release. The good thing is you can configure any of those, even the command that will be used to build your contract.


let mut state = StateBuilder::new()
.add_code(&wasmswap_code) // code_id = 1
.add_code(cw20_code) // code_id = 2
.build();

Finally, we need to create a State for the VM which will contain the wasm binaries. Note that code id's that we are gonna use later on will be in the same order as how it is given to the StateBuilder. So in this case, code_id for wasmswap_code will be 1 and 2 for cw20_code.

Instantiating the token

Let's instantiate the cw20 contract for PICA.

Code

    let sender = Account::generate_from_seed::<JunoAddressHandler>("sender").unwrap();

// Instantiate the cw20 contract
let (cw20_address, _) = <JunoApi>::instantiate(
&mut state,
2, // Code ID of cw20_base is 2
None,
block(),
None,
info(&sender),
100_000_000_000,
Cw20InstantiateMsg {
name: "Picasso".into(),
symbol: "PICA".into(),
decimals: 10,
initial_balances: vec![Cw20Coin {
address: sender.clone().into(),
amount: Uint128::new(100_000_000_000_000),
}],
mint: None,
marketing: None,
},
)
.unwrap();

To run, use:

cargo test --test integration

Analyze

let sender = Account::generate_from_seed::<JunoAddressHandler>("sender").unwrap();

We need to create a valid address which we are gonna use to call the contracts. Which is the sender field in MessageInfo. For that, we are using Account type with JunoAddressHandler. This will create a bech32 encoded address with juno prefix.


let (cw20_address, _) = <JunoApi>::instantiate(
&mut state,
2, // Code ID of cw20_base is 2
None,
block(),
None,
info(&sender),
100_000_000_000, // We don't care about the gas
Cw20InstantiateMsg {
name: "Picasso".into(),
symbol: "PICA".into(),
decimals: 10,
initial_balances: vec![Cw20Coin {
address: sender.clone().into(),
amount: Uint128::new(100_000_000_000_000),
}],
mint: None,
marketing: None,
},
)
.unwrap();

And then we are instantiating the cw20 contract. The first notable thing is we are using JunoApi because we have used the JunoAddressHandler. JunoApi will use JunoAddressHandler as the address handler.

Note that we are giving the instantiate message as is without doing any JSON encoding. This is because instantiate function gets any JSON-serializable type and serializes it under the hood. But if you have no access to message type and you don't want to define them yourself, you can use instantiate_raw function and provide the JSON-encoded message.

Finally, note that we do info(&sender) which creates a MessageInfo and sets the sender field to the given account's address.

Instantiating the swap

Next thing is to instantiate the swap with the token we just created.

    let (contract_addr, _) = <JunoApi>::instantiate(
&mut state,
1, // Code ID for wasmswap is 1
None,
block(),
None,
info(&sender),
100_000_000_000,
InstantiateMsg {
token1_denom: Denom::Native("cosm".into()),
token2_denom: Denom::Cw20(Addr::unchecked(cw20_address.clone())),
lp_token_code_id: 2,
owner: None,
protocol_fee_recipient: sender.clone().into(),
protocol_fee_percent: Decimal::zero(),
lp_fee_percent: Decimal::zero(),
},
)
.unwrap();

Just as we instantiated the cw20 token, we instantiated the swap. Note that cw20_address that we got from the previous token instantiation is given to the swap contract as the cw20 token.

And also, lp_token_code_id is set to 2 which is the code id for cw20_base.

Increasing allowance

Before executing the swap, we need to make sure that the swap contract has the allowance to transfer tokens from the account that does the swap. So let's create a second account and increase the allowance for the swap contract.

    // Create the second account which will do the swap
let swapper = Account::generate_from_seed::<JunoAddressHandler>("swapper").unwrap();

let _ = <JunoApi>::execute(
&mut state,
env(&cw20_address),
info(&sender),
100_000_000_000,
Cw20ExecuteMsg::IncreaseAllowance {
spender: contract_addr.clone().into(),
amount: Uint128::new(100_000_000_000),
expires: Some(cw0::Expiration::Never {}),
},
)
.unwrap();

Note that we have given cw20_address to the env function. This will create an Env and set contract.address field to the given address and this contract will be executed.

Adding liquidity to the swap

We also need to have enough liquidity to be able to do the swap. So let's add some juice.

    let _ = <JunoApi>::execute(
&mut state,
env(&contract_addr),
MessageInfo {
sender: sender.clone().into(),
funds: vec![Coin::new(400_000_000, "cosm")],
},
100_000_000_000,
ExecuteMsg::AddLiquidity {
token1_amount: 400_000_000u128.into(),
min_liquidity: 50_000u128.into(),
max_token2: 300_000_000u128.into(),
expiration: None,
},
)
.unwrap();

And when we execute it:

thread 'works' panicked at 'called `Result::unwrap()` on an `Err` value: BankError(InsufficientBalance)', tests/orchestrate.rs:109:6
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

WHAT THE HELL?

No worries, it is intended to show you how to easily understand what went wrong. Our VM logs too many great things about the execution flow which includes the error messages. So let's enable logs to see all that yummy logs.

# In Cargo.toml
env_logger = "0.10"
// In integration.rs

fn initialize() {
use std::sync::Once;
static INIT: Once = Once::new();
INIT.call_once(|| {
env_logger::init();
});
}

#[test]
fn swap_works() {
initialize();
}

And then let's execute the test once more with enabling the logs.

RUST_LOG=debug cargo test --test integration

Before going into the reason for failure, take your time to read the logs and see how much useful information there is. You can see the host functions that are running, responses from the executions, and sub-messages that are dispatched.

Now let's see what went wrong:

[2022-12-11T20:10:53Z DEBUG cosmwasm_orchestrate::vm] Transfer: 
Account(Addr("juno1pgm8hyk0pvphmlvfjc8wsvk4daluz5tgrw6pu5mfpemk74uxnx9qwm56ug"))
-> Account(Addr("juno1ptl22cw3jth6ue4ruhgef2s5gfz23tt57vlrmydqu7vxk5xhn4gse7s6fe"))
[Coin { denom: "cosm", amount: Uint128(400000000) }]

[2022-12-11T20:10:53Z DEBUG cosmwasm_orchestrate::vm] < Transaction abort: 0

The VM is trying to transfer 400_000_000cosm and then it aborts the transaction with BankError(InsufficientBalance).

The problem is obvious. See that we needed to transfer some funds to the contract to be able to add the liquidity by properly setting funds field in the MessageInfo. But the sender account does not have any cosm balance at all. So, the VM tried to transfer the given funds to the contract before execution and failed.

Let's add some balance to the sender account in the StateBuilder.

    // Change the `StateBuilder` to:
let mut state = StateBuilder::new()
.add_code(&wasmswap_code)
.add_balance(sender.clone(), Coin::new(100_000_000_000_000, "cosm"))
.add_code(cw20_code)
.build();

Now that we have enough balance, let's re-run again and see the lovely green ok message.

cosmwasm_std vs. cosmwasm_orchestrate::cosmwasm_std

You might have already noticed it but we are using Coin and MessageInfo from cosmwasm_orchestrate::cosmwasm_std instead of using them directly from cosmwasm_std. This is a temporary thing that we hope to resolve soon. The problem is our VM is no_std but cosmwasm_std is originally only std. We created a PR for this and until it gets merged, cosmwasm-orchestrate will be using our fork, hence Cargo thinks that cosmwasm_std::Coin is not the same thing as cosmwasm_orchestrate::cosmwasm_std::Coin. Until the merge, for any type/function that is given to cosmwasm-orchestrate, use cosmwasm_orchestrate::cosmwasm_std. You will get a type mismatch error if you do it wrong, so beware of that.

Executing the swap

    // Change the `StateBuilder` to:
let mut state = StateBuilder::new()
.add_code(&wasmswap_code)
.add_balance(sender.clone(), Coin::new(100_000_000_000_000, "cosm"))
.add_balance(swapper.clone(), Coin::new(120_000_000, "cosm"))
.add_code(cw20_code)
.build();

let _ = <JunoApi>::execute(
&mut state,
env(&contract_addr),
MessageInfo {
sender: swapper.clone().into(),
funds: vec![Coin::new(120_000_000, "cosm")],
},
100_000_000_000,
ExecuteMsg::Swap {
input_token: TokenSelect::Token1,
input_amount: Uint128::new(120_000_000),
min_output: Uint128::zero(),
expiration: None,
},
)
.unwrap();

Note that we add balance to swapper account as well to be able to send funds to the swap contract. And finally, we can swap 120_000_000cosm. The final thing to do is verify the swap worked.

Verifying the swap

We will check swapper's pica balance to verify the swap. To do that, we need to query the cw20 token.

    let query_res: BalanceResponse = <JunoApi<Direct>>::query(
&mut state,
env(&cw20_address),
Cw20QueryMsg::Balance {
address: swapper.clone().into(),
},
)
.unwrap();

assert_eq!(
query_res.balance,
Uint128::new((120_000_000 * 300_000_000) / (400_000_000 + 120_000_000))
);

Note that we used <JunoApi<Direct>> this time instead of <JunoApi>. This is because the execution type for query can only be Direct. See previous sections to learn more about this.

And here we verified the whole execution.

One final note is if you were to verify the native balances also, you could directly use the bank module to see the balances:

state.db.bank.balance(&swapper, "cosm");