Near Learn

Testing async flows

Test NEAR cross-contract flows in a near-workspaces sandbox: a failing mock callee, gas starvation, per-receipt outcomes and logs, and yield timeouts.

Advanced18 min read3-question check

Unit tests with testing_env! run a single method in isolation. They can show which promises a method builds, but they never execute them, so they cannot tell you whether your callback really restores a balance when the token contract panics. For that you need a sandbox: a local NEAR node where real receipts run across real contracts. In Rust that is near-workspaces.

The trick that makes async tests useful is a mock callee you can make fail on demand. Every interesting branch of a callback is a failure branch.

A callee that fails on command#

contracts/mock-ft/src/lib.rs: just enough of NEP-141 to be called
Rust
use near_sdk::json_types::U128;
use near_sdk::{log, near, require, AccountId};

#[near(contract_state)]
#[derive(Default)]
pub struct MockFt {
    fail: bool,
}

#[near]
impl MockFt {
    pub fn set_fail(&mut self, fail: bool) {
        self.fail = fail;
    }

    #[payable]
    pub fn ft_transfer(&mut self, receiver_id: AccountId, amount: U128, memo: Option<String>) {
        let _ = memo;
        require!(!self.fail, "mock: transfer failed");
        log!("mock: sent {} to {}", amount.0, receiver_id);
    }
}

The contract under test is a vault like the one in Callbacks don’t roll back the caller: new(token), a #[private] credit(account_id, amount) for seeding balances, balance_of(account_id) -> U128, and withdraw(amount) -> Promise, which refuses to start with less than 25 Tgas, debits, calls ft_transfer, and chains on_withdraw. The callback logs refunded and restores the balance on failure, and returns true or false.

Add near-workspaces (with its unstable feature, which provides compile_project), tokio, anyhow and serde_json as dev-dependencies of the test crate.

Three tests every call flow needs#

tests/withdraw.rs: happy path, failing callee, not enough gas
Rust
use near_workspaces::network::Sandbox;
use near_workspaces::result::ExecutionFinalResult;
use near_workspaces::types::Gas;
use near_workspaces::{Account, Contract, Worker};
use serde_json::json;

struct Env {
    _worker: Worker<Sandbox>, // keep the sandbox alive for the whole test
    vault: Contract,
    ft: Contract,
    alice: Account,
}

async fn setup() -> anyhow::Result<Env> {
    let worker = near_workspaces::sandbox().await?;
    let vault_wasm = near_workspaces::compile_project("./contracts/vault").await?;
    let ft_wasm = near_workspaces::compile_project("./contracts/mock-ft").await?;
    let vault = worker.dev_deploy(&vault_wasm).await?;
    let ft = worker.dev_deploy(&ft_wasm).await?;
    let alice = worker.dev_create_account().await?;

    vault.call("new").args_json(json!({ "token": ft.id() })).transact().await?.into_result()?;
    // called by the vault account itself, so #[private] lets it through
    vault
        .call("credit")
        .args_json(json!({ "account_id": alice.id(), "amount": "100" }))
        .transact()
        .await?
        .into_result()?;
    Ok(Env { _worker: worker, vault, ft, alice })
}

async fn balance(env: &Env) -> anyhow::Result<String> {
    let b: String = env
        .vault
        .view("balance_of")
        .args_json(json!({ "account_id": env.alice.id() }))
        .await?
        .json()?;
    Ok(b)
}

async fn withdraw(env: &Env, amount: &str, tgas: u64) -> anyhow::Result<ExecutionFinalResult> {
    Ok(env
        .alice
        .call(env.vault.id(), "withdraw")
        .args_json(json!({ "amount": amount }))
        .gas(Gas::from_tgas(tgas))
        .transact()
        .await?)
}

/// Print one line per receipt: who ran it, gas, failure, logs.
fn dump(res: &ExecutionFinalResult) {
    for o in res.receipt_outcomes() {
        println!(
            "{} burnt {} Tgas failed={} logs={:?}",
            o.executor_id, o.gas_burnt.as_tgas(), o.is_failure(), o.logs
        );
    }
}

#[tokio::test]
async fn happy_path_debits() -> anyhow::Result<()> {
    let env = setup().await?;
    let res = withdraw(&env, "40", 100).await?;
    dump(&res);
    assert!(res.receipt_failures().is_empty());
    assert!(res.json::<bool>()?);
    assert_eq!(balance(&env).await?, "60");
    Ok(())
}

