This is a mostly Claude-generated document describing the Rust end-to-end test
framework. It reflects the implementation that lives in crates/e2e-tests/
and is intended as a reference for engineers writing new tests.
This is a living document — the earlier draft (#2446) set the direction; the sections below have been revised to match what was actually built.
An E2E test runs real mpc-node OS processes against a real neard sandbox
with a freshly deployed MPC contract. The crate exposes four components that
cooperate:
graph TD
Cluster[MpcCluster] -->|owns| Sandbox[NearSandbox]
Cluster -->|owns| Blockchain[NearBlockchain]
Cluster -->|owns N| Node[MpcNode / MpcNodeSetup]
Cluster -->|owns| Ports[TestPorts]
Sandbox --- S1(neard process + genesis + boot node info)
Blockchain --- B1(near-kit RPC client)
Blockchain --- B2(Account creation, contract deploy, view/call)
Node --- N1(Spawn mpc-node binary)
Node --- N2(Generate secrets.json + start_config.toml)
Node --- N3(Scrape /metrics, toggle block ingestion)
Ports --- P1(Deterministic per-test port ranges)
-
Real
neardsandbox, realmpc-nodebinaries. Each test starts anear-sandboxprocess (via thenear-sandboxcrate, which downloads a real neard binary) and spawnsNrealmpc-nodeprocesses built from this repo. The mpc-node binary uses its built-in NEAR indexer, which in turn runs its own smallneardpeered with the sandbox over P2P — i.e. we exercise the production code path end-to-end, including config parsing, P2P networking, the indexer, and Prometheus metrics. -
RPC-only contract interaction. Tests never touch the sandbox process directly; everything happens through a
near-kitRPC client. TheNearBlockchainandDeployedContractwrappers accept any RPC URL, so the same test code could in principle run against a deployed testnet (though this isn't exercised in CI yet). -
Deterministic ports per test.
cargo nextestruns each test in its own process, so tests execute concurrently by default. Each test declares a uniqueport_seed;TestPorts::e2e_testsmaps that seed to a non-overlapping range of ports for the sandbox, the mpc-node web/P2P/pprof endpoints, and the per-node neard instances. Seeds are centralised as constants intests/common.rsto avoid collisions. -
Support for mixed node versions.
MpcClusterConfig::binary_pathstakes a vec of paths; if it has a single entry, all nodes use it, otherwise each node uses the corresponding entry. This enables compatibility tests acrossmpc-nodeversions (not yet in routine use, but wired up). -
Separate "operator" keys. Each node account has two full-access keys on chain: one used by the mpc-node process itself for its NEAR transactions (
node_keys) and a second key held by the test harness (operator_keys) for casting votes. Disjoint key sets keep the node's nonce sequence clean and let the harness issue contract calls without racing the node. -
Crate location. The framework lives at
crates/e2e-tests/. Itssrc/exposes the components as library APIs; actual tests live intests/and are declared via the[[test]] name = "e2e"entry inCargo.toml, which compiles them as submodules oftests/e2e.rs(one binary, one neard download, parallel test execution via nextest).
Note: The snippets below capture the shape of each component, not exhaustive signatures. Read the source for the authoritative API.
Starts a near-sandbox binary at a configurable version, on ports assigned by
TestPorts. Exposes everything an mpc-node indexer needs to peer with
it: genesis path, boot-node string (<pubkey>@127.0.0.1:<port>), and chain ID.
pub struct NearSandbox { /* wraps near_sandbox::Sandbox */ }
impl NearSandbox {
pub async fn start(ports: &TestPorts, version: &str) -> anyhow::Result<Self>;
pub fn rpc_url(&self) -> String;
pub fn genesis_path(&self) -> PathBuf;
pub fn boot_nodes(&self) -> anyhow::Result<String>;
pub fn chain_id(&self) -> anyhow::Result<String>;
}The inner Sandbox kills the process and deletes its temp directory on drop.
Wraps a near_kit::Near client signed as the sandbox root account. All
contract interaction, account creation, and WASM deployment goes through it.
pub struct NearBlockchain { /* root_client + rpc_url */ }
impl NearBlockchain {
pub fn new(rpc_url: &str, chain_id: &str, root_account: &str,
root_secret: near_kit::SecretKey) -> anyhow::Result<Self>;
pub async fn create_account_with_keys(&self, name: &str, balance_near: u128,
keys: &[SigningKey]) -> anyhow::Result<()>;
pub async fn create_account_and_deploy(&self, name: &str, balance_near: u128,
key: &SigningKey, wasm: &[u8]) -> anyhow::Result<DeployedContract>;
pub fn client_for(&self, account_id: &str, key: &SigningKey)
-> anyhow::Result<NearKitCaller<ExecutedOptimistic>>;
}DeployedContract wraps the contract's account ID plus its own near-kit
client. It exposes account_id(), client(), call (signed by the contract
account), call_from_with_deposit (untyped escape hatch for calls with no typed
method yet), view, and state() (parsed ProtocolContractState).
Binds a signer to a non-contract account (nodes voting, users submitting sign
requests) and implements the CallContract transport trait, s.t. typed calls can go
through MpcContractHandle
The T parameter is the wait level: how far a call waits
before returning (e.g. Final, ExecutedOptimistic,...).
Finality influences the return type (T::Response).
pub trait CallMpc: Sized {
fn call_mpc(self, contract_id: &AccountId) -> MpcContractHandle<Self>;
}
pub trait WithWaitLevel {
fn with_wait_level<U: WaitLevel>(self) -> MpcContractHandle<NearKitCaller<U>>;
}MpcNodeSetup owns everything needed to start a node (binary path, home dir,
signing keys, ports, contract account, sandbox chain info, foreign-chain
config). It writes secrets.json and start_config.toml on creation and
exposes start() to spawn the process, returning an MpcNode.
MpcNode owns a running child process via a ProcessGuard that SIGKILLs on
drop. It exposes:
kill() -> MpcNodeSetupandrestart() -> MpcNode— lifecycle.has_exited()— sanity check to surface early crashes.get_metric(name)— scrape/metricsand parse ani64.set_block_ingestion(active)— writes a flag file that pauses block ingest (requires thenetwork-hardship-simulationfeature, which CI builds with).web_address(),pprof_address(),setup()for accessors.
MpcNodeSetup also offers wipe_db() (targeted SST/MANIFEST cleanup) and
reset_mpc_state() (nuke the entire home dir — used after SIGKILL where the
indexer state may be corrupt).
The entry point for tests. MpcCluster::start(config) does everything:
- Create
TestPortsviaTestPorts::e2e_tests(config.port_seed). - Create a per-test artifact directory (
TestDir, kept on failure, see Debugging a failure). - Start the
NearSandbox. - Build a
NearBlockchainsigned as the sandbox root. - Generate deterministic signing keys for each node (near signer, p2p, operator, plus a separate contract deployer key).
- Deploy the compiled MPC contract WASM to
mpc.sandbox. - Create
nodeN.sandboxaccounts, each with anear_signer_keyand a disjointoperator_keyas full-access keys. - Call
init()on the contract with the initial participants. - Call
submit_participant_infofor each initial participant (with a{"Mock": "Valid"}attestation — enough to satisfy the contract in tests). - Deploy the tee-verifier WASM to
tee-verifier.sandboxand vote it in from every participant, for topology parity with production. Mock attestations are verified without calling it, so the verifier stays idle; the cross-contract flow is covered by the mpc-contract sandbox tests. - Spawn the
mpc-nodebinaries (start before adding domains so key generation has running nodes to talk to). - Sleep briefly and assert no node exited early.
- If
config.domainsis non-empty, voteadd_domainsfrom each participant and wait forRunningstate. - Create user accounts for signing/CKD/verify requests.
The returned cluster exposes:
- Node lifecycle:
kill_nodes,start_nodes,reset_and_start_nodes(wipe + start + wait for health),kill_all. - Contract state:
get_contract_state,wait_for_state,wait_for_node_healthy,get_tee_accounts. - Resharing:
start_resharing,start_resharing_and_wait,vote_cancel_resharing_from,add_domains_and_wait,start_add_domains. - Metrics:
get_metric_all_nodes,wait_for_metric_all_nodes. - Data management:
wipe_db,set_block_ingestion. - Request submission:
send_sign_request,send_ckd_request,send_verify_foreign_transaction. - Foreign chains:
view_foreign_chains_supported_by_contract,view_foreign_chain_configurations,view_available_foreign_chains,view_foreign_chains_configs,view_allowed_foreign_chain_providers,register_foreign_chain_config,whitelist_foreign_chains,wait_for_foreign_chains_registrations,wait_for_available_foreign_chains - User accounts:
user_client,default_user_account.
Drop kills all running nodes; the artifact directory is held via test_dir and
removed when the cluster is dropped, unless the test failed (see
Debugging a failure).
pub struct MpcClusterConfig {
pub num_nodes: usize,
pub threshold: usize,
pub domains: Vec<DomainConfig>,
pub binary_paths: Vec<PathBuf>, // one or num_nodes
pub contract_wasm: Vec<u8>, // pre-compiled by the test
pub tee_verifier_wasm: Vec<u8>, // pre-compiled by the test
pub port_seed: u16,
pub triples_to_buffer: usize,
pub presignatures_to_buffer: usize,
pub sandbox_version: String,
pub home_base: Option<PathBuf>,
pub initial_participant_indices: Vec<usize>,
pub migration_targets: Vec<usize>, // source node indices
pub init_format: ContractInitFormat,
pub foreign_chains: ForeignChainsClusterConfig,
}
pub struct ForeignChainsClusterConfig {
pub node_configs: Vec<ForeignChainsConfig>, // per-node; empty = default for all
pub whitelist: BTreeMap<ForeignChain, ChainEntry>, // voted in during setup
}
impl MpcClusterConfig {
pub fn default_for_test(port_seed: u16, contract_wasm: Vec<u8>, tee_verifier_wasm: Vec<u8>) -> Self;
pub fn participant_indices(&self) -> Vec<usize>;
}Each test declares a port_seed: u16 (the allocator's test_id). Ports are
computed as:
e2e base (20000) + test_id * ports_per_test() + offset
For the e2e scheme, ports_per_test is 82 (2 cluster ports + 8 per-node ports
× 10 maximum nodes); the mpc-node scheme uses a different block size, so this
number is specific to TestPorts::e2e_tests. Cluster-level ports cover the
sandbox RPC and network; per-node ports cover p2p, web UI, migration web UI,
pprof, and the node's internal neard RPC/network.
The allocator is test_port_allocator::TestPorts, the same struct the
mpc-node integration tests use — each constructor pins its scheme, and the
e2e scheme's range (20000+) is kept disjoint from TestPorts::mpc_node_tests
(10000+) and reserve_port (40000+).
Centralising seeds in tests/common.rs (e.g. CKD_VERIFICATION_PORT_SEED = 9)
keeps parallel tests from colliding. If a test crashes and leaves an orphan
mpc-node holding its ports, the next run will fail to bind — clean up with
cargo make kill-orphan-mpc-nodes.
foreign_chain_mock.rs exposes setup_bitcoin_mock, setup_evm_mock, and
setup_starknet_mock. Each takes an httpmock::MockServer and attaches a
POST / handler that returns hardcoded JSON-RPC responses for the methods
the MPC nodes call during verify_foreign_transaction. Tests own the
MockServers directly (rather than a wrapping struct) so they can wire the
URLs into the per-node ForeignChainsConfig via
MpcClusterConfig::foreign_chains.
Tests live under crates/e2e-tests/tests/<feature>.rs and must be declared as
a module from tests/e2e.rs. tests/common.rs provides helpers used by most
tests (must_setup_cluster, wait_for_presignatures, must_load_contract_wasm,
send_sign_request, etc.).
// tests/request_lifecycle.rs
use crate::common;
#[tokio::test]
async fn request_lifecycle__signature_request_succeeds() {
let (cluster, running) =
common::must_setup_cluster(common::SIGN_REQUEST_PER_SCHEME_PORT_SEED, |_| {}).await;
let mut rng = rand::thread_rng();
let user = cluster.default_user_account().clone();
common::send_sign_request(&cluster, &running, &mut rng, &user).await;
}must_setup_cluster builds the default 3-node / 2-of-3 / 3-domain cluster, waits
for Running, and blocks until presignatures are buffered. Pass a closure to
override fields on MpcClusterConfig.
NearSandbox uses the near-sandbox
crate, which downloads a precompiled neard binary on first run. That binary
is linked against a specific glibc and will crash on systems whose glibc is
incompatible (common on older/minimal Linux distributions and some CI
images).
If you hit a crash or "GLIBC not found" error from the sandbox, point the
tests at a locally built neard via NEAR_SANDBOX_BIN_PATH:
export NEAR_SANDBOX_BIN_PATH=/path/to/neard # e.g. a neard built from nearcore
cargo make e2e-testscargo make e2e-tests # Build required binaries and run all tests
cargo make e2e-tests-skip-build # Reuse binaries from a previous run
cargo make e2e-tests-skip-build <name> # Run tests matching a substring filter
cargo make e2e-tests-skip-build -E 'test(request_lifecycle)' --no-capture # Forward nextest flags
cargo make kill-orphan-mpc-nodes # Recover from ports held by crashed runsEverything after the task name is forwarded to cargo nextest run unchanged,
so any nextest filter or flag works (substring filters, -E expressions,
--no-capture, --retries, -j, etc.). With no arguments, the whole suite
runs with the ci-e2e profile. Do not put flags after a -- separator: it is
forwarded verbatim, and nextest only accepts filters, not flags, after --.
The task runner builds five things before tests run: the mpc-node binary with
the network-hardship-simulation feature, the MPC contract WASM, the
tee-verifier WASM, the test parallel contract WASM, and the backup CLI. WASM
paths are passed to tests via the MPC_CONTRACT_WASM,
MPC_TEE_VERIFIER_WASM and MPC_PARALLEL_CONTRACT_WASM environment
variables, read by the must_load_* helpers in tests/common.rs; if the
env var is unset and no pre-built WASM is found,
test-utils::contract_build::ContractBuilder builds it on the fly (useful for
local iteration).
CI runs the same task via the mpc-e2e-tests job.
Each test gets an artifact directory (/tmp/mpc-e2e-<random> by default) with a
node<i>/ subdirectory per node holding its config, secrets, RocksDB data, the
embedded neard home, stdout.log (the node's tracing output) and stderr.log
(panics only). TestDir
(src/test_dir.rs) keeps it when the test failed and prints the path to the
test's stderr; a startup failure also carries it in the error:
failed to start cluster: cluster artifacts preserved in /tmp/mpc-e2e-AbC123:
mpc-node 0 exited early, check /tmp/mpc-e2e-AbC123/node0 (stdout.log holds its
logs, stderr.log any panic)
| Variable | Effect |
|---|---|
E2E_KEEP_TMP=1 |
Keep artifacts for passing tests too. 0, false, no or off (any case) deletes them even for failing ones. |
E2E_HOME_BASE=<dir> |
Parent of the artifact directories, created if missing. Same as MpcClusterConfig::home_base, without editing the test. |
MPC_NODE_LOG=<filter> |
RUST_LOG for the spawned mpc-node processes (default DEBUG). |
MPC_NODE_BACKTRACE=<0|1|full> |
RUST_BACKTRACE for the spawned mpc-node processes (default 1). |
- Follow the
<subject>__should_<assertion>or<subject>__<scenario>naming fromdocs/engineering-standards.md. - Reserve a unique
port_seedconstant intests/common.rsbefore adding a new test. Don't reuse someone else's seed, even if the test is short. - Prefer
common::must_setup_clusterover callingMpcCluster::startdirectly; it initialisestracing_subscriberand waits for presignatures. - Tests must be deterministic across parallel execution. Use the port
allocator, the per-cluster artifact directory (
cluster.test_dir), and the deterministic key generation rather than creating state outside the cluster. - Arithmetic in tests uses raw
+/-/*//; overflow panics are the desired failure mode (seeCLAUDE.md).
docs/engineering-standards.md— test naming, panic policy, I/O separation.- Issue #2440 —
MpcNodedesign. - Issue #2441 —
MpcClusterdesign. - PR #2446 — original design proposal.