diff --git a/.config/nextest.toml b/.config/nextest.toml index 7862e5f06..ac5ce0d8f 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -1,2 +1,2 @@ [profile.ci-workflow] -default-filter = 'not package(contract-history) and not test(=test_upload_quote_for_collateral_with_phala_endpoint)' +default-filter = 'not package(contract-history) and not package(e2e-tests) and not test(=test_upload_quote_for_collateral_with_phala_endpoint)' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c0067f6d1..723d06d4c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -145,6 +145,58 @@ jobs: - name: Run cargo-nextest run: cargo nextest run --cargo-profile=test-release --all-features --locked --profile=ci-workflow + mpc-e2e-tests: + name: "Rust E2E tests" + runs-on: warp-ubuntu-2404-x64-16x + timeout-minutes: 60 + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + persist-credentials: false + + - name: Install build dependencies + run: | + sudo apt-get update + sudo apt-get install -y liblzma-dev + + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1 + with: + save-if: ${{ github.ref == 'refs/heads/main' }} + cache-provider: "warpbuild" + + - name: Install cargo-nextest + uses: taiki-e/install-action@d4422f254e595ee762a758628fe4f16ce050fa2e # v2.67.28 + with: + tool: nextest@0.9.126 + + - name: Install cargo-binstall + uses: taiki-e/install-action@d4422f254e595ee762a758628fe4f16ce050fa2e # v2.67.28 + with: + tool: cargo-binstall + + - name: Install wasm-opt + run: | + cargo binstall --force --no-confirm --locked wasm-opt@0.116.1 + echo "${HOME}/.cargo/bin" >> $GITHUB_PATH + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Build mpc-node binary + run: cargo build -p mpc-node --release --locked --features test-utils + + - name: Build contract WASM + run: | + cargo build -p mpc-contract --target=wasm32-unknown-unknown --profile=release-contract --locked + wasm-opt -Oz --enable-bulk-memory target/wasm32-unknown-unknown/release-contract/mpc_contract.wasm -o target/wasm32-unknown-unknown/release-contract/mpc_contract.wasm + + - name: Run E2E tests + run: cargo nextest run -p e2e-tests --cargo-profile=test-release --locked + mpc-pytest-build: name: "MPC pytests: build" runs-on: warp-ubuntu-2404-x64-16x diff --git a/Cargo.lock b/Cargo.lock index 5cbba0c6f..a12925743 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2936,6 +2936,27 @@ dependencies = [ "memmap2 0.5.10", ] +[[package]] +name = "e2e-tests" +version = "3.7.0" +dependencies = [ + "anyhow", + "bs58 0.5.1", + "ed25519-dalek", + "hex", + "near-mpc-contract-interface", + "near-sandbox", + "near-workspaces", + "rand 0.8.5", + "serde", + "serde_json", + "tempfile", + "tokio", + "toml 1.0.6+spec-1.1.0", + "tracing", + "tracing-subscriber", +] + [[package]] name = "easy-cast" version = "0.5.4" @@ -7325,6 +7346,7 @@ dependencies = [ "async-trait", "base64 0.22.1", "bs58 0.5.1", + "cargo-near-build", "chrono", "fs2", "json-patch 2.0.0", diff --git a/Cargo.toml b/Cargo.toml index 2e3d679d5..88cde0262 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "crates/contract", "crates/contract-history", "crates/devnet", + "crates/e2e-tests", "crates/foreign-chain-inspector", "crates/foreign-chain-rpc-interfaces", "crates/include-measurements", @@ -146,6 +147,7 @@ near-account-id = "2.5.0" near-jsonrpc-client = "0.20.0" near-jsonrpc-primitives = "0.34.6" near-primitives = "0.34.6" +near-sandbox = "0.3.5" near-sdk = { version = "5.24.1", features = [ "legacy", "unit-testing", diff --git a/crates/e2e-tests/Cargo.toml b/crates/e2e-tests/Cargo.toml new file mode 100644 index 000000000..639976d9a --- /dev/null +++ b/crates/e2e-tests/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "e2e-tests" +edition.workspace = true +version.workspace = true +license.workspace = true + +[dependencies] +anyhow.workspace = true +bs58.workspace = true +ed25519-dalek.workspace = true +hex.workspace = true +near-mpc-contract-interface = { workspace = true } +near-sandbox = { workspace = true } +near-workspaces = { workspace = true, features = ["unstable"] } +rand.workspace = true +serde = { workspace = true } +serde_json.workspace = true +tempfile.workspace = true +tokio.workspace = true +toml.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true + +[lints] +workspace = true diff --git a/crates/e2e-tests/README.md b/crates/e2e-tests/README.md new file mode 100644 index 000000000..e1b68b421 --- /dev/null +++ b/crates/e2e-tests/README.md @@ -0,0 +1,80 @@ +# E2E Test Infrastructure + +Rust E2E test framework replacing the Python pytest system tests. Spawns real +`mpc-node` OS processes against a local NEAR sandbox, exercising the full +binary including config parsing, P2P networking, built-in NEAR indexer, and +Prometheus metrics. + +## Architecture + +``` +MpcCluster + |-- SandboxNode near-sandbox process with controlled ports + |-- NearBlockchain RPC client (near-workspaces) for contract interaction + |-- Vec N mpc-node OS processes, each with its own neard indexer +``` + +- **SandboxNode** starts a `near-sandbox` neard validator with deterministic ports. +- **NearBlockchain** is a pure RPC client wrapping `near-workspaces`. It deploys + the MPC contract, creates accounts, submits transactions, and queries state. + Environment-agnostic -- can target sandbox or testnet. +- **MpcNode** manages a single `mpc-node` binary. Generates a `start_config.toml` + pointing the node's built-in NEAR indexer at the sandbox validator via + `boot_nodes`. Each node runs its own neard process internally, peering with the + sandbox validator over P2P. +- **MpcCluster** orchestrates everything: starts sandbox, deploys contract, creates + accounts, starts N nodes, initializes the contract, adds signature domains, and + waits for the Running state. + +## Port Allocation + +Each test gets a unique `test_id`. All ports are computed deterministically: + +``` +BASE_PORT (20000) + test_id * 82 + offset +``` + +Per test: 2 cluster-level ports (sandbox RPC, sandbox network) + 8 ports per +node (P2P, web UI, migration UI, pprof, near RPC, near network, 2 reserved) +times up to 10 nodes = 82 ports total. + +This allows `cargo nextest` to run tests in parallel without port collisions. + +## Running Tests + +```bash +# Prerequisites: build mpc-node binary and contract WASM +cargo build -p mpc-node --release --features test-utils +cargo near build non-reproducible-wasm \ + --manifest-path crates/contract/Cargo.toml --locked + +# Run E2E tests +cargo nextest run -p e2e-tests --cargo-profile=test-release +``` + +## Adding a New Test + +1. Create `tests/my_test.rs` +2. Use a unique `E2ePortAllocator::new()` to avoid port collisions +3. Build an `MpcCluster` with your desired configuration +4. Use cluster methods to interact with the contract and assert behavior + +```rust +#[tokio::test(flavor = "multi_thread")] +async fn test_my_scenario() -> anyhow::Result<()> { + let cluster = MpcCluster::start(ClusterConfig { + num_nodes: 3, + threshold: 2, + port_allocator: E2ePortAllocator::new(42), + ..ClusterConfig::default() + }).await?; + + // ... test logic ... + Ok(()) +} +``` + +## Design Reference + +See `docs/pytest-deprecation.md` (PR #2446) for the full design document +describing the migration from Python to Rust E2E tests. diff --git a/crates/e2e-tests/src/blockchain.rs b/crates/e2e-tests/src/blockchain.rs new file mode 100644 index 000000000..5910083cd --- /dev/null +++ b/crates/e2e-tests/src/blockchain.rs @@ -0,0 +1,538 @@ +use std::path::Path; +use std::time::Duration; + +use anyhow::Context; +use near_mpc_contract_interface::method_names; +use near_mpc_contract_interface::types::{ + Attestation, DomainConfig, DomainId, DomainPurpose, Ed25519PublicKey, MockAttestation, + Participants, ProtocolContractState, SignatureScheme, Threshold, ThresholdParameters, +}; +use near_workspaces::network::Custom; +use near_workspaces::types::{AccessKey, Gas, NearToken}; +use near_workspaces::{Account, AccountId, Contract, Worker}; +use serde_json::json; + +use crate::sandbox::SandboxNode; + +const GAS_FOR_SIGN_CALL: Gas = Gas::from_tgas(15); +const SIGNATURE_DEPOSIT: u128 = 1; +const CKD_DEPOSIT: u128 = 1; + +/// Pure RPC client for interacting with the NEAR blockchain. +/// +/// Wraps `near-workspaces::Worker` to provide contract deployment, +/// account management, and MPC contract interactions. Environment-agnostic: +/// can target sandbox or testnet. +pub struct NearBlockchain { + worker: Worker, + root_account: Account, + contract: Option, + user_account: Option, +} + +impl NearBlockchain { + /// Connect to an existing sandbox node via RPC. + /// + /// Reads the validator key from the sandbox home directory to obtain the + /// root account (`test.near`) credentials. + pub async fn from_sandbox(sandbox: &SandboxNode) -> anyhow::Result { + let rpc_url = sandbox.rpc_url(); + tracing::info!(%rpc_url, "connecting near-workspaces to sandbox"); + + let worker = near_workspaces::custom(&rpc_url) + .await + .context("failed to connect near-workspaces to sandbox")?; + + // Load the validator key to get root account credentials. + let validator_key_path = sandbox.home_dir().join("validator_key.json"); + let (account_id, secret_key) = load_key_file(&validator_key_path)?; + + let root_account = Account::from_secret_key(account_id, secret_key, &worker); + + Ok(Self { + worker, + root_account, + contract: None, + user_account: None, + }) + } + + /// Deploy the MPC contract WASM and set up a user account for requests. + pub async fn deploy_contract(&mut self, wasm: &[u8]) -> anyhow::Result<()> { + // Deploy to a subaccount of root + let contract_account_id: AccountId = format!("mpc.{}", self.root_account.id()) + .parse() + .context("invalid contract account id")?; + + let (_, sk) = self.worker.generate_dev_account_credentials(); + let result = self + .root_account + .batch(&contract_account_id) + .create_account() + .add_key(sk.public_key(), AccessKey::full_access()) + .transfer(NearToken::from_near(100)) + .deploy(wasm) + .transact() + .await + .context("failed to deploy contract")?; + + anyhow::ensure!( + result.is_success(), + "contract deployment failed: {result:?}" + ); + + let contract = Contract::from_secret_key(contract_account_id, sk, &self.worker); + self.contract = Some(contract); + + // Create a user account for submitting sign/ckd requests + let user_account_id: AccountId = format!("user.{}", self.root_account.id()) + .parse() + .context("invalid user account id")?; + + let (_, user_sk) = self.worker.generate_dev_account_credentials(); + let result = self + .root_account + .batch(&user_account_id) + .create_account() + .add_key(user_sk.public_key(), AccessKey::full_access()) + .transfer(NearToken::from_near(100)) + .transact() + .await + .context("failed to create user account")?; + + anyhow::ensure!( + result.is_success(), + "user account creation failed: {result:?}" + ); + + self.user_account = Some(Account::from_secret_key( + user_account_id, + user_sk, + &self.worker, + )); + Ok(()) + } + + pub fn contract(&self) -> &Contract { + self.contract.as_ref().expect("contract not deployed") + } + + pub fn contract_id(&self) -> &AccountId { + self.contract().id() + } + + pub fn user_account(&self) -> &Account { + self.user_account + .as_ref() + .expect("user account not created") + } + + pub fn root_account(&self) -> &Account { + &self.root_account + } + + pub fn worker(&self) -> &Worker { + &self.worker + } + + /// Create a named subaccount under root (e.g. `signer_0.test.near`). + pub async fn create_subaccount(&self, name: &str) -> anyhow::Result { + let account_id: AccountId = format!("{name}.{}", self.root_account.id()) + .parse() + .with_context(|| format!("invalid subaccount id: {name}"))?; + + let (_, sk) = self.worker.generate_dev_account_credentials(); + let result = self + .root_account + .batch(&account_id) + .create_account() + .add_key(sk.public_key(), AccessKey::full_access()) + .transfer(NearToken::from_near(100)) + .transact() + .await + .with_context(|| format!("failed to create subaccount {name}"))?; + + anyhow::ensure!( + result.is_success(), + "subaccount creation failed: {result:?}" + ); + + Ok(Account::from_secret_key(account_id, sk, &self.worker)) + } + + /// Initialize the MPC contract with threshold parameters. + pub async fn init_contract(&self, params: &ThresholdParameters) -> anyhow::Result<()> { + let contract = self.contract(); + let result = contract + .call(method_names::INIT) + .args_json(json!({ + "parameters": params, + })) + .max_gas() + .transact() + .await + .context("init call failed")?; + + anyhow::ensure!(result.is_success(), "init failed: {result:?}"); + Ok(()) + } + + /// Submit mock TEE attestation for a participant. + pub async fn submit_attestation(&self, account: &Account, sign_pk: &str) -> anyhow::Result<()> { + let attestation = Attestation::Mock(MockAttestation::Valid); + let tls_key: Ed25519PublicKey = sign_pk + .parse() + .map_err(|e| anyhow::anyhow!("invalid ed25519 public key: {e}"))?; + + let result = account + .call(self.contract_id(), method_names::SUBMIT_PARTICIPANT_INFO) + .args_json((&attestation, &tls_key)) + .max_gas() + .transact() + .await + .context("submit_participant_info failed")?; + + anyhow::ensure!( + result.is_success(), + "submit_participant_info failed: {result:?}" + ); + Ok(()) + } + + /// Vote to add domains from each voter account. + pub async fn vote_add_domains( + &self, + voters: &[&Account], + domains: &[DomainConfig], + ) -> anyhow::Result<()> { + let args = json!({ "domains": domains }); + + for voter in voters { + let result = voter + .call(self.contract_id(), method_names::VOTE_ADD_DOMAINS) + .args_json(&args) + .max_gas() + .transact() + .await + .with_context(|| format!("vote_add_domains failed for {}", voter.id()))?; + + anyhow::ensure!( + result.is_success(), + "vote_add_domains failed for {}: {result:?}", + voter.id() + ); + } + Ok(()) + } + + /// Query the contract state view. + pub async fn get_state(&self) -> anyhow::Result { + let result = self + .contract() + .view(method_names::STATE) + .await + .context("state view failed")?; + + result + .json() + .context("failed to deserialize contract state") + } + + /// Wait until the contract reaches the Running state. + pub async fn wait_for_running(&self, timeout: Duration) -> anyhow::Result<()> { + let start = tokio::time::Instant::now(); + let poll_interval = Duration::from_millis(500); + + loop { + let state = self.get_state().await?; + match &state { + ProtocolContractState::Running(_) => { + tracing::info!("contract is in Running state"); + return Ok(()); + } + _ => { + if start.elapsed() > timeout { + anyhow::bail!( + "timed out waiting for Running state after {:?}, current: {state:?}", + timeout + ); + } + tracing::debug!(?state, "waiting for Running state..."); + tokio::time::sleep(poll_interval).await; + } + } + } + } + + /// Wait until the contract reaches the Initializing state. + pub async fn wait_for_initializing(&self, timeout: Duration) -> anyhow::Result<()> { + let start = tokio::time::Instant::now(); + let poll_interval = Duration::from_millis(500); + + loop { + let state = self.get_state().await?; + match &state { + ProtocolContractState::Initializing(_) => { + tracing::info!("contract is in Initializing state"); + return Ok(()); + } + _ => { + if start.elapsed() > timeout { + anyhow::bail!( + "timed out waiting for Initializing state after {:?}, current: {state:?}", + timeout + ); + } + tracing::debug!(?state, "waiting for Initializing state..."); + tokio::time::sleep(poll_interval).await; + } + } + } + } + + /// Submit signature requests for all Sign domains and wait for responses. + pub async fn send_and_await_signature_requests( + &self, + num_per_domain: usize, + timeout: Duration, + ) -> anyhow::Result<()> { + let state = self.get_state().await?; + let ProtocolContractState::Running(running) = state else { + anyhow::bail!("contract not in Running state"); + }; + + let user = self.user_account(); + let contract_id = self.contract_id().clone(); + + for domain in &running.domains.domains { + let purpose = domain.purpose.as_ref(); + let is_sign = matches!(purpose, Some(DomainPurpose::Sign) | None); + let is_ecdsa_or_eddsa = matches!( + domain.scheme, + SignatureScheme::Secp256k1 | SignatureScheme::Ed25519 + ); + + if !is_sign || !is_ecdsa_or_eddsa { + continue; + } + + tracing::info!( + domain_id = domain.id.0, + scheme = ?domain.scheme, + count = num_per_domain, + "submitting sign requests" + ); + + for i in 0..num_per_domain { + let payload = match domain.scheme { + SignatureScheme::Secp256k1 => { + let bytes: [u8; 32] = rand::random(); + json!({"Ecdsa": hex::encode(bytes)}) + } + SignatureScheme::Ed25519 => { + let bytes: [u8; 32] = rand::random(); + json!({"Eddsa": hex::encode(bytes)}) + } + _ => continue, + }; + + let args = json!({ + "request": { + "domain_id": domain.id.0, + "path": "test", + "payload_v2": payload, + } + }); + + let start = tokio::time::Instant::now(); + loop { + let result = user + .call(&contract_id, method_names::SIGN) + .args_json(&args) + .gas(GAS_FOR_SIGN_CALL) + .deposit(NearToken::from_yoctonear(SIGNATURE_DEPOSIT)) + .transact() + .await + .with_context(|| { + format!("sign request {i} for domain {} failed", domain.id.0) + })?; + + if result.is_success() { + tracing::info!( + domain_id = domain.id.0, + request = i, + "sign request succeeded" + ); + break; + } + + if start.elapsed() > timeout { + anyhow::bail!( + "sign request {i} for domain {} timed out: {result:?}", + domain.id.0 + ); + } + + tokio::time::sleep(Duration::from_secs(1)).await; + } + } + } + Ok(()) + } + + /// Submit CKD requests for all CKD domains and wait for responses. + pub async fn send_and_await_ckd_requests( + &self, + num_per_domain: usize, + timeout: Duration, + ) -> anyhow::Result<()> { + let state = self.get_state().await?; + let ProtocolContractState::Running(running) = state else { + anyhow::bail!("contract not in Running state"); + }; + + let user = self.user_account(); + let contract_id = self.contract_id().clone(); + + for domain in &running.domains.domains { + let purpose = domain.purpose.as_ref(); + if !matches!(purpose, Some(DomainPurpose::CKD)) { + continue; + } + + tracing::info!( + domain_id = domain.id.0, + scheme = ?domain.scheme, + count = num_per_domain, + "submitting CKD requests" + ); + + for i in 0..num_per_domain { + // BLS12-381 G1 generator point (compressed, 48 bytes). + // This is a well-known valid point on the curve. + let bls_g1_generator: [u8; 48] = [ + 0x97, 0xf1, 0xd3, 0xa7, 0x31, 0x97, 0xd7, 0x94, 0x26, 0x95, 0x63, 0x8c, 0x4f, + 0xa9, 0xac, 0x0f, 0xc3, 0x68, 0x8c, 0x4f, 0x97, 0x74, 0xb9, 0x05, 0xa1, 0x4e, + 0x3a, 0x3f, 0x17, 0x1b, 0xac, 0x58, 0x6c, 0x55, 0xe8, 0x3f, 0xf9, 0x7a, 0x1a, + 0xef, 0xfb, 0x3a, 0xf0, 0x0a, 0xdb, 0x22, 0xc6, 0xbb, + ]; + let app_public_key = format!( + "bls12381g1:{}", + bs58::encode(&bls_g1_generator).into_string() + ); + + let args = json!({ + "request": { + "derivation_path": format!("test/{i}"), + "app_public_key": app_public_key, + "domain_id": domain.id.0, + } + }); + + let start = tokio::time::Instant::now(); + loop { + let result = user + .call(&contract_id, method_names::REQUEST_APP_PRIVATE_KEY) + .args_json(&args) + .max_gas() + .deposit(NearToken::from_yoctonear(CKD_DEPOSIT)) + .transact() + .await + .with_context(|| { + format!("CKD request {i} for domain {} failed", domain.id.0) + })?; + + if result.is_success() { + tracing::info!( + domain_id = domain.id.0, + request = i, + "CKD request succeeded" + ); + break; + } + + if start.elapsed() > timeout { + anyhow::bail!( + "CKD request {i} for domain {} timed out: {result:?}", + domain.id.0 + ); + } + + tokio::time::sleep(Duration::from_secs(1)).await; + } + } + } + Ok(()) + } +} + +/// Build ThresholdParameters from participant data. +pub fn make_threshold_parameters( + accounts: &[(AccountId, String)], // (account_id, sign_pk) + p2p_urls: &[String], + threshold: u64, +) -> ThresholdParameters { + let mut participants_list = Vec::new(); + for (i, ((account_id, sign_pk), url)) in accounts.iter().zip(p2p_urls.iter()).enumerate() { + participants_list.push(( + near_mpc_contract_interface::types::AccountId(account_id.to_string()), + near_mpc_contract_interface::types::ParticipantId(i as u32), + near_mpc_contract_interface::types::ParticipantInfo { + url: url.clone(), + sign_pk: sign_pk.clone(), + }, + )); + } + + ThresholdParameters { + participants: Participants { + next_id: near_mpc_contract_interface::types::ParticipantId(accounts.len() as u32), + participants: participants_list, + }, + threshold: Threshold(threshold), + } +} + +/// Build the default set of domains to add: Secp256k1/Sign, Ed25519/Sign, Bls12381/CKD. +pub fn default_domains(start_id: u64) -> Vec { + vec![ + DomainConfig { + id: DomainId(start_id), + scheme: SignatureScheme::Secp256k1, + purpose: Some(DomainPurpose::Sign), + }, + DomainConfig { + id: DomainId(start_id + 1), + scheme: SignatureScheme::Ed25519, + purpose: Some(DomainPurpose::Sign), + }, + DomainConfig { + id: DomainId(start_id + 2), + scheme: SignatureScheme::Bls12381, + purpose: Some(DomainPurpose::CKD), + }, + ] +} + +/// Load account id and secret key from a NEAR key file (validator_key.json format). +fn load_key_file(path: &Path) -> anyhow::Result<(AccountId, near_workspaces::types::SecretKey)> { + let content = std::fs::read_to_string(path) + .with_context(|| format!("failed to read key file: {}", path.display()))?; + + let parsed: serde_json::Value = + serde_json::from_str(&content).context("failed to parse key file")?; + + let account_id: AccountId = parsed["account_id"] + .as_str() + .context("missing account_id")? + .parse() + .context("invalid account_id")?; + + let secret_key: near_workspaces::types::SecretKey = parsed["secret_key"] + .as_str() + .context("missing secret_key")? + .parse() + .context("invalid secret_key")?; + + Ok((account_id, secret_key)) +} diff --git a/crates/e2e-tests/src/lib.rs b/crates/e2e-tests/src/lib.rs new file mode 100644 index 000000000..df3ce499f --- /dev/null +++ b/crates/e2e-tests/src/lib.rs @@ -0,0 +1,8 @@ +pub mod blockchain; +pub mod mpc_cluster; +pub mod mpc_node; +pub mod port_allocator; +pub mod sandbox; + +pub use mpc_cluster::{ClusterConfig, MpcCluster}; +pub use port_allocator::E2ePortAllocator; diff --git a/crates/e2e-tests/src/mpc_cluster.rs b/crates/e2e-tests/src/mpc_cluster.rs new file mode 100644 index 000000000..e579b0e39 --- /dev/null +++ b/crates/e2e-tests/src/mpc_cluster.rs @@ -0,0 +1,338 @@ +use std::path::PathBuf; +use std::time::Duration; + +use anyhow::Context; +use ed25519_dalek::SigningKey; +use near_workspaces::types::{AccessKey, AccessKeyPermission, FunctionCallPermission}; + +use crate::blockchain::{self, NearBlockchain}; +use crate::mpc_node::{MpcNode, MpcNodeConfig, NodePorts}; +use crate::port_allocator::E2ePortAllocator; +use crate::sandbox::SandboxNode; + +/// Configuration for starting an MPC cluster. +pub struct ClusterConfig { + pub num_nodes: usize, + pub threshold: u64, + pub triples_to_buffer: usize, + pub presignatures_to_buffer: usize, + pub port_allocator: E2ePortAllocator, + /// Path to the `mpc-node` binary. Defaults to `target/release/mpc-node`. + pub binary_path: Option, + /// Path to the contract WASM. Defaults to + /// `target/wasm32-unknown-unknown/release-contract/mpc_contract.wasm`. + pub wasm_path: Option, +} + +impl Default for ClusterConfig { + fn default() -> Self { + Self { + num_nodes: 2, + threshold: 2, + triples_to_buffer: 200, + presignatures_to_buffer: 100, + port_allocator: E2ePortAllocator::new(0), + binary_path: None, + wasm_path: None, + } + } +} + +/// Orchestrates sandbox + blockchain + N mpc-node processes. +pub struct MpcCluster { + pub blockchain: NearBlockchain, + pub nodes: Vec, + _sandbox: SandboxNode, + _temp_dir: tempfile::TempDir, + _binary_path: PathBuf, +} + +impl MpcCluster { + /// Start a complete MPC cluster: + /// 1. Start sandbox validator + /// 2. Connect blockchain RPC client + /// 3. Deploy MPC contract + /// 4. Create node accounts with access keys + /// 5. Initialize contract with threshold parameters + /// 6. Submit mock TEE attestations + /// 7. Start all mpc-node processes + /// 8. Vote to add domains + /// 9. Wait for Running state (nodes complete key generation) + pub async fn start(config: ClusterConfig) -> anyhow::Result { + let _ = tracing_subscriber::fmt().with_env_filter("INFO").try_init(); + + let temp_dir = tempfile::TempDir::new().context("failed to create temp dir")?; + + // Resolve binary and WASM paths (handles git worktrees) + let binary_path = find_artifact( + config.binary_path, + "target/release/mpc-node", + "mpc-node binary", + "cargo build -p mpc-node --release --features test-utils", + )?; + let wasm_path = find_artifact( + config.wasm_path, + "target/wasm32-unknown-unknown/release-contract/mpc_contract.wasm", + "contract WASM", + "cargo near build non-reproducible-wasm --manifest-path crates/contract/Cargo.toml --locked", + )?; + + let wasm = std::fs::read(&wasm_path) + .with_context(|| format!("failed to read WASM: {}", wasm_path.display()))?; + + // 1. Start sandbox + tracing::info!("starting sandbox..."); + let sandbox = SandboxNode::start(&config.port_allocator).await?; + + // 2. Connect blockchain client + tracing::info!("connecting blockchain client..."); + let mut blockchain = NearBlockchain::from_sandbox(&sandbox).await?; + + // 3. Deploy contract + tracing::info!("deploying MPC contract..."); + blockchain.deploy_contract(&wasm).await?; + + let contract_id = blockchain.contract_id().clone(); + + // 4. Create node accounts and MPC nodes + tracing::info!(num_nodes = config.num_nodes, "creating node accounts..."); + let mut nodes = Vec::with_capacity(config.num_nodes); + let mut account_sign_pks = Vec::new(); + let mut p2p_urls = Vec::new(); + + for i in 0..config.num_nodes { + let p2p_signing_key = SigningKey::generate(&mut rand::thread_rng()); + let near_signer_key = SigningKey::generate(&mut rand::thread_rng()); + let account = blockchain.create_subaccount(&format!("signer_{i}")).await?; + + // Add the near_signer_key as a function-call access key on the account. + // The mpc-node will use this key from secrets.json to submit transactions. + let signer_pub = near_signer_key.verifying_key(); + let signer_pub_str = format!( + "ed25519:{}", + bs58::encode(signer_pub.as_bytes()).into_string() + ); + let near_pub_key: near_workspaces::types::PublicKey = signer_pub_str + .parse() + .context("failed to parse near signer public key")?; + + let result = account + .batch(account.id()) + .add_key( + near_pub_key, + AccessKey { + nonce: 0, + permission: AccessKeyPermission::FunctionCall(FunctionCallPermission { + allowance: None, + receiver_id: contract_id.to_string(), + method_names: vec![], + }), + }, + ) + .transact() + .await + .with_context(|| format!("failed to add access key for signer_{i}"))?; + + anyhow::ensure!( + result.is_success(), + "add access key failed for signer_{i}: {result:?}" + ); + + let node_home = temp_dir.path().join(format!("node_{i}")); + let ports = NodePorts::from_allocator(&config.port_allocator, i); + + let node = MpcNode::new( + MpcNodeConfig { + node_index: i, + home_dir: node_home, + signer_account_id: account.id().clone(), + p2p_signing_key, + near_signer_key, + ports, + mpc_contract_id: contract_id.clone(), + account, + triples_to_buffer: config.triples_to_buffer, + presignatures_to_buffer: config.presignatures_to_buffer, + }, + &sandbox, + )?; + + let sign_pk = node.p2p_public_key_str(); + let url = node.p2p_url(); + + account_sign_pks.push((node.signer_account_id.clone(), sign_pk)); + p2p_urls.push(url); + nodes.push(node); + } + + // 5. Build threshold parameters and init contract + tracing::info!(threshold = config.threshold, "initializing contract..."); + let params = + blockchain::make_threshold_parameters(&account_sign_pks, &p2p_urls, config.threshold); + blockchain.init_contract(¶ms).await?; + + // 6. Submit mock TEE attestations + tracing::info!("submitting TEE attestations..."); + for node in &nodes { + let sign_pk = node.p2p_public_key_str(); + blockchain + .submit_attestation(&node.account, &sign_pk) + .await?; + } + + // Wait for contract to be in Running state after init + blockchain + .wait_for_running(Duration::from_secs(30)) + .await + .context("contract did not reach Running state after init")?; + + // 7. Start all MPC node processes + tracing::info!("starting mpc-node processes..."); + for node in &mut nodes { + node.start(&binary_path)?; + } + + // Give nodes a moment to initialize, then check if config.json was created + tokio::time::sleep(Duration::from_secs(3)).await; + for node in &nodes { + let config_json = node.home_dir.join("config.json"); + if config_json.exists() { + tracing::info!(node = node.node_index, "config.json exists"); + } else { + tracing::error!(node = node.node_index, home = %node.home_dir.display(), "config.json does NOT exist!"); + // List what files are in the home dir + if let Ok(entries) = std::fs::read_dir(&node.home_dir) { + for entry in entries.flatten() { + tracing::error!(node = node.node_index, file = %entry.file_name().to_string_lossy(), " found file"); + } + } + } + } + + // 8. Vote to add domains + tracing::info!("voting to add domains..."); + let domains = blockchain::default_domains(0); + let voter_accounts: Vec<&near_workspaces::Account> = + nodes.iter().map(|n| &n.account).collect(); + blockchain + .vote_add_domains(&voter_accounts, &domains) + .await?; + + // 9. Wait for Running state (nodes complete key generation via P2P) + tracing::info!("waiting for key generation to complete..."); + blockchain + .wait_for_running(Duration::from_secs(120)) + .await + .context("contract did not reach Running state after key generation")?; + + tracing::info!("cluster is ready"); + + Ok(Self { + blockchain, + nodes, + _sandbox: sandbox, + _temp_dir: temp_dir, + _binary_path: binary_path, + }) + } + + /// Submit signature requests and wait for responses. + pub async fn send_and_await_signature_requests( + &self, + num_per_domain: usize, + ) -> anyhow::Result<()> { + self.blockchain + .send_and_await_signature_requests(num_per_domain, Duration::from_secs(300)) + .await + } + + /// Submit CKD requests and wait for responses. + pub async fn send_and_await_ckd_requests(&self, num_per_domain: usize) -> anyhow::Result<()> { + self.blockchain + .send_and_await_ckd_requests(num_per_domain, Duration::from_secs(300)) + .await + } + + /// Get the current contract state. + pub async fn get_contract_state( + &self, + ) -> anyhow::Result { + self.blockchain.get_state().await + } + + /// Kill all MPC node processes. + pub fn kill_all(&mut self) { + for node in &mut self.nodes { + node.kill(); + } + } +} + +impl Drop for MpcCluster { + fn drop(&mut self) { + self.kill_all(); + } +} + +/// Find the workspace root by walking up from the crate directory. +/// In a git worktree, also discovers the main repository root as a fallback +/// for finding build artifacts. +fn find_project_root() -> anyhow::Result { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + // crates/e2e-tests -> root is ../.. + let root = manifest_dir + .ancestors() + .nth(2) + .context("failed to find project root")? + .to_path_buf(); + Ok(root) +} + +/// Find a build artifact (binary or WASM) by checking: +/// 1. Explicit path from config +/// 2. Project root's target/ (works in normal repos) +/// 3. Main repo's target/ (fallback for git worktrees) +fn find_artifact( + explicit: Option, + relative_path: &str, + description: &str, + build_hint: &str, +) -> anyhow::Result { + // 1. Explicit path + if let Some(p) = explicit { + anyhow::ensure!(p.exists(), "{description} not found at {}", p.display()); + return Ok(p); + } + + let project_root = find_project_root()?; + + // 2. Project root target/ + let candidate = project_root.join(relative_path); + if candidate.exists() { + return Ok(candidate); + } + + // 3. Git worktree fallback: find the main repo via `.git` file + let git_path = project_root.join(".git"); + if git_path.is_file() { + // In a worktree, .git is a file containing "gitdir: " + if let Ok(content) = std::fs::read_to_string(&git_path) { + if let Some(gitdir) = content.strip_prefix("gitdir: ") { + let gitdir = PathBuf::from(gitdir.trim()); + // gitdir is usually /.git/worktrees/ + // so the main repo root is gitdir/../../.. + if let Some(main_root) = gitdir.ancestors().nth(3) { + let candidate = main_root.join(relative_path); + if candidate.exists() { + return Ok(candidate); + } + } + } + } + } + + anyhow::bail!( + "{description} not found. Searched:\n - {}\nBuild it with: {build_hint}", + project_root.join(relative_path).display(), + ) +} diff --git a/crates/e2e-tests/src/mpc_node.rs b/crates/e2e-tests/src/mpc_node.rs new file mode 100644 index 000000000..3739e665a --- /dev/null +++ b/crates/e2e-tests/src/mpc_node.rs @@ -0,0 +1,396 @@ +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; + +use anyhow::Context; +use ed25519_dalek::SigningKey; +use near_workspaces::{Account, AccountId}; +use serde::Serialize; +use serde_json::json; + +use crate::port_allocator::E2ePortAllocator; +use crate::sandbox::SandboxNode; + +const DUMMY_IMAGE_HASH: &str = + "sha256:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; + +/// Ports allocated for a single MPC node. +pub struct NodePorts { + pub p2p: u16, + pub web_ui: u16, + pub migration_web_ui: u16, + pub pprof: u16, + pub near_rpc: u16, + pub near_network: u16, +} + +impl NodePorts { + pub fn from_allocator(ports: &E2ePortAllocator, index: usize) -> Self { + Self { + p2p: ports.p2p_port(index), + web_ui: ports.web_ui_port(index), + migration_web_ui: ports.migration_web_ui_port(index), + pprof: ports.pprof_port(index), + near_rpc: ports.near_rpc_port(index), + near_network: ports.near_network_port(index), + } + } +} + +/// Arguments for constructing an [`MpcNode`]. +pub struct MpcNodeConfig { + pub node_index: usize, + pub home_dir: PathBuf, + pub signer_account_id: AccountId, + pub p2p_signing_key: SigningKey, + pub near_signer_key: SigningKey, + pub ports: NodePorts, + pub mpc_contract_id: AccountId, + pub account: Account, + pub triples_to_buffer: usize, + pub presignatures_to_buffer: usize, +} + +/// Manages a single `mpc-node` OS process. +/// +/// Generates the `start_config.toml` and spawns the binary. Each node runs its +/// own internal neard indexer that peers with the sandbox validator via P2P. +pub struct MpcNode { + pub node_index: usize, + pub home_dir: PathBuf, + pub signer_account_id: AccountId, + pub p2p_signing_key: SigningKey, + /// Key used by the node to sign NEAR transactions (must have access key on account). + pub near_signer_key: SigningKey, + pub ports: NodePorts, + + // Blockchain connection info (for TOML config) + pub mpc_contract_id: AccountId, + sandbox_genesis_path: PathBuf, + sandbox_boot_nodes: String, + + // Config values + secret_store_key_hex: String, + backup_encryption_key_hex: String, + triples_to_buffer: usize, + presignatures_to_buffer: usize, + + // near-workspaces account (for voting on contract) + pub account: Account, + + // Runtime + process: Option, +} + +impl MpcNode { + /// Create a new MPC node config (not yet running). + pub fn new(config: MpcNodeConfig, sandbox: &SandboxNode) -> anyhow::Result { + let sandbox_genesis_path = sandbox.genesis_path(); + let sandbox_boot_nodes = sandbox.boot_nodes()?; + + // Deterministic secret keys for each node + let secret_byte = b'A' + config.node_index as u8; + let secret_store_key_hex = hex::encode([secret_byte; 16]); + let backup_encryption_key_hex = hex::encode([secret_byte; 32]); + + std::fs::create_dir_all(&config.home_dir).with_context(|| { + format!( + "failed to create node home dir: {}", + config.home_dir.display() + ) + })?; + + Ok(Self { + node_index: config.node_index, + home_dir: config.home_dir, + signer_account_id: config.signer_account_id, + p2p_signing_key: config.p2p_signing_key, + near_signer_key: config.near_signer_key, + ports: config.ports, + mpc_contract_id: config.mpc_contract_id, + sandbox_genesis_path, + sandbox_boot_nodes, + secret_store_key_hex, + backup_encryption_key_hex, + triples_to_buffer: config.triples_to_buffer, + presignatures_to_buffer: config.presignatures_to_buffer, + account: config.account, + process: None, + }) + } + + /// The ed25519 public key formatted as `"ed25519:"`. + pub fn p2p_public_key_str(&self) -> String { + let verifying_key = self.p2p_signing_key.verifying_key(); + format!( + "ed25519:{}", + bs58::encode(verifying_key.as_bytes()).into_string() + ) + } + + /// The P2P URL for this node. + pub fn p2p_url(&self) -> String { + format!("http://127.0.0.1:{}", self.ports.p2p) + } + + /// Write the `start_config.toml` and spawn the mpc-node process. + pub fn start(&mut self, binary_path: &Path) -> anyhow::Result<()> { + anyhow::ensure!( + self.process.is_none(), + "node {} already running", + self.node_index + ); + + self.write_secrets_json()?; + let config_path = self.write_start_config()?; + + tracing::info!( + node = self.node_index, + account = %self.signer_account_id, + p2p_port = self.ports.p2p, + "starting mpc-node" + ); + + let stdout_file = std::fs::File::create(self.home_dir.join("stdout.log")) + .context("failed to create stdout log")?; + let stderr_file = std::fs::File::create(self.home_dir.join("stderr.log")) + .context("failed to create stderr log")?; + + let child = Command::new(binary_path) + .arg("start-with-config-file") + .arg(&config_path) + .env("RUST_LOG", "DEBUG") + .env("RUST_BACKTRACE", "1") + .stdout(Stdio::from(stdout_file)) + .stderr(Stdio::from(stderr_file)) + .spawn() + .with_context(|| { + format!( + "failed to spawn mpc-node {} (binary: {})", + self.node_index, + binary_path.display() + ) + })?; + + self.process = Some(child); + Ok(()) + } + + /// Stop the node. + pub fn kill(&mut self) { + if let Some(mut child) = self.process.take() { + let _ = child.kill(); + let _ = child.wait(); + } + } + + pub fn is_running(&self) -> bool { + self.process.is_some() + } + + /// Write `secrets.json` so the node uses our pre-generated keys instead of + /// generating random ones. The p2p key must match what was registered on + /// the contract, and the near signer key must have an access key on the account. + fn write_secrets_json(&self) -> anyhow::Result<()> { + let secrets_path = self.home_dir.join("secrets.json"); + + let format_key = |key: &SigningKey| -> String { + let keypair_bytes = key.to_keypair_bytes(); + format!("ed25519:{}", bs58::encode(keypair_bytes).into_string()) + }; + + let secrets = json!({ + "p2p_private_key": format_key(&self.p2p_signing_key), + "near_signer_key": format_key(&self.near_signer_key), + "near_responder_keys": [format_key(&self.near_signer_key)], + }); + + std::fs::write(&secrets_path, serde_json::to_vec_pretty(&secrets)?) + .with_context(|| format!("failed to write {}", secrets_path.display()))?; + + tracing::debug!(path = %secrets_path.display(), "wrote secrets.json"); + Ok(()) + } + + /// Build the TOML config and write it for `mpc-node start-with-config-file`. + fn write_start_config(&self) -> anyhow::Result { + let config_path = self.home_dir.join("start_config.toml"); + let signer = self.signer_account_id.to_string(); + + let config = StartConfigToml { + home_dir: self.home_dir.display().to_string(), + secrets: SecretsToml { + secret_store_key_hex: &self.secret_store_key_hex, + backup_encryption_key_hex: &self.backup_encryption_key_hex, + }, + tee: TeeToml { + image_hash: DUMMY_IMAGE_HASH, + latest_allowed_hash_file_path: "latest_allowed_hash.txt", + authority: TeeAuthorityToml { r#type: "local" }, + }, + log: LogToml { + format: "plain", + filter: "debug", + }, + near_init: NearInitToml { + chain_id: "mpc-localnet", + boot_nodes: &self.sandbox_boot_nodes, + genesis_path: self.sandbox_genesis_path.display().to_string(), + download_genesis: false, + rpc_addr: format!("0.0.0.0:{}", self.ports.near_rpc), + network_addr: format!("0.0.0.0:{}", self.ports.near_network), + }, + node: NodeToml { + my_near_account_id: &signer, + near_responder_account_id: &signer, + number_of_responder_keys: 1, + web_ui: format!("127.0.0.1:{}", self.ports.web_ui), + migration_web_ui: format!("127.0.0.1:{}", self.ports.migration_web_ui), + pprof_bind_address: format!("127.0.0.1:{}", self.ports.pprof), + cores: 4, + indexer: IndexerToml { + validate_genesis: true, + concurrency: 1, + mpc_contract_id: self.mpc_contract_id.as_str(), + finality: "optimistic", + sync_mode: BTreeMap::from([("Block", SyncModeBlockToml { height: 0 })]), + }, + triple: TripleToml { + concurrency: 2, + desired_triples_to_buffer: self.triples_to_buffer, + timeout_sec: 60, + parallel_triple_generation_stagger_time_sec: 1, + }, + presignature: PresignatureToml { + concurrency: 2, + desired_presignatures_to_buffer: self.presignatures_to_buffer, + timeout_sec: 60, + }, + signature: TimeoutToml { timeout_sec: 60 }, + ckd: TimeoutToml { timeout_sec: 60 }, + keygen: TimeoutToml { timeout_sec: 60 }, + }, + }; + + let toml_string = + toml::to_string_pretty(&config).context("failed to serialize start config")?; + std::fs::write(&config_path, &toml_string) + .with_context(|| format!("failed to write {}", config_path.display()))?; + + tracing::debug!(path = %config_path.display(), "wrote start_config.toml"); + Ok(config_path) + } +} + +impl Drop for MpcNode { + fn drop(&mut self) { + if let Some(mut child) = self.process.take() { + let _ = child.kill(); + let _ = child.wait(); + } + } +} + +// --------------------------------------------------------------------------- +// TODO: Factor `StartConfig` out of `mpc-node` into a lightweight crate so we +// can reuse it here instead of duplicating the structure. +// +// Serialization types for `start_config.toml`. +// These mirror the structure in `crates/node/src/config/start.rs` (StartConfig) +// without pulling in the full mpc-node dependency. +// --------------------------------------------------------------------------- + +#[derive(Serialize)] +struct StartConfigToml<'a> { + home_dir: String, + secrets: SecretsToml<'a>, + tee: TeeToml<'a>, + log: LogToml<'a>, + near_init: NearInitToml<'a>, + node: NodeToml<'a>, +} + +#[derive(Serialize)] +struct SecretsToml<'a> { + secret_store_key_hex: &'a str, + backup_encryption_key_hex: &'a str, +} + +#[derive(Serialize)] +struct TeeToml<'a> { + image_hash: &'a str, + latest_allowed_hash_file_path: &'a str, + authority: TeeAuthorityToml<'a>, +} + +#[derive(Serialize)] +struct LogToml<'a> { + format: &'a str, + filter: &'a str, +} + +#[derive(Serialize)] +struct TeeAuthorityToml<'a> { + r#type: &'a str, +} + +#[derive(Serialize)] +struct NearInitToml<'a> { + chain_id: &'a str, + boot_nodes: &'a str, + genesis_path: String, + download_genesis: bool, + rpc_addr: String, + network_addr: String, +} + +#[derive(Serialize)] +struct NodeToml<'a> { + my_near_account_id: &'a str, + near_responder_account_id: &'a str, + number_of_responder_keys: usize, + web_ui: String, + migration_web_ui: String, + pprof_bind_address: String, + cores: usize, + indexer: IndexerToml<'a>, + triple: TripleToml, + presignature: PresignatureToml, + signature: TimeoutToml, + ckd: TimeoutToml, + keygen: TimeoutToml, +} + +#[derive(Serialize)] +struct IndexerToml<'a> { + validate_genesis: bool, + concurrency: usize, + mpc_contract_id: &'a str, + finality: &'a str, + sync_mode: BTreeMap<&'a str, SyncModeBlockToml>, +} + +#[derive(Serialize)] +struct SyncModeBlockToml { + height: u64, +} + +#[derive(Serialize)] +struct TripleToml { + concurrency: usize, + desired_triples_to_buffer: usize, + timeout_sec: u64, + parallel_triple_generation_stagger_time_sec: u64, +} + +#[derive(Serialize)] +struct PresignatureToml { + concurrency: usize, + desired_presignatures_to_buffer: usize, + timeout_sec: u64, +} + +#[derive(Serialize)] +struct TimeoutToml { + timeout_sec: u64, +} diff --git a/crates/e2e-tests/src/port_allocator.rs b/crates/e2e-tests/src/port_allocator.rs new file mode 100644 index 000000000..a19c79f11 --- /dev/null +++ b/crates/e2e-tests/src/port_allocator.rs @@ -0,0 +1,93 @@ +/// Deterministic port allocator for E2E tests. +/// +/// Each test gets a unique `test_id`. All ports are computed from it to avoid +/// collisions when tests run in parallel via `cargo nextest`. +/// +/// Layout per test: +/// - 2 cluster-level ports (sandbox RPC, sandbox network) +/// - 8 ports per node * MAX_NODES +#[derive(Debug, Clone)] +pub struct E2ePortAllocator { + test_id: u16, +} + +impl E2ePortAllocator { + const BASE_PORT: u16 = 20000; + const PORTS_PER_NODE: u16 = 8; + const MAX_NODES: u16 = 10; + /// Cluster-level ports that are not per-node. + const CLUSTER_PORTS: u16 = 2; + /// Total ports reserved per test. + const PORTS_PER_TEST: u16 = Self::CLUSTER_PORTS + Self::MAX_NODES * Self::PORTS_PER_NODE; + + pub const fn new(test_id: u16) -> Self { + Self { test_id } + } + + fn base(&self) -> u16 { + Self::BASE_PORT + self.test_id * Self::PORTS_PER_TEST + } + + // -- Cluster-level ports -- + + pub fn sandbox_rpc_port(&self) -> u16 { + self.base() + } + + pub fn sandbox_network_port(&self) -> u16 { + self.base() + 1 + } + + // -- Per-node ports -- + + fn node_base(&self, node_index: usize) -> u16 { + self.base() + Self::CLUSTER_PORTS + (node_index as u16) * Self::PORTS_PER_NODE + } + + pub fn p2p_port(&self, node_index: usize) -> u16 { + self.node_base(node_index) + } + + pub fn web_ui_port(&self, node_index: usize) -> u16 { + self.node_base(node_index) + 1 + } + + pub fn migration_web_ui_port(&self, node_index: usize) -> u16 { + self.node_base(node_index) + 2 + } + + pub fn pprof_port(&self, node_index: usize) -> u16 { + self.node_base(node_index) + 3 + } + + pub fn near_rpc_port(&self, node_index: usize) -> u16 { + self.node_base(node_index) + 4 + } + + pub fn near_network_port(&self, node_index: usize) -> u16 { + self.node_base(node_index) + 5 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn no_port_overlap_between_tests() { + let a = E2ePortAllocator::new(0); + let b = E2ePortAllocator::new(1); + // Last port of test 0 must be less than first port of test 1 + let a_last = a.near_network_port(E2ePortAllocator::MAX_NODES as usize - 1); + let b_first = b.sandbox_rpc_port(); + assert!(a_last < b_first, "{a_last} >= {b_first}"); + } + + #[test] + fn no_port_overlap_between_nodes() { + let a = E2ePortAllocator::new(0); + let last_of_node0 = a.near_network_port(0); + let first_of_node1 = a.p2p_port(1); + assert!(last_of_node0 < first_of_node1); + } +} diff --git a/crates/e2e-tests/src/sandbox.rs b/crates/e2e-tests/src/sandbox.rs new file mode 100644 index 000000000..1c842742a --- /dev/null +++ b/crates/e2e-tests/src/sandbox.rs @@ -0,0 +1,80 @@ +use std::path::{Path, PathBuf}; + +use anyhow::Context; + +use crate::port_allocator::E2ePortAllocator; + +/// Wraps a `near-sandbox` neard process with controlled ports. +/// +/// The sandbox validator is the single NEAR node that all mpc-node indexers +/// connect to via P2P boot_nodes. +pub struct SandboxNode { + sandbox: near_sandbox::Sandbox, + rpc_port: u16, + net_port: u16, +} + +impl SandboxNode { + /// Start a sandbox validator with ports from the allocator. + pub async fn start(ports: &E2ePortAllocator) -> anyhow::Result { + let rpc_port = ports.sandbox_rpc_port(); + let net_port = ports.sandbox_network_port(); + + tracing::info!(rpc_port, net_port, "starting near-sandbox"); + + let config = near_sandbox::SandboxConfig { + rpc_port: Some(rpc_port), + net_port: Some(net_port), + ..Default::default() + }; + + let sandbox = near_sandbox::Sandbox::start_sandbox_with_config(config) + .await + .context("failed to start near-sandbox")?; + + tracing::info!(rpc_addr = %sandbox.rpc_addr, "near-sandbox started"); + + Ok(Self { + sandbox, + rpc_port, + net_port, + }) + } + + pub fn rpc_url(&self) -> String { + format!("http://127.0.0.1:{}", self.rpc_port) + } + + pub fn rpc_port(&self) -> u16 { + self.rpc_port + } + + pub fn net_port(&self) -> u16 { + self.net_port + } + + /// Path to the sandbox home directory (contains genesis.json, node_key.json, etc.). + pub fn home_dir(&self) -> &Path { + self.sandbox.home_dir.path() + } + + /// Path to genesis.json inside the sandbox home. + pub fn genesis_path(&self) -> PathBuf { + self.home_dir().join("genesis.json") + } + + /// Constructs the boot_nodes string for mpc-node NearInitConfig. + /// + /// Format: `"ed25519:@127.0.0.1:"` + pub fn boot_nodes(&self) -> anyhow::Result { + let node_key_path = self.home_dir().join("node_key.json"); + let content = std::fs::read_to_string(&node_key_path) + .with_context(|| format!("failed to read {}", node_key_path.display()))?; + let parsed: serde_json::Value = + serde_json::from_str(&content).context("failed to parse node_key.json")?; + let public_key = parsed["public_key"] + .as_str() + .context("missing public_key in node_key.json")?; + Ok(format!("{public_key}@127.0.0.1:{}", self.net_port)) + } +} diff --git a/crates/e2e-tests/tests/request_lifecycle.rs b/crates/e2e-tests/tests/request_lifecycle.rs new file mode 100644 index 000000000..92c2b53cf --- /dev/null +++ b/crates/e2e-tests/tests/request_lifecycle.rs @@ -0,0 +1,25 @@ +use e2e_tests::{ClusterConfig, E2ePortAllocator, MpcCluster}; + +/// Port of `pytest/tests/shared_cluster_tests/test_requests.py::test_request_lifecycle`. +/// +/// Starts a 2-node MPC cluster, submits 10 signature requests per Sign domain +/// (Secp256k1, Ed25519) and 10 CKD requests per CKD domain (Bls12381), and +/// verifies that all succeed. +#[tokio::test(flavor = "multi_thread")] +async fn test_request_lifecycle() -> anyhow::Result<()> { + let config = ClusterConfig { + num_nodes: 2, + threshold: 2, + triples_to_buffer: 200, + presignatures_to_buffer: 100, + port_allocator: E2ePortAllocator::new(1), + ..ClusterConfig::default() + }; + + let cluster = MpcCluster::start(config).await?; + + cluster.send_and_await_signature_requests(10).await?; + cluster.send_and_await_ckd_requests(10).await?; + + Ok(()) +}