#[tokio::test]
async fn failing_callee_is_rolled_back() -> anyhow::Result<()> {
    let env = setup().await?;
    env.ft.call("set_fail").args_json(json!({ "fail": true })).transact().await?.into_result()?;

    let res = withdraw(&env, "40", 100).await?;
    dump(&res);

    // The transaction SUCCEEDS: its result is the callback's return value...
    assert!(res.is_success());
    // ...but one receipt inside it failed: ft_transfer on the mock token.
    let failures = res.receipt_failures();
    assert_eq!(failures.len(), 1);
    assert_eq!(&failures[0].executor_id, env.ft.id());
    assert!(res.logs().iter().any(|l| l.contains("refunded")));

    // Guard the rollback path's gas: it must stay well under its 10 Tgas reserve.
    let callback = res
        .receipt_outcomes()
        .iter()
        .find(|o| o.logs.iter().any(|l| l.contains("refunded")))
        .expect("callback outcome");
    assert!(callback.gas_burnt.as_tgas() < 7, "rollback path is too close to its reserve");

    assert!(!res.json::<bool>()?);
    assert_eq!(balance(&env).await?, "100");
    Ok(())
}

#[tokio::test]
async fn too_little_gas_is_refused_up_front() -> anyhow::Result<()> {
    let env = setup().await?;
    let res = withdraw(&env, "40", 15).await?;
    assert!(res.is_failure());
    assert_eq!(balance(&env).await?, "100"); // nothing was debited
    Ok(())
}

Reading outcomes#

APIGives you
is_success() / is_failure()The final status of the transaction (the end of the return chain)
json::<T>(), borsh::<T>(), raw_bytes()The final return value, decoded. Consumes the result, so call it last
receipt_outcomes()One ExecutionOutcome per receipt: executor_id, gas_burnt, logs, receipt_ids, is_failure()
receipt_failures()Only the receipts that failed
logs()Logs from the transaction and every receipt, in one list
total_gas_burntGas burnt by the whole tree: the number to budget against
The parts of ExecutionFinalResult you will use most
  • Name receipts by executor. In a call/callback flow, filter outcomes by executor_id and by a log line you emit on purpose.
  • Turn measurements into assertions. The gas_burnt < 7 check above makes a heavier rollback path fail CI before it can fail on mainnet. See Measure gas.
  • Test interleavings explicitly. To simulate another transaction landing between call and callback, make the mock callee call back into the vault, or send a second transaction with transact_async() before awaiting the first.

Testing yield timeouts#

A yielded transaction does not finish until it is resumed or 200 blocks pass. Send it with transact_async() and poll its status. As the near-sdk repository’s own MPC example notes, the status can report the transaction as ready before the callback has run, so wait for the callback’s log too.

Nobody answers: the timeout branch must refund (slow: about 200 blocks of real time)
Rust
use std::task::Poll;
use std::time::Duration;
use near_workspaces::types::{Gas, NearToken};
use serde_json::json;

#[tokio::test]
#[ignore = "waits for the 200-block yield timeout"]
async fn unanswered_request_times_out() -> anyhow::Result<()> {
    let worker = near_workspaces::sandbox().await?;
    let wasm = near_workspaces::compile_project("./contracts/oracle").await?;
    let oracle = worker.dev_deploy(&wasm).await?;
    let answerer = worker.dev_create_account().await?;
    let alice = worker.dev_create_account().await?;
    oracle
        .call("new")
        .args_json(json!({ "worker": answerer.id() }))
        .transact()
        .await?
        .into_result()?;

    let pending = alice
        .call(oracle.id(), "ask")
        .args_json(json!({ "question": "2+2?" }))
        .deposit(NearToken::from_millinear(10))
        .gas(Gas::from_tgas(100))
        .transact_async()
        .await?;

    let start = worker.view_block().await?.height();
    loop {
        tokio::time::sleep(Duration::from_secs(2)).await;
        if let Poll::Ready(res) = pending.status().await? {
            if res.logs().iter().any(|l| l.contains("timed out")) {
                assert_eq!(res.json::<Option<String>>()?, None);
                break;
            }
        }
        let waited = worker.view_block().await?.height() - start;
        anyhow::ensure!(waited < 260, "callback did not run after {} blocks", waited);
    }
    Ok(())
}

Check yourself

3 questions · progress saved in this browser

  1. 1.In the failing-callee test, ft_transfer panics and the callback restores the balance. What does res.is_success() return?
  2. 2.Why use a sandbox instead of testing_env! unit tests for callbacks?
  3. 3.Why should res.json::<T>() be called after your other assertions on res?