diff --git a/.config/nextest.toml b/.config/nextest.toml index b175611f1..d63f185cf 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -1,2 +1,2 @@ [profile.ci-other] -default-filter = 'not package(mpc-node) and not package(mpc-contract) and not package(contract-history) and not test(=tee_authority::tests::test_upload_quote_for_collateral_with_phala_endpoint)' +default-filter = 'not package(mpc-node) and not package(mpc-contract) and not package(e2e-tests) and not package(contract-history) and not test(=tee_authority::tests::test_upload_quote_for_collateral_with_phala_endpoint)' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81e63758f..25f0e453e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -545,6 +545,74 @@ jobs: - name: Run cargo-make fast checks run: cargo make check-all-fast + mpc-e2e-tests: + name: "MPC E2E tests" + needs: mpc-pytest-build + runs-on: warp-ubuntu-2404-x64-16x + timeout-minutes: 60 + permissions: + contents: read + env: + MPC_PYTEST_BINARIES_CACHE_KEY: mpc-pytest-binaries-${{ github.run_id }} + + steps: + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + persist-credentials: false + + - name: Restore pytest binaries cache + uses: WarpBuilds/cache/restore@8e2c4dd9bdfe2460f9f0d558ce34d030589e1556 # v1 + with: + key: ${{ env.MPC_PYTEST_BINARIES_CACHE_KEY }} + fail-on-cache-miss: true + path: | + target/release/mpc-node + target/release/backup-cli + target/wasm32-unknown-unknown/release-contract/mpc_contract.wasm + target/wasm32-unknown-unknown/release-contract/test_parallel_contract.wasm + libs/nearcore/target/release/neard + + - name: Install build dependencies + run: | + sudo apt-get update + sudo apt-get install -y liblzma-dev libudev-dev + + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1 + with: + save-if: ${{ github.ref == 'refs/heads/main' }} + cache-provider: "warpbuild" + prefix-key: v0-rust-e2e + + - name: Install cargo-binstall + uses: taiki-e/install-action@d4422f254e595ee762a758628fe4f16ce050fa2e # v2.67.28 + with: + tool: cargo-binstall + + - name: Install cargo-near + run: | + cargo binstall --force --no-confirm --locked cargo-near@0.19.1 --pkg-url="{ repo }/releases/download/{ name }-v{ version }/{ name }-{ target }.{ archive-format }" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - 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 contract with cargo-near + run: | + cargo near build non-reproducible-wasm \ + --manifest-path crates/contract/Cargo.toml \ + --out-dir target/near/mpc_contract + + - name: Run E2E tests + run: | + RUST_LOG=info,e2e_tests=debug cargo test -p e2e-tests --all-features --locked 2>&1 + ci-extra: name: "Extra CI checks" runs-on: warp-ubuntu-2404-x64-2x diff --git a/Cargo.lock b/Cargo.lock index eeac329a0..39e352acf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1489,6 +1489,50 @@ dependencies = [ "subtle", ] +[[package]] +name = "bollard" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97ccca1260af6a459d75994ad5acc1651bcabcbdbc41467cc9786519ab854c30" +dependencies = [ + "base64 0.22.1", + "bollard-stubs", + "bytes", + "futures-core", + "futures-util", + "hex", + "http 1.4.0", + "http-body-util", + "hyper 1.8.1", + "hyper-named-pipe", + "hyper-util", + "hyperlocal", + "log", + "pin-project-lite", + "serde", + "serde_derive", + "serde_json", + "serde_repr", + "serde_urlencoded", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tower-service", + "url", + "winapi", +] + +[[package]] +name = "bollard-stubs" +version = "1.47.1-rc.27.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f179cfbddb6e77a5472703d4b30436bff32929c0aa8a9008ecf23d1d3cdd0da" +dependencies = [ + "serde", + "serde_repr", + "serde_with", +] + [[package]] name = "bon" version = "3.8.2" @@ -3061,6 +3105,7 @@ name = "e2e-tests" version = "3.7.0" dependencies = [ "anyhow", + "bollard", "bs58 0.5.1", "ed25519-dalek", "futures", @@ -3072,10 +3117,12 @@ dependencies = [ "reqwest 0.13.2", "serde", "serde_json", + "tar", "tempfile", "tokio", "toml 1.0.6+spec-1.1.0", "tracing", + "tracing-subscriber", ] [[package]] @@ -4438,6 +4485,21 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-named-pipe" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73b7d8abf35697b81a825e386fc151e0d503e8cb5fcb93cc8669c376dfd6f278" +dependencies = [ + "hex", + "hyper 1.8.1", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", + "winapi", +] + [[package]] name = "hyper-rustls" version = "0.27.7" @@ -4524,6 +4586,21 @@ dependencies = [ "windows-registry", ] +[[package]] +name = "hyperlocal" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" +dependencies = [ + "hex", + "http-body-util", + "hyper 1.8.1", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "iana-time-zone" version = "0.1.65" diff --git a/Cargo.toml b/Cargo.toml index 923df1537..39b9e25f2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -78,6 +78,7 @@ average = "0.16" axum = "0.8.8" backon = { version = "1.6.0", features = ["tokio-sleep"] } base64 = "0.22.1" +bollard = "0.18" blstrs = "0.7.1" borsh = { version = "1.6.0", features = ["derive"] } bs58 = { version = "0.5.1" } @@ -190,6 +191,7 @@ signature = "2.2.0" socket2 = "0.6.3" subtle = "2.6.1" syn = "2.0" +tar = "0.4" tempfile = "3.27.0" test-log = "0.2.19" thiserror = "2.0.18" diff --git a/crates/e2e-tests/Cargo.toml b/crates/e2e-tests/Cargo.toml index 4eb12d897..d8674bd14 100644 --- a/crates/e2e-tests/Cargo.toml +++ b/crates/e2e-tests/Cargo.toml @@ -6,6 +6,7 @@ license = { workspace = true } [dependencies] anyhow = { workspace = true } +bollard = { workspace = true } bs58 = { workspace = true } ed25519-dalek = { workspace = true } futures = { workspace = true } @@ -17,10 +18,14 @@ rand = { workspace = true } reqwest = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +tar = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true } toml = { workspace = true } tracing = { workspace = true } +[dev-dependencies] +tracing-subscriber = { workspace = true } + [lints] workspace = true diff --git a/crates/e2e-tests/src/blockchain.rs b/crates/e2e-tests/src/blockchain.rs index 4ce4ead49..41c8aca32 100644 --- a/crates/e2e-tests/src/blockchain.rs +++ b/crates/e2e-tests/src/blockchain.rs @@ -1,53 +1,97 @@ +use anyhow::Context; use ed25519_dalek::SigningKey; +use near_kit::FinalExecutionOutcome; use near_mpc_contract_interface::types::ProtocolContractState; use serde::de::DeserializeOwned; +/// RPC client for any NEAR network (sandbox or testnet). +/// +/// Wraps a `near_kit::Near` client signed as the root/funder account. +/// Whether the RPC URL points to a local Docker sandbox or NEAR testnet, +/// the code path is identical. pub struct NearBlockchain { - _rpc_url: String, + root_client: near_kit::Near, + rpc_url: String, } pub struct ClientHandle { - _private: (), + inner: near_kit::Near, } impl NearBlockchain { - pub fn new( - _rpc_url: &str, - _root_account: &str, - _root_secret_key: &str, - ) -> anyhow::Result { - unimplemented!("NEAR RPC client implementation — see Change 2") + pub fn new(rpc_url: &str, root_account: &str, root_secret_key: &str) -> anyhow::Result { + let sk: near_kit::SecretKey = root_secret_key.parse().context("invalid root secret key")?; + let signer = near_kit::InMemorySigner::from_secret_key(root_account, sk) + .map_err(|e| anyhow::anyhow!("failed to create root signer: {e}"))?; + let client = near_kit::Near::custom(rpc_url).signer(signer).build(); + Ok(Self { + root_client: client, + rpc_url: rpc_url.to_string(), + }) } pub async fn create_account( &self, - _name: &str, - _balance_near: u64, - _key: &SigningKey, + name: &str, + balance_near: u64, + key: &SigningKey, ) -> anyhow::Result<()> { - unimplemented!() + self.root_client + .transaction(name) + .create_account() + .transfer(near_kit::NearToken::from_near(balance_near as u128)) + .add_full_access_key(near_kit::PublicKey::Ed25519(key.verifying_key().to_bytes())) + .send() + .await + .map_err(|e| anyhow::anyhow!("failed to create account {name}: {e}"))?; + Ok(()) } pub async fn create_account_and_deploy( &self, - _name: &str, - _balance_near: u64, - _key: &SigningKey, - _wasm: &[u8], + name: &str, + balance_near: u64, + key: &SigningKey, + wasm: &[u8], ) -> anyhow::Result { - unimplemented!() + self.root_client + .transaction(name) + .create_account() + .transfer(near_kit::NearToken::from_near(balance_near as u128)) + .add_full_access_key(near_kit::PublicKey::Ed25519(key.verifying_key().to_bytes())) + .deploy(wasm.to_vec()) + .send() + .await + .map_err(|e| anyhow::anyhow!("failed to create account and deploy to {name}: {e}"))?; + + let client = self.make_client(name, key)?; + Ok(DeployedContract { + client, + contract_id: name.to_string(), + }) } - pub fn client_for(&self, _account_id: &str, _key: &SigningKey) -> anyhow::Result { - unimplemented!() + pub fn client_for(&self, account_id: &str, key: &SigningKey) -> anyhow::Result { + Ok(ClientHandle { + inner: self.make_client(account_id, key)?, + }) } pub fn rpc_url(&self) -> &str { - unimplemented!() + &self.rpc_url + } + + fn make_client(&self, account_id: &str, key: &SigningKey) -> anyhow::Result { + let sk = near_kit::SecretKey::Ed25519(key.to_bytes()); + let signer = near_kit::InMemorySigner::from_secret_key(account_id, sk) + .map_err(|e| anyhow::anyhow!("failed to create signer for {account_id}: {e}"))?; + Ok(self.root_client.with_signer(signer)) } } +/// Handle to a deployed MPC signer contract. pub struct DeployedContract { + client: near_kit::Near, contract_id: String, } @@ -56,24 +100,63 @@ impl DeployedContract { &self.contract_id } - pub async fn call(&self, _method: &str, _args: serde_json::Value) -> anyhow::Result<()> { - unimplemented!() + pub async fn call(&self, method: &str, args: serde_json::Value) -> anyhow::Result<()> { + self.client + .call(&self.contract_id, method) + .args(args) + .gas(near_kit::Gas::from_tgas(300)) + .send() + .await + .map_err(|e| anyhow::anyhow!("contract call `{method}` failed: {e}"))?; + Ok(()) } pub async fn call_from( &self, - _client: &ClientHandle, - _method: &str, - _args: serde_json::Value, + client: &ClientHandle, + method: &str, + args: serde_json::Value, ) -> anyhow::Result<()> { - unimplemented!() + client + .inner + .call(&self.contract_id, method) + .args(args) + .gas(near_kit::Gas::from_tgas(300)) + .send() + .await + .map_err(|e| { + anyhow::anyhow!("contract call `{method}` (external signer) failed: {e}") + })?; + Ok(()) + } + + pub async fn call_from_with_deposit( + &self, + client: &ClientHandle, + method: &str, + args: serde_json::Value, + gas: near_kit::Gas, + deposit: near_kit::NearToken, + ) -> anyhow::Result { + client + .inner + .call(&self.contract_id, method) + .args(args) + .gas(gas) + .deposit(deposit) + .send() + .await + .map_err(|e| anyhow::anyhow!("contract call `{method}` (with deposit) failed: {e}")) } pub async fn view( &self, - _method: &str, + method: &str, ) -> anyhow::Result { - unimplemented!() + self.client + .view::(&self.contract_id, method) + .await + .map_err(|e| anyhow::anyhow!("contract view `{method}` failed: {e}")) } pub async fn state(&self) -> anyhow::Result { diff --git a/crates/e2e-tests/src/cluster.rs b/crates/e2e-tests/src/cluster.rs index 3987d6888..236a4c75b 100644 --- a/crates/e2e-tests/src/cluster.rs +++ b/crates/e2e-tests/src/cluster.rs @@ -151,10 +151,7 @@ impl MpcCluster { ) .await?; - if !config.domains.is_empty() { - add_initial_domains(&blockchain, &contract, &node_near_keys, &config.domains).await?; - } - + // Start MPC nodes BEFORE adding domains: key generation requires running nodes. let nodes = start_mpc_nodes( &config, &sandbox, @@ -165,6 +162,10 @@ impl MpcCluster { &ports, )?; + if !config.domains.is_empty() { + add_initial_domains(&blockchain, &contract, &node_near_keys, &config.domains).await?; + } + let user_accounts = create_user_accounts(&blockchain, 1).await?; tracing::info!("MPC cluster is ready"); @@ -309,7 +310,7 @@ impl MpcCluster { let deadline = tokio::time::Instant::now() + timeout; loop { let values = self.get_metric_all_nodes(name).await?; - if values.iter().all(|v| *v == Some(expected)) { + if values.iter().all(|v| v.unwrap_or(0) >= expected) { return Ok(()); } if tokio::time::Instant::now() >= deadline { @@ -392,6 +393,32 @@ impl MpcCluster { .next() .expect("cluster should have at least one user account") } + + /// Send a sign request from the default user account and return the outcome. + pub async fn send_sign_request( + &self, + domain_id: DomainId, + payload: serde_json::Value, + ) -> anyhow::Result { + let user = self.default_user_account().clone(); + let client = self.user_client(&user)?; + let args = json!({ + "request": { + "domain_id": domain_id, + "path": "test", + "payload_v2": payload, + } + }); + self.contract + .call_from_with_deposit( + &client, + method_names::SIGN, + args, + near_kit::Gas::from_tgas(15), + near_kit::NearToken::from_yoctonear(1), + ) + .await + } } impl Drop for MpcCluster { @@ -622,7 +649,7 @@ fn build_participants( let mut list = Vec::new(); for (i, key) in p2p_keys.iter().enumerate().take(num_nodes) { let account_id = ContractAccountId(format!("node{i}.{SANDBOX_ROOT_ACCOUNT}")); - let pubkey = near_mpc_crypto_types::Ed25519PublicKey::from(&key.verifying_key()); + let pubkey = near_mpc_crypto_types::Ed25519PublicKey::from(key.verifying_key().to_bytes()); list.push(( account_id, ParticipantId(i as u32), diff --git a/crates/e2e-tests/src/lib.rs b/crates/e2e-tests/src/lib.rs index 5fbb2b49f..e0b0b51f3 100644 --- a/crates/e2e-tests/src/lib.rs +++ b/crates/e2e-tests/src/lib.rs @@ -4,7 +4,7 @@ pub mod mpc_node; pub mod near_sandbox; pub mod port_allocator; -pub use blockchain::{DeployedContract, NearBlockchain}; +pub use blockchain::{ClientHandle, DeployedContract, NearBlockchain}; pub use cluster::{MpcCluster, MpcClusterConfig, MpcNodeState}; pub use near_sandbox::NearSandbox; pub use port_allocator::E2ePortAllocator; diff --git a/crates/e2e-tests/src/mpc_node.rs b/crates/e2e-tests/src/mpc_node.rs index 041943395..09da28023 100644 --- a/crates/e2e-tests/src/mpc_node.rs +++ b/crates/e2e-tests/src/mpc_node.rs @@ -170,7 +170,7 @@ impl MpcNodeSetup { /// The ed25519 public key formatted as `"ed25519:"`. pub fn p2p_public_key_str(&self) -> String { String::from(&Ed25519PublicKey::from( - &self.p2p_signing_key.verifying_key(), + self.p2p_signing_key.verifying_key().to_bytes(), )) } diff --git a/crates/e2e-tests/src/near_sandbox.rs b/crates/e2e-tests/src/near_sandbox.rs index 8fc1960b8..f9c8f96ad 100644 --- a/crates/e2e-tests/src/near_sandbox.rs +++ b/crates/e2e-tests/src/near_sandbox.rs @@ -1,34 +1,234 @@ +use std::collections::HashMap; use std::path::{Path, PathBuf}; +use anyhow::{Context, bail}; +use bollard::Docker; +use bollard::container::{Config, CreateContainerOptions, RemoveContainerOptions}; +use bollard::models::{HostConfig, PortBinding}; + use crate::port_allocator::E2ePortAllocator; +const CONTAINER_RPC_PORT: &str = "3030/tcp"; +const CONTAINER_NET_PORT: &str = "3031/tcp"; + +/// Wraps a NEAR sandbox node for E2E tests. +/// +/// Starts a Docker container running `nearprotocol/sandbox`, exposes RPC and +/// network ports, and extracts genesis.json and node_key.json so MPC node +/// indexers can sync blocks via P2P. +/// +/// The container is stopped and cleaned up when this value is dropped. pub struct NearSandbox { - _rpc_port: u16, - _network_port: u16, + docker: Docker, + container_id: String, + rpc_port: u16, + network_port: u16, + sandbox_dir: PathBuf, } impl NearSandbox { pub async fn start( - _ports: &E2ePortAllocator, - _image: &str, - _test_dir: &Path, + ports: &E2ePortAllocator, + image: &str, + test_dir: &Path, ) -> anyhow::Result { - unimplemented!("Docker sandbox implementation — see Change 2") + let rpc_port = ports.near_node_rpc_port(); + let network_port = ports.near_node_network_port(); + + tracing::info!( + rpc_port, + network_port, + image, + "starting NEAR sandbox Docker container" + ); + + let docker = + Docker::connect_with_local_defaults().context("failed to connect to Docker")?; + + let port_bindings = HashMap::from([ + ( + CONTAINER_RPC_PORT.to_string(), + Some(vec![PortBinding { + host_port: Some(rpc_port.to_string()), + ..Default::default() + }]), + ), + ( + CONTAINER_NET_PORT.to_string(), + Some(vec![PortBinding { + host_port: Some(network_port.to_string()), + ..Default::default() + }]), + ), + ]); + + let container = docker + .create_container( + Some(CreateContainerOptions::<&str> { + ..Default::default() + }), + Config { + image: Some(image), + host_config: Some(HostConfig { + port_bindings: Some(port_bindings), + auto_remove: Some(true), + ..Default::default() + }), + ..Default::default() + }, + ) + .await + .context("failed to create sandbox container")?; + + let container_id = container.id; + docker + .start_container::<&str>(&container_id, None) + .await + .context("failed to start sandbox container")?; + + tracing::info!(container_id = %container_id, "sandbox container started"); + + if let Err(e) = wait_for_rpc(rpc_port).await { + let _ = docker + .remove_container( + &container_id, + Some(RemoveContainerOptions { + force: true, + ..Default::default() + }), + ) + .await; + return Err(e.context("sandbox node failed to become ready")); + } + + let sandbox_dir = test_dir.join("sandbox"); + std::fs::create_dir_all(&sandbox_dir) + .with_context(|| format!("failed to create {}", sandbox_dir.display()))?; + + copy_from_container(&docker, &container_id, "/data/genesis.json", &sandbox_dir).await?; + copy_from_container(&docker, &container_id, "/data/node_key.json", &sandbox_dir).await?; + + tracing::info!( + rpc_url = %format!("http://127.0.0.1:{rpc_port}"), + genesis = %sandbox_dir.join("genesis.json").display(), + "NEAR sandbox ready" + ); + + Ok(Self { + docker, + container_id, + rpc_port, + network_port, + sandbox_dir, + }) } pub fn rpc_url(&self) -> String { - unimplemented!() + format!("http://127.0.0.1:{}", self.rpc_port) } pub fn genesis_path(&self) -> PathBuf { - unimplemented!() + self.sandbox_dir.join("genesis.json") } pub fn boot_nodes(&self) -> anyhow::Result { - unimplemented!() + let node_key_path = self.sandbox_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.network_port)) } pub fn chain_id(&self) -> anyhow::Result { - unimplemented!() + let genesis = self.genesis_path(); + let content = std::fs::read_to_string(&genesis) + .with_context(|| format!("failed to read {}", genesis.display()))?; + let parsed: serde_json::Value = + serde_json::from_str(&content).context("failed to parse genesis.json")?; + parsed["chain_id"] + .as_str() + .map(|s| s.to_string()) + .context("missing chain_id in genesis.json") + } +} + +impl Drop for NearSandbox { + fn drop(&mut self) { + tracing::info!(container_id = %self.container_id, "stopping NEAR sandbox container"); + // Best-effort synchronous removal via a blocking runtime. + let docker = self.docker.clone(); + let id = self.container_id.clone(); + std::thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(async { + let _ = docker + .remove_container( + &id, + Some(RemoveContainerOptions { + force: true, + ..Default::default() + }), + ) + .await; + }); + }) + .join() + .ok(); + } +} + +/// Copy a single file from a container to a host directory. +async fn copy_from_container( + docker: &Docker, + container_id: &str, + container_path: &str, + host_dir: &Path, +) -> anyhow::Result<()> { + use bollard::container::DownloadFromContainerOptions; + use futures::TryStreamExt; + + let chunks: Vec<_> = docker + .download_from_container( + container_id, + Some(DownloadFromContainerOptions { + path: container_path, + }), + ) + .try_collect() + .await + .with_context(|| format!("failed to download {container_path}"))?; + let tar_bytes: Vec = chunks.into_iter().flatten().collect(); + + let mut archive = tar::Archive::new(&tar_bytes[..]); + archive.unpack(host_dir).with_context(|| { + format!( + "failed to unpack {container_path} into {}", + host_dir.display() + ) + })?; + Ok(()) +} + +async fn wait_for_rpc(rpc_port: u16) -> anyhow::Result<()> { + let url = format!("http://127.0.0.1:{rpc_port}/status"); + let client = reqwest::Client::new(); + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(30); + loop { + match client.get(&url).send().await { + Ok(resp) if resp.status().is_success() => return Ok(()), + _ => { + if tokio::time::Instant::now() >= deadline { + bail!("sandbox RPC on port {rpc_port} did not become ready within 30s"); + } + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + } + } } } diff --git a/crates/e2e-tests/tests/request_lifecycle.rs b/crates/e2e-tests/tests/request_lifecycle.rs new file mode 100644 index 000000000..da3a5360a --- /dev/null +++ b/crates/e2e-tests/tests/request_lifecycle.rs @@ -0,0 +1,127 @@ +use std::path::Path; +use std::time::Duration; + +use e2e_tests::{MpcCluster, MpcClusterConfig}; +use near_mpc_contract_interface::types::{ + DomainPurpose, ProtocolContractState, SignatureResponse, SignatureScheme, +}; +use serde_json::json; + +/// Load the pre-built MPC contract WASM from the known cargo build path. +/// +/// Panics if the WASM file doesn't exist. Build it first with: +/// cargo build -p mpc-contract --target=wasm32-unknown-unknown --profile=release-contract --locked +fn load_contract_wasm() -> Vec { + let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + // Prefer the cargo-near output (optimized + ABI-stripped). + let near_path = manifest_dir.join("../../target/near/mpc_contract/mpc_contract.wasm"); + // Fallback to raw cargo build output. + let raw_path = + manifest_dir.join("../../target/wasm32-unknown-unknown/release-contract/mpc_contract.wasm"); + + let path = if near_path.exists() { + &near_path + } else { + &raw_path + }; + std::fs::read(path).unwrap_or_else(|e| { + panic!( + "Failed to read contract WASM at {}: {e}\n\ + Build it first with one of:\n \ + cargo near build non-reproducible-wasm --manifest-path crates/contract/Cargo.toml\n \ + cargo build -p mpc-contract --target=wasm32-unknown-unknown --profile=release-contract", + path.display() + ) + }) +} + +fn generate_ecdsa_payload() -> serde_json::Value { + let bytes: [u8; 32] = rand::random(); + json!({ "Ecdsa": hex::encode(bytes) }) +} + +fn generate_eddsa_payload() -> serde_json::Value { + let bytes: [u8; 32] = rand::random(); + json!({ "Eddsa": hex::encode(bytes) }) +} + +#[tokio::test] +async fn test_request_lifecycle() { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,e2e_tests=debug".parse().unwrap()), + ) + .try_init() + .ok(); + + let contract_wasm = load_contract_wasm(); + let config = MpcClusterConfig::default_for_test(1, contract_wasm); + let cluster = MpcCluster::start(config) + .await + .expect("failed to start cluster"); + + // Verify contract is in Running state with expected domains. + let state = cluster + .get_contract_state() + .await + .expect("failed to get contract state"); + let running = match &state { + ProtocolContractState::Running(r) => r, + other => panic!("expected Running state, got: {other:?}"), + }; + + let sign_domains: Vec<_> = running + .domains + .domains + .iter() + .filter(|d| d.purpose == Some(DomainPurpose::Sign)) + .collect(); + assert!(!sign_domains.is_empty(), "no Sign domains found"); + + // Wait for nodes to generate presignatures before sending requests. + cluster + .wait_for_metric_all_nodes( + "mpc_owned_num_presignatures_available", + 1, + Duration::from_secs(120), + ) + .await + .expect("nodes did not generate presignatures in time"); + + // Send a sign request for each Sign domain. + for domain in &sign_domains { + let payload = match domain.scheme { + SignatureScheme::Secp256k1 => generate_ecdsa_payload(), + SignatureScheme::Ed25519 => generate_eddsa_payload(), + _ => continue, + }; + + tracing::info!(domain_id = ?domain.id, scheme = ?domain.scheme, "sending sign request"); + let outcome = cluster + .send_sign_request(domain.id, payload) + .await + .expect("sign request transaction failed"); + + assert!( + outcome.is_success(), + "sign request for domain {:?} failed: {:?}", + domain.id, + outcome.failure_message() + ); + + let signature: SignatureResponse = outcome + .json() + .expect("failed to deserialize SignatureResponse from transaction result"); + + match (&domain.scheme, &signature) { + (SignatureScheme::Secp256k1, SignatureResponse::Secp256k1(_)) => {} + (SignatureScheme::Ed25519, SignatureResponse::Ed25519 { .. }) => {} + _ => panic!( + "signature scheme mismatch: requested {:?}, got {:?}", + domain.scheme, signature + ), + } + tracing::info!(domain_id = ?domain.id, "sign request returned valid signature"); + } +} diff --git a/crates/node/src/config/start.rs b/crates/node/src/config/start.rs index f64c5c4ab..973138d9e 100644 --- a/crates/node/src/config/start.rs +++ b/crates/node/src/config/start.rs @@ -280,7 +280,11 @@ pub enum ChainId { impl ChainId { pub fn is_localnet(&self) -> bool { - *self == ChainId::Localnet + match self { + ChainId::Localnet => true, + ChainId::Custom(s) => s == "sandbox", + _ => false, + } } }