Skip to main content

thruput_testkit/
lib.rs

1//! A harness for writing multi-agent integration tests as ordinary application
2//! code.
3//!
4//! A test builds a [`Scenario`] against an explicit [`NetworkConfig`], hands it
5//! a set of [`Agent`]s, and asserts on what they observe. Test scenarios are
6//! run in `#[tokio::test]` functions.
7//!
8//! Each agent receives a deterministic native account derived from the scenario
9//! seed. Agents [`deploy`] contracts with explicit funding and optional
10//! initialization, [`call`] them, await receipts, and read chain state, in
11//! addition to coordinating with other agents off-chain and using the virtual
12//! clock and deterministic randomness.
13//!
14//! [`deploy`]: AgentContext::deploy_artifact
15//! [`call`]: crate::contract
16//!
17//! ```rust,no_run
18//! use thruput_testkit::{Agent, DeployOptions, LocalNetworkConfig, NetworkConfig, Scenario};
19//!
20//! # async fn example() -> thruput_testkit::Result<()> {
21//! let mut config = LocalNetworkConfig::default();
22//! config.hog_devnode_binary = Some("/path/to/supporting/hog-devnode".into());
23//! config.contract_artifact_directories.push("/path/to/built_contracts".into());
24//! let scenario = Scenario::builder("counter-test")
25//!     .network(NetworkConfig::local(config))
26//!     .artifact("counter", "target/thruput/counter.rv32a")
27//!     .build()
28//!     .await?;
29//!
30//! scenario.run_agents([Agent::new("alice", |mut ctx| async move {
31//!     ctx.fund(1_000_000_000).await?; // 100 native tokens from the bootstrap faucet
32//!     let deployed = ctx.deploy_artifact("counter", DeployOptions::funded(100_000_000)).await?;
33//!     assert!(deployed.registered);
34//!     Ok(())
35//! })]).await?;
36//! scenario.stop().await?;
37//! # Ok(())
38//! # }
39//! ```
40//!
41//! # Networks and accounts
42//!
43//! [`NetworkConfig::existing`] attaches to a running node,
44//! [`NetworkConfig::local`] launches and owns an ephemeral `hog-devnode`, and
45//! [`NetworkConfig::offline`] runs agents with locally-derived accounts and no chain
46//! (for coordination/timing/messaging tests). [`Scenario::run_agents`] returns an
47//! error when any agent returns an error, so the ordinary `.await?` test path cannot
48//! silently ignore agent failures.
49//!
50//! Before using a live network, the scenario checks that the endpoint is
51//! compatible and serves the network hash named in its runtime manifest, so
52//! nothing is signed for a different network.
53//!
54//! The scenario owns its fixture accounts' keys, derived from its seed;
55//! [`ScenarioBuilder::accounts`] sets how many there are. A local node receives
56//! only their public allocations, of
57//! [`LocalNetworkConfig::initial_account_tokens`] raw native token units each
58//! (100 native tokens by default). On an existing network the accounts start
59//! empty: fund them explicitly with [`AgentContext::fund`] or
60//! [`AgentContext::submit_fund`].
61//!
62//! # Manual blocks and uncertain submissions
63//!
64//! [`AgentContext::submit_fund`], [`AgentContext::submit_deploy_artifact`] and
65//! the `submit` forms of calls return handles without waiting, for tests that
66//! drive blocks by hand (see [`BlockDriverConfig`]): await pending, produce a
67//! block, then await the receipt. Receipt waits are read-only.
68//!
69//! Each submission reserves its nonce before dispatch. When a submission's
70//! outcome is uncertain, the harness never resends it: the error retains the
71//! original transaction hash and exact signed bytes for reconciliation
72//! ([`TestkitError::Submission`], or [`TestkitError::ReceiptWait`] for a wait
73//! that stopped), as does a [`SubmittedTransaction`]. In particular:
74//!
75//! - A deployment is accepted only once its successful receipt matches the
76//!   original hash and predicted address; until then
77//!   [`TestkitError::UnverifiedDeployment`] retains both. A new request for
78//!   the same artifact with different funding or initialization is refused
79//!   ([`TestkitError::DeploymentRequestMismatch`]) until the retained attempt
80//!   is resolved, and scheduler statuses never replay or discard it.
81//! - Local funding is retained on the [`Network`] handle before it is sent.
82//!   After an uncertain response, retry the same recipient and amount on that
83//!   handle, and keep it alive during recovery; a changed amount is refused
84//!   ([`TestkitError::FundingRequestMismatch`]). Conflicting or invalid funding
85//!   evidence is kept inspectable in [`TestkitError::InvalidFundingEvidence`]
86//!   or [`TestkitError::InvalidFundingResponse`]; it does not prove admission.
87//!
88//! # Typed calls
89//!
90//! With `thruput-sdk`'s `remote` feature (typically on a dev-dependency),
91//! `#[contract]` generates an `<Iface>Remote` proxy for each interface. Bound
92//! to an agent's [`Caller`], each of its methods encodes the arguments and
93//! returns a prepared invocation: a [`ContractView`] for a method declared
94//! `pure` or `readOnly`, which awaiting simulates with the agent as
95//! `msg.sender`, or otherwise a [`ContractCall`], which awaiting submits
96//! through the agent's wallet. Either decodes the method's return type. A
97//! method with no effect annotation gets the same proxy method as one declared
98//! `stateChanging`, so the annotation can be omitted; the omission is only
99//! visible in the contract's spec, where the effect is recorded as undeclared.
100//!
101//! ```rust,no_run
102//! # #![allow(unexpected_cfgs)]
103//! mod counter {
104//!     use thruput_sdk::contract;
105//!
106//!     #[contract(version = "0.1.0")]
107//!     pub trait Counter {
108//!         /// Add `by` to the count, returning the new count.
109//!         fn increment(&mut self, by: u64) -> u64;
110//!
111//!         #[contract(effect = "readOnly")]
112//!         fn get(&self) -> u64;
113//!     }
114//! }
115//!
116//! use counter::CounterRemote;
117//! use thruput_testkit::Agent;
118//! use thruput_testkit::xdr::types::ContractAddress;
119//!
120//! fn alice(address: ContractAddress) -> Agent {
121//!     Agent::new("alice", move |ctx| async move {
122//!         let counter = CounterRemote::new(ctx.caller().clone(), &address);
123//!         let count = counter.increment(1).await?; // submitted
124//!         assert_eq!(counter.get().await?, count); // simulated
125//!         Ok(())
126//!     })
127//! }
128//! # fn main() {}
129//! ```
130//!
131//! See [`contract`] for receipts, deferred submission, and reads through
132//! methods without a declared effect, and [`Contract`] for calling a method by
133//! name when no interface crate is at hand.
134
135mod account;
136pub mod agent;
137mod artifact;
138pub mod caller;
139pub mod clock;
140pub mod contract;
141pub mod error;
142mod logging;
143pub mod mailbox;
144mod manifest;
145pub mod network;
146mod nonce;
147pub mod rng;
148pub mod scenario;
149
150pub use account::{DeployOptions, Deployed, SubmittedDeploy};
151pub use agent::{Agent, AgentContext, AgentId};
152pub use artifact::ArtifactKind;
153pub use caller::Caller;
154pub use clock::Clock;
155pub use contract::{
156    Contract, ContractCall, ContractEntrypointID, ContractView, SubmittedContractCall,
157    entrypoint_id, try_entrypoint_id,
158};
159pub use error::{AgentFailure, AgentFailures, Result, TestkitError};
160pub use logging::init_tracing;
161pub use mailbox::{Envelope, Mailbox, Message};
162pub use network::{LocalNetworkConfig, Network, NetworkConfig, SubmittedTransaction};
163pub use rng::AgentRng;
164pub use scenario::{AgentOutcome, Scenario, ScenarioBuilder, ScenarioReport};
165pub use thruput_node_supervisor::{
166    BlockDriverConfig, BlockDriverMode, BlockProductionError, LogLevel, ProducedBlock,
167};
168pub use thruput_signing::default_wallet::WalletInitCall;
169
170// Re-exports so agent code can name RPC/XDR types and encode calldata without
171// taking direct dependencies on these crates.
172pub use thruput_json_rpc_types as rpc_types;
173pub use thruput_xdr as xdr;
174pub use xdrs;