From c6064a40200c6ef3a23effae9eb43ac5416ead63 Mon Sep 17 00:00:00 2001 From: Nodar Date: Tue, 24 Mar 2026 18:48:38 +0100 Subject: [PATCH 1/7] feat: Implement MPC cluster orchestration for Rust E2E tests Introduce the core MPC cluster orchestration infrastructure that replaces Python's MpcCluster. This is the first of two stacked changes -- this one adds the cluster logic with todo!() stubs for the NEAR blockchain and sandbox components (filled in by the next change). Components: - cluster.rs: MpcCluster orchestrator with full setup sequence (sandbox, contract deploy, account creation, attestations, domain addition, node spawning), node lifecycle (kill/start/restart), contract operations (state queries, add_domains, resharing), metrics polling, and data management (wipe_db, block ingestion control). - mpc_node.rs: Updated MpcNode/MpcNodeSetup with metrics scraping (HTTP /metrics), wait_for_metric, set_block_ingestion, reserve_key_event_attempt, migration_state, wipe_db. Decoupled from NearNode -- takes genesis_path, boot_nodes, chain_id directly. - sandbox.rs: NearSandbox stub (todo!() -- Docker implementation in next change) - blockchain.rs: NearBlockchain + DeployedContract stubs (todo!() -- near-kit implementation in next change) Closes #2441 --- Cargo.lock | 293 ++++++++--- Cargo.toml | 2 +- crates/e2e-tests/Cargo.toml | 8 +- crates/e2e-tests/src/blockchain.rs | 82 ++++ crates/e2e-tests/src/cluster.rs | 706 +++++++++++++++++++++++++++ crates/e2e-tests/src/lib.rs | 7 +- crates/e2e-tests/src/mpc_node.rs | 120 ++++- crates/e2e-tests/src/near_node.rs | 80 --- crates/e2e-tests/src/near_sandbox.rs | 34 ++ 9 files changed, 1157 insertions(+), 175 deletions(-) create mode 100644 crates/e2e-tests/src/blockchain.rs create mode 100644 crates/e2e-tests/src/cluster.rs delete mode 100644 crates/e2e-tests/src/near_node.rs create mode 100644 crates/e2e-tests/src/near_sandbox.rs diff --git a/Cargo.lock b/Cargo.lock index f3731e03b..b48a33288 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -233,7 +233,7 @@ dependencies = [ "either", "serde", "serde_with", - "sha2", + "sha2 0.10.9", "thiserror 2.0.18", ] @@ -1004,7 +1004,7 @@ dependencies = [ "serde", "serde_json", "serde_with", - "sha2", + "sha2 0.10.9", "test-utils", "thiserror 2.0.18", "tracing", @@ -1024,7 +1024,7 @@ dependencies = [ "node-types", "reqwest 0.13.2", "serde_json", - "sha2", + "sha2 0.10.9", "tempfile", "test-utils", "time", @@ -1089,7 +1089,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5aeeee1a5defa63cba39097a510dfe63ef53658fc8995202a610f6a8a4d03639" dependencies = [ "attohttpc", - "dirs", + "dirs 4.0.0", "rust-ini", "serde", "serde-xml-rs", @@ -1228,7 +1228,7 @@ dependencies = [ "ed25519-dalek", "mpc-contract", "mpc-node", - "near-account-id 2.5.0", + "near-account-id 2.6.0", "near-mpc-contract-interface", "rand 0.8.5", "rand_core 0.6.4", @@ -1326,6 +1326,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "bip39" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" +dependencies = [ + "bitcoin_hashes", + "serde", + "unicode-normalization", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -1410,6 +1421,15 @@ dependencies = [ "constant_time_eq 0.4.2", ] +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -1669,7 +1689,7 @@ dependencies = [ "indenter", "pathdiff", "rustc_version 0.4.1", - "sha2", + "sha2 0.10.9", "tracing", ] @@ -1772,7 +1792,7 @@ dependencies = [ "base64 0.22.1", "derive_more 2.1.1", "ed25519-dalek", - "near-account-id 2.5.0", + "near-account-id 2.6.0", "near-async", "near-client", "near-crypto 2.11.0-rc.3", @@ -1857,7 +1877,7 @@ dependencies = [ "rand_core 0.6.4", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "sha3", ] @@ -2071,7 +2091,7 @@ dependencies = [ "reqwest 0.13.2", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tokio", ] @@ -2428,6 +2448,16 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-mac" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58bcd97a54c7ca5ce2f6eb16f6bede5b0ab5f0055fedc17d2f0b4466e21671ca" +dependencies = [ + "generic-array", + "subtle", +] + [[package]] name = "ctr" version = "0.9.2" @@ -2831,7 +2861,7 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", + "block-buffer 0.10.4", "const-oid", "crypto-common", "subtle", @@ -2843,7 +2873,16 @@ version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" dependencies = [ - "dirs-sys", + "dirs-sys 0.3.7", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys 0.5.0", ] [[package]] @@ -2863,10 +2902,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" dependencies = [ "libc", - "redox_users", + "redox_users 0.4.6", "winapi", ] +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", +] + [[package]] name = "dirs-sys-next" version = "0.1.2" @@ -2874,7 +2925,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" dependencies = [ "libc", - "redox_users", + "redox_users 0.4.6", "winapi", ] @@ -2932,7 +2983,7 @@ dependencies = [ "reqwest 0.12.28", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "x509-parser 0.16.0", ] @@ -2949,7 +3000,7 @@ dependencies = [ "pkcs8", "serde", "serde_json", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -2998,12 +3049,16 @@ dependencies = [ "anyhow", "bs58 0.5.1", "ed25519-dalek", + "futures", "hex", + "near-kit", + "near-mpc-contract-interface", "near-mpc-crypto-types", - "near-sandbox", - "near-workspaces", + "reqwest 0.13.2", "serde", "serde_json", + "tempfile", + "tokio", "toml 1.0.6+spec-1.1.0", "tracing", ] @@ -3059,7 +3114,7 @@ dependencies = [ "ed25519", "rand_core 0.6.4", "serde", - "sha2", + "sha2 0.10.9", "signature", "subtle", "zeroize", @@ -3623,7 +3678,7 @@ dependencies = [ "frost-core", "frost-rerandomized", "rand_core 0.6.4", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -3650,7 +3705,7 @@ dependencies = [ "frost-rerandomized", "k256", "rand_core 0.6.4", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -4166,7 +4221,17 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac", + "hmac 0.12.1", +] + +[[package]] +name = "hmac" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "deae6d9dbb35ec2c502d62b8f7b1c000a0822c3b0794ba36b3149c0a1c840dff" +dependencies = [ + "crypto-mac", + "digest 0.9.0", ] [[package]] @@ -5038,7 +5103,7 @@ dependencies = [ "elliptic-curve", "once_cell", "serdect", - "sha2", + "sha2 0.10.9", "signature", ] @@ -5527,7 +5592,7 @@ dependencies = [ "mpc-primitives", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "sha3", "test-utils", ] @@ -5557,7 +5622,7 @@ dependencies = [ "mpc-contract", "mpc-primitives", "near-abi", - "near-account-id 2.5.0", + "near-account-id 2.6.0", "near-mpc-bounded-collections", "near-mpc-contract-interface", "near-mpc-sdk", @@ -5571,7 +5636,7 @@ dependencies = [ "serde", "serde_json", "serde_with", - "sha2", + "sha2 0.10.9", "sha3", "signature", "test-utils", @@ -5594,7 +5659,7 @@ dependencies = [ "futures", "hex", "mpc-contract", - "near-account-id 2.5.0", + "near-account-id 2.6.0", "near-crypto 0.28.0", "near-jsonrpc-client 0.15.1", "near-jsonrpc-primitives 0.28.0", @@ -5650,7 +5715,7 @@ dependencies = [ "mpc-contract", "mpc-primitives", "mpc-tls", - "near-account-id 2.5.0", + "near-account-id 2.6.0", "near-async", "near-client", "near-config-utils 2.11.0-rc.3", @@ -5839,9 +5904,9 @@ dependencies = [ [[package]] name = "near-account-id" -version = "2.5.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91f75ff8eee73815c247d0e17f3c0b705f0e993922a5548acd2ad377aeb67fca" +checksum = "702dbca982e748975658812c7be2ca53211f454137486f98f6cf768934e2cb29" dependencies = [ "borsh", "schemars 0.8.22", @@ -5950,7 +6015,7 @@ dependencies = [ "num-rational", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "smart-default 0.6.0", "time", "tracing", @@ -5975,7 +6040,7 @@ dependencies = [ "parking_lot 0.12.5", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "smart-default 0.7.1", "time", "tracing", @@ -6000,7 +6065,7 @@ dependencies = [ "parking_lot 0.12.5", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "smart-default 0.7.1", "time", "tracing", @@ -6195,7 +6260,7 @@ dependencies = [ "derive_more 2.1.1", "ed25519-dalek", "hex", - "near-account-id 2.5.0", + "near-account-id 2.6.0", "near-config-utils 0.34.6", "near-schema-checker-lib 0.34.6", "near-stdx 0.34.6", @@ -6220,7 +6285,7 @@ dependencies = [ "derive_more 2.1.1", "ed25519-dalek", "hex", - "near-account-id 2.5.0", + "near-account-id 2.6.0", "near-config-utils 2.11.0-rc.3", "near-schema-checker-lib 2.11.0-rc.3", "near-stdx 2.11.0-rc.3", @@ -6242,7 +6307,7 @@ dependencies = [ "ed25519-dalek", "merlin", "rand_core 0.6.4", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -6505,12 +6570,55 @@ dependencies = [ "time", ] +[[package]] +name = "near-kit" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "518dc7024a8cbe6eed77bd69ddc073bfcff629c286649a5e6bfcde3a3929a772" +dependencies = [ + "base64 0.22.1", + "bip39", + "borsh", + "bs58 0.5.1", + "dirs 6.0.0", + "ed25519-dalek", + "futures", + "hex", + "k256", + "near-account-id 2.6.0", + "near-gas", + "near-kit-macros", + "near-token", + "rand 0.8.5", + "reqwest 0.13.2", + "serde", + "serde_json", + "serde_with", + "sha2 0.10.9", + "sha3", + "slipped10", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "near-kit-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "149fb4f24c335dfc4e2c485e74befcc83f75a398c858a7a38fc3f75171b6628a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "near-mainnet-res" version = "2.11.0-rc.3" source = "git+https://github.com/near/nearcore?tag=2.11.0-rc.3#e14d0211789f7112f4af68110979a5bec36b5933" dependencies = [ - "near-account-id 2.5.0", + "near-account-id 2.6.0", "near-chain-configs 2.11.0-rc.3", "near-primitives 2.11.0-rc.3", "serde_json", @@ -6548,7 +6656,7 @@ dependencies = [ "serde_json", "serde_repr", "serde_with", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -6631,7 +6739,7 @@ dependencies = [ "rayon", "reed-solomon-erasure", "serde", - "sha2", + "sha2 0.10.9", "strum 0.24.1", "stun", "thiserror 2.0.18", @@ -6691,7 +6799,7 @@ checksum = "6e1dcbb5276d4c400cb8e897a34aa87d08e0ef05143d4f394ac70257fd066345" dependencies = [ "borsh", "enum-map", - "near-account-id 2.5.0", + "near-account-id 2.6.0", "near-primitives-core 0.34.6", "near-schema-checker-lib 0.34.6", "num-rational", @@ -6709,7 +6817,7 @@ source = "git+https://github.com/near/nearcore?tag=2.11.0-rc.3#e14d0211789f7112f dependencies = [ "borsh", "enum-map", - "near-account-id 2.5.0", + "near-account-id 2.6.0", "near-primitives-core 2.11.0-rc.3", "near-schema-checker-lib 2.11.0-rc.3", "num-rational", @@ -6876,7 +6984,7 @@ dependencies = [ "num-rational", "serde", "serde_repr", - "sha2", + "sha2 0.10.9", "thiserror 2.0.18", ] @@ -6892,7 +7000,7 @@ dependencies = [ "bs58 0.4.0", "derive_more 2.1.1", "enum-map", - "near-account-id 2.5.0", + "near-account-id 2.6.0", "near-gas", "near-schema-checker-lib 0.34.6", "near-token", @@ -6900,7 +7008,7 @@ dependencies = [ "serde", "serde_repr", "serde_with", - "sha2", + "sha2 0.10.9", "thiserror 2.0.18", ] @@ -6915,7 +7023,7 @@ dependencies = [ "bs58 0.4.0", "derive_more 2.1.1", "enum-map", - "near-account-id 2.5.0", + "near-account-id 2.6.0", "near-gas", "near-schema-checker-lib 2.11.0-rc.3", "near-token", @@ -6923,7 +7031,7 @@ dependencies = [ "serde", "serde_repr", "serde_with", - "sha2", + "sha2 0.10.9", "thiserror 2.0.18", ] @@ -6937,7 +7045,7 @@ dependencies = [ "futures", "hex", "insta", - "near-account-id 2.5.0", + "near-account-id 2.6.0", "near-async", "near-chain-configs 2.11.0-rc.3", "near-client", @@ -6967,7 +7075,7 @@ dependencies = [ "binary-install", "fs4", "json-patch 4.1.0", - "near-account-id 2.5.0", + "near-account-id 2.6.0", "near-token", "serde", "serde_json", @@ -7051,7 +7159,7 @@ dependencies = [ "borsh", "bs58 0.5.1", "near-abi", - "near-account-id 2.5.0", + "near-account-id 2.6.0", "near-crypto 0.34.6", "near-gas", "near-parameters 0.34.6", @@ -7295,7 +7403,7 @@ dependencies = [ "ripemd", "rustix 1.1.4", "serde", - "sha2", + "sha2 0.10.9", "sha3", "strum 0.24.1", "tempfile", @@ -7339,7 +7447,7 @@ dependencies = [ "ripemd", "rustix 1.1.4", "serde", - "sha2", + "sha2 0.10.9", "sha3", "strum 0.24.1", "tempfile", @@ -7403,13 +7511,12 @@ dependencies = [ "async-trait", "base64 0.22.1", "bs58 0.5.1", - "cargo-near-build", "chrono", "fs2", "json-patch 2.0.0", "libc", "near-abi-client", - "near-account-id 2.5.0", + "near-account-id 2.6.0", "near-crypto 0.34.6", "near-gas", "near-jsonrpc-client 0.20.0", @@ -7421,7 +7528,7 @@ dependencies = [ "reqwest 0.12.28", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tempfile", "thiserror 1.0.69", "tokio", @@ -7465,7 +7572,7 @@ dependencies = [ "anyhow", "borsh", "bytesize", - "dirs", + "dirs 4.0.0", "easy-ext", "futures", "hex", @@ -7552,7 +7659,7 @@ dependencies = [ "rand_chacha 0.3.1", "rayon", "serde_json", - "sha2", + "sha2 0.10.9", "smallvec", "thiserror 2.0.18", "tracing", @@ -7959,6 +8066,12 @@ dependencies = [ "tokio-stream", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "ordered-float" version = "2.10.1" @@ -8135,7 +8248,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ "digest 0.10.7", - "hmac", + "hmac 0.12.1", ] [[package]] @@ -8209,7 +8322,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" dependencies = [ "pest", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -9013,6 +9126,17 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + [[package]] name = "reed-solomon-erasure" version = "6.0.0" @@ -9268,7 +9392,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" dependencies = [ - "hmac", + "hmac 0.12.1", "subtle", ] @@ -9406,7 +9530,7 @@ dependencies = [ "pkcs1", "pkcs8", "rand_core 0.6.4", - "sha2", + "sha2 0.10.9", "signature", "spki", "subtle", @@ -9506,7 +9630,7 @@ version = "8.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" dependencies = [ - "sha2", + "sha2 0.10.9", "walkdir", ] @@ -9533,7 +9657,7 @@ dependencies = [ "block_on_proc", "cfg-if 1.0.4", "hex", - "hmac", + "hmac 0.12.1", "http 0.2.12", "log", "maybe-async", @@ -9544,7 +9668,7 @@ dependencies = [ "serde", "serde-xml-rs", "serde_derive", - "sha2", + "sha2 0.10.9", "thiserror 1.0.69", "time", "tokio", @@ -10302,6 +10426,19 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if 1.0.4", + "cpufeatures", + "digest 0.9.0", + "opaque-debug", +] + [[package]] name = "sha2" version = "0.10.9" @@ -10420,6 +10557,17 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "slipped10" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a45443e66aa5d96db5e02d17db056e1ca970232a4fe73e1f9bc1816d68f4e98" +dependencies = [ + "ed25519-dalek", + "hmac 0.9.0", + "sha2 0.9.9", +] + [[package]] name = "smallvec" version = "1.15.1" @@ -10817,7 +10965,7 @@ dependencies = [ "assert_matches", "chain-gateway", "mpc-primitives", - "near-account-id 2.5.0", + "near-account-id 2.6.0", "near-mpc-contract-interface", "serde_json", "thiserror 2.0.18", @@ -10897,7 +11045,7 @@ dependencies = [ "near-mpc-contract-interface", "near-sdk", "serde", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -10911,7 +11059,7 @@ dependencies = [ "near-sdk", "serde_json", "serde_yaml", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -11021,7 +11169,7 @@ dependencies = [ "serde_bytes", "serde_json", "serde_with", - "sha2", + "sha2 0.10.9", "sha3", "subtle", "thiserror 2.0.18", @@ -11626,6 +11774,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-segmentation" version = "1.12.0" @@ -11736,7 +11893,7 @@ name = "utilities" version = "3.7.0" dependencies = [ "near-account-id 1.1.4", - "near-account-id 2.5.0", + "near-account-id 2.6.0", ] [[package]] @@ -13204,7 +13361,7 @@ dependencies = [ "displaydoc", "flate2", "getrandom 0.3.4", - "hmac", + "hmac 0.12.1", "indexmap 2.13.0", "lzma-rs", "memchr", diff --git a/Cargo.toml b/Cargo.toml index c42bc0932..ae0cfb2c1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -146,8 +146,8 @@ near-abi = "0.4.4" near-account-id = "2.5.0" near-jsonrpc-client = "0.20.0" near-jsonrpc-primitives = "0.34.6" +near-kit = { version = "0.7.0", default-features = false } 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 index e9d8e0e2b..241c63028 100644 --- a/crates/e2e-tests/Cargo.toml +++ b/crates/e2e-tests/Cargo.toml @@ -8,12 +8,16 @@ license = { workspace = true } anyhow = { workspace = true } bs58 = { workspace = true } ed25519-dalek = { workspace = true } +futures = { workspace = true } hex = { workspace = true } +near-kit = { workspace = true } +near-mpc-contract-interface = { workspace = true } near-mpc-crypto-types = { workspace = true } -near-sandbox = { workspace = true } -near-workspaces = { workspace = true, features = ["unstable"] } +reqwest = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +tempfile = { workspace = true } +tokio = { workspace = true } toml = { workspace = true } tracing = { workspace = true } diff --git a/crates/e2e-tests/src/blockchain.rs b/crates/e2e-tests/src/blockchain.rs new file mode 100644 index 000000000..4ce4ead49 --- /dev/null +++ b/crates/e2e-tests/src/blockchain.rs @@ -0,0 +1,82 @@ +use ed25519_dalek::SigningKey; +use near_mpc_contract_interface::types::ProtocolContractState; +use serde::de::DeserializeOwned; + +pub struct NearBlockchain { + _rpc_url: String, +} + +pub struct ClientHandle { + _private: (), +} + +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 async fn create_account( + &self, + _name: &str, + _balance_near: u64, + _key: &SigningKey, + ) -> anyhow::Result<()> { + unimplemented!() + } + + pub async fn create_account_and_deploy( + &self, + _name: &str, + _balance_near: u64, + _key: &SigningKey, + _wasm: &[u8], + ) -> anyhow::Result { + unimplemented!() + } + + pub fn client_for(&self, _account_id: &str, _key: &SigningKey) -> anyhow::Result { + unimplemented!() + } + + pub fn rpc_url(&self) -> &str { + unimplemented!() + } +} + +pub struct DeployedContract { + contract_id: String, +} + +impl DeployedContract { + pub fn contract_id(&self) -> &str { + &self.contract_id + } + + pub async fn call(&self, _method: &str, _args: serde_json::Value) -> anyhow::Result<()> { + unimplemented!() + } + + pub async fn call_from( + &self, + _client: &ClientHandle, + _method: &str, + _args: serde_json::Value, + ) -> anyhow::Result<()> { + unimplemented!() + } + + pub async fn view( + &self, + _method: &str, + ) -> anyhow::Result { + unimplemented!() + } + + pub async fn state(&self) -> anyhow::Result { + self.view("state").await + } +} diff --git a/crates/e2e-tests/src/cluster.rs b/crates/e2e-tests/src/cluster.rs new file mode 100644 index 000000000..1b7379b6f --- /dev/null +++ b/crates/e2e-tests/src/cluster.rs @@ -0,0 +1,706 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use anyhow::{Context, bail}; +use ed25519_dalek::SigningKey; +use near_kit::AccountId; +use near_mpc_contract_interface::method_names; +use near_mpc_contract_interface::types::{ + AccountId as ContractAccountId, DomainConfig, DomainId, DomainPurpose, ParticipantId, + ParticipantInfo, Participants, ProtocolContractState, SignatureScheme, Threshold, + ThresholdParameters, +}; +use serde_json::json; + +use crate::blockchain::{ClientHandle, DeployedContract, NearBlockchain}; +use crate::mpc_node::{MpcNode, MpcNodeSetup, MpcNodeSetupArgs, NodePorts}; +use crate::near_sandbox::NearSandbox; +use crate::port_allocator::E2ePortAllocator; + +const DEFAULT_SANDBOX_IMAGE: &str = "nearprotocol/sandbox:2.10.7"; +const SANDBOX_ROOT_ACCOUNT: &str = "sandbox"; +const SANDBOX_ROOT_SECRET_KEY: &str = "ed25519:3JoAjwLppjgvxkk6kNsu5wQj3FfUJnpBKWieC73hVTpBeA6FZiCc5tfyZL3a3tHeQJegQe4qGSv8FLsYp7TYd1r6"; + +// --------------------------------------------------------------------------- +// MpcClusterConfig +// --------------------------------------------------------------------------- + +/// Configuration for creating a new [`MpcCluster`]. +pub struct MpcClusterConfig { + /// Number of MPC nodes to start. + pub num_nodes: usize, + /// Threshold for signing (number of nodes required). + pub threshold: usize, + /// Signature domains to initialize after contract setup. + pub domains: Vec, + /// Path to the mpc-node binary. If multiple paths, each node gets the corresponding one. + pub binary_paths: Vec, + /// Compiled contract WASM bytes (pre-compiled by the test). + pub contract_wasm: Vec, + /// Port seed for the port allocator (must be unique across parallel tests). + pub port_seed: u16, + /// Triple buffer size per node. + pub triples_to_buffer: usize, + /// Presignature buffer size per node. + pub presignatures_to_buffer: usize, + /// Docker image for the NEAR sandbox (e.g. `"nearprotocol/sandbox:2.10.7"`). + pub sandbox_image: String, + /// Root directory for all test artifacts (logs, configs, DB). If `None`, a temp dir is created. + pub home_base: Option, +} + +impl MpcClusterConfig { + /// Sensible defaults for a basic E2E test. + /// + /// - 3 nodes, 2-of-3 threshold + /// - All 3 standard domains (Secp256k1, Ed25519, Bls12381) + /// - 10 triples, 10 presignatures per node + pub fn default_for_test(port_seed: u16, contract_wasm: Vec) -> Self { + Self { + num_nodes: 3, + threshold: 2, + domains: vec![ + DomainConfig { + id: DomainId(0), + scheme: SignatureScheme::Secp256k1, + purpose: Some(DomainPurpose::Sign), + }, + DomainConfig { + id: DomainId(1), + scheme: SignatureScheme::Ed25519, + purpose: Some(DomainPurpose::Sign), + }, + DomainConfig { + id: DomainId(2), + scheme: SignatureScheme::Bls12381, + purpose: Some(DomainPurpose::CKD), + }, + ], + binary_paths: vec![default_mpc_binary_path()], + contract_wasm, + port_seed, + triples_to_buffer: 10, + presignatures_to_buffer: 10, + sandbox_image: DEFAULT_SANDBOX_IMAGE.to_string(), + home_base: None, + } + } +} + +fn default_mpc_binary_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/release/mpc-node") +} + +// --------------------------------------------------------------------------- +// MpcCluster +// --------------------------------------------------------------------------- + +/// A running MPC test cluster with a deployed contract and N mpc-node processes. +/// +/// Orchestrates the full test environment: Docker sandbox -> contract -> +/// accounts -> attestations -> domains -> mpc-node processes. +/// +/// All nodes are killed when dropped. +pub struct MpcCluster { + pub sandbox: NearSandbox, + pub blockchain: NearBlockchain, + pub contract: DeployedContract, + pub nodes: Vec, + pub node_keys: Vec, + pub user_accounts: HashMap, + pub ports: E2ePortAllocator, + /// Held to keep the temp directory alive for the lifetime of the cluster. + pub test_dir: tempfile::TempDir, +} + +impl MpcCluster { + /// Create the full cluster: start Docker sandbox, deploy contract, + /// create accounts, submit attestations, add domains, spawn mpc-node + /// binaries, and wait for Running state. + pub async fn start(config: MpcClusterConfig) -> anyhow::Result { + let ports = E2ePortAllocator::new(config.port_seed); + let test_dir = create_test_dir(&config.home_base)?; + + let sandbox = NearSandbox::start(&ports, &config.sandbox_image, test_dir.path()).await?; + let blockchain = NearBlockchain::new( + &sandbox.rpc_url(), + SANDBOX_ROOT_ACCOUNT, + SANDBOX_ROOT_SECRET_KEY, + )?; + + let contract_key = generate_deterministic_key(255); + let contract_account: AccountId = format!("mpc.{SANDBOX_ROOT_ACCOUNT}").parse()?; + let (node_keys, node_near_keys, node_p2p_keys) = generate_node_keys(config.num_nodes); + + let contract = deploy_contract( + &blockchain, + &contract_account, + &contract_key, + &config.contract_wasm, + ) + .await?; + + create_node_accounts(&blockchain, &node_near_keys).await?; + + init_contract( + &blockchain, + &contract, + &node_near_keys, + &node_p2p_keys, + config.threshold, + config.num_nodes, + &ports, + ) + .await?; + + if !config.domains.is_empty() { + add_initial_domains(&blockchain, &contract, &node_near_keys, &config.domains).await?; + } + + let nodes = start_mpc_nodes( + &config, + &sandbox, + &node_near_keys, + &node_p2p_keys, + &contract_account, + test_dir.path(), + &ports, + )?; + + let user_accounts = create_user_account(&blockchain).await?; + + tracing::info!("MPC cluster is ready"); + + Ok(Self { + sandbox, + blockchain, + contract, + nodes, + node_keys, + user_accounts, + ports, + test_dir, + }) + } + + // -- Node lifecycle ------------------------------------------------------ + + pub fn kill_nodes(&mut self, indices: &[usize]) -> anyhow::Result<()> { + for &idx in indices { + let state = self.nodes.remove(idx); + let new_state = match state { + MpcNodeState::Running(node) => MpcNodeState::Stopped(node.kill()), + MpcNodeState::Stopped(setup) => { + tracing::warn!(node = idx, "node already stopped"); + MpcNodeState::Stopped(setup) + } + }; + self.nodes.insert(idx, new_state); + } + Ok(()) + } + + pub fn start_nodes(&mut self, indices: &[usize]) -> anyhow::Result<()> { + for &idx in indices { + let state = self.nodes.remove(idx); + let new_state = match state { + MpcNodeState::Stopped(setup) => MpcNodeState::Running(setup.start()?), + MpcNodeState::Running(node) => { + tracing::warn!(node = idx, "node already running"); + MpcNodeState::Running(node) + } + }; + self.nodes.insert(idx, new_state); + } + Ok(()) + } + + pub fn kill_all(&mut self) { + let indices: Vec = (0..self.nodes.len()).collect(); + let _ = self.kill_nodes(&indices); + } + + // -- Contract operations ------------------------------------------------- + + pub async fn get_contract_state(&self) -> anyhow::Result { + self.contract.state().await + } + + pub async fn wait_for_state( + &self, + predicate: impl Fn(&ProtocolContractState) -> bool, + timeout: Duration, + ) -> anyhow::Result<()> { + wait_for_contract_state(&self.contract, timeout, predicate).await + } + + pub async fn add_domains(&self, domains: Vec) -> anyhow::Result<()> { + let args = json!({ "domains": domains }); + self.call_from_all_nodes_concurrently(method_names::VOTE_ADD_DOMAINS, args) + .await?; + + self.wait_for_state( + |s| matches!(s, ProtocolContractState::Initializing(_)), + Duration::from_secs(30), + ) + .await?; + + self.wait_for_state( + |s| matches!(s, ProtocolContractState::Running(_)), + Duration::from_secs(120), + ) + .await + } + + pub async fn start_resharing( + &self, + new_participants: &[usize], + new_threshold: usize, + ) -> anyhow::Result<()> { + let state = self.get_contract_state().await?; + let epoch_id = match &state { + ProtocolContractState::Running(r) => r.keyset.epoch_id, + _ => bail!("cannot reshare: contract not in Running state"), + }; + + let participants = + build_participants_from_nodes(new_participants, &self.nodes, &self.ports); + let proposal = ThresholdParameters { + threshold: Threshold(new_threshold as u64), + participants, + }; + + self.call_from_all_nodes_concurrently( + method_names::VOTE_NEW_PARAMETERS, + json!({ "prospective_epoch_id": epoch_id, "proposal": proposal }), + ) + .await?; + + self.wait_for_state( + |s| matches!(s, ProtocolContractState::Resharing(_)), + Duration::from_secs(30), + ) + .await?; + + self.wait_for_state( + |s| matches!(s, ProtocolContractState::Running(_)), + Duration::from_secs(120), + ) + .await + } + + // -- Metrics ------------------------------------------------------------- + + pub async fn get_metric_all_nodes(&self, name: &str) -> anyhow::Result>> { + let mut results = Vec::new(); + for node in &self.nodes { + match node { + MpcNodeState::Running(n) => results.push(n.get_metric(name).await?), + MpcNodeState::Stopped(_) => results.push(None), + } + } + Ok(results) + } + + pub async fn wait_for_metric_all_nodes( + &self, + name: &str, + expected: i64, + timeout: Duration, + ) -> anyhow::Result<()> { + 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)) { + return Ok(()); + } + if tokio::time::Instant::now() >= deadline { + bail!( + "metric {name} did not reach {expected} on all nodes within {}s (values: {values:?})", + timeout.as_secs() + ); + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + } + + // -- Data management ----------------------------------------------------- + + pub fn wipe_db(&self, indices: &[usize]) -> anyhow::Result<()> { + for &idx in indices { + match &self.nodes[idx] { + MpcNodeState::Stopped(setup) => setup.wipe_db()?, + MpcNodeState::Running(_) => bail!("cannot wipe DB for running node {idx}"), + } + } + Ok(()) + } + + pub fn set_block_ingestion(&self, indices: &[usize], active: bool) -> anyhow::Result<()> { + for &idx in indices { + match &self.nodes[idx] { + MpcNodeState::Running(node) => node.set_block_ingestion(active)?, + MpcNodeState::Stopped(_) => { + bail!("cannot set block ingestion for stopped node {idx}") + } + } + } + Ok(()) + } + + // -- Helpers ------------------------------------------------------------- + + async fn call_from_all_nodes_concurrently( + &self, + method: &str, + args: serde_json::Value, + ) -> anyhow::Result<()> { + let clients: Vec<_> = self + .nodes + .iter() + .zip(self.node_keys.iter()) + .enumerate() + .filter(|(_, (node, _))| matches!(node, MpcNodeState::Running(_))) + .map(|(i, (node, key))| { + let client = self + .blockchain + .client_for(node.account_id().as_ref(), key)?; + Ok((i, node.account_id().clone(), client)) + }) + .collect::>>()?; + + let futures = clients.iter().map(|(i, account, client)| { + let args = args.clone(); + let method = method.to_string(); + async move { + self.contract + .call_from(client, &method, args) + .await + .with_context(|| format!("node {i} ({account}) failed to call {method}")) + } + }); + + futures::future::try_join_all(futures).await?; + Ok(()) + } + + pub fn user_client(&self, account_id: &AccountId) -> anyhow::Result { + let key = self + .user_accounts + .get(account_id) + .with_context(|| format!("unknown user account: {account_id}"))?; + self.blockchain.client_for(account_id.as_ref(), key) + } + + pub fn default_user_account(&self) -> &AccountId { + self.user_accounts + .keys() + .next() + .expect("cluster should have at least one user account") + } +} + +impl Drop for MpcCluster { + fn drop(&mut self) { + self.kill_all(); + } +} + +// --------------------------------------------------------------------------- +// MpcNodeState +// --------------------------------------------------------------------------- + +/// A node that is either running or stopped (killed). +pub enum MpcNodeState { + Running(MpcNode), + Stopped(MpcNodeSetup), +} + +impl MpcNodeState { + pub fn account_id(&self) -> &AccountId { + match self { + MpcNodeState::Running(n) => n.setup().account_id(), + MpcNodeState::Stopped(s) => s.account_id(), + } + } + + pub fn p2p_public_key_str(&self) -> String { + match self { + MpcNodeState::Running(n) => n.setup().p2p_public_key_str(), + MpcNodeState::Stopped(s) => s.p2p_public_key_str(), + } + } + + pub fn p2p_url(&self) -> String { + match self { + MpcNodeState::Running(n) => n.setup().p2p_url(), + MpcNodeState::Stopped(s) => s.p2p_url(), + } + } +} + +// --------------------------------------------------------------------------- +// Setup helpers (factored out of MpcCluster::start) +// --------------------------------------------------------------------------- + +fn create_test_dir(home_base: &Option) -> anyhow::Result { + match home_base { + Some(base) => { + std::fs::create_dir_all(base)?; + Ok(tempfile::tempdir_in(base)?) + } + None => Ok(tempfile::tempdir()?), + } +} + +fn generate_node_keys(num_nodes: usize) -> (Vec, Vec, Vec) { + let mut node_keys = Vec::new(); + let mut near_keys = Vec::new(); + let mut p2p_keys = Vec::new(); + for i in 0..num_nodes { + let near_key = generate_deterministic_key(i); + let p2p_key = generate_deterministic_key(100 + i); + node_keys.push(near_key.clone()); + near_keys.push(near_key); + p2p_keys.push(p2p_key); + } + (node_keys, near_keys, p2p_keys) +} + +async fn deploy_contract( + blockchain: &NearBlockchain, + contract_account: &AccountId, + contract_key: &SigningKey, + wasm: &[u8], +) -> anyhow::Result { + tracing::info!(account = %contract_account, "deploying MPC contract"); + blockchain + .create_account_and_deploy(contract_account.as_ref(), 1000, contract_key, wasm) + .await +} + +async fn create_node_accounts( + blockchain: &NearBlockchain, + near_keys: &[SigningKey], +) -> anyhow::Result<()> { + for (i, key) in near_keys.iter().enumerate() { + let account = format!("node{i}.{SANDBOX_ROOT_ACCOUNT}"); + tracing::info!(account = %account, "creating MPC node account"); + blockchain.create_account(&account, 100, key).await?; + } + Ok(()) +} + +async fn init_contract( + blockchain: &NearBlockchain, + contract: &DeployedContract, + near_keys: &[SigningKey], + p2p_keys: &[SigningKey], + threshold: usize, + num_nodes: usize, + ports: &E2ePortAllocator, +) -> anyhow::Result<()> { + let participants = build_participants(num_nodes, p2p_keys, ports); + let params = ThresholdParameters { + threshold: Threshold(threshold as u64), + participants, + }; + + tracing::info!(threshold, "initializing contract"); + contract + .call(method_names::INIT, json!({ "parameters": params })) + .await?; + + for (i, (near_key, p2p_key)) in near_keys.iter().zip(p2p_keys.iter()).enumerate() { + let account = format!("node{i}.{SANDBOX_ROOT_ACCOUNT}"); + let client = blockchain.client_for(&account, near_key)?; + let pubkey = + near_mpc_crypto_types::Ed25519PublicKey::from(p2p_key.verifying_key().to_bytes()); + contract + .call_from( + &client, + method_names::SUBMIT_PARTICIPANT_INFO, + json!({ + "proposed_participant_attestation": { "Mock": "Valid" }, + "tls_public_key": pubkey, + }), + ) + .await + .with_context(|| format!("failed to submit attestation for node {i}"))?; + } + + wait_for_contract_state(contract, Duration::from_secs(30), |s| { + matches!(s, ProtocolContractState::Running(_)) + }) + .await + .context("contract did not reach Running state after init") +} + +async fn add_initial_domains( + blockchain: &NearBlockchain, + contract: &DeployedContract, + near_keys: &[SigningKey], + domains: &[DomainConfig], +) -> anyhow::Result<()> { + tracing::info!(count = domains.len(), "adding domains"); + let args = json!({ "domains": domains }); + + for (i, key) in near_keys.iter().enumerate() { + let account = format!("node{i}.{SANDBOX_ROOT_ACCOUNT}"); + let client = blockchain.client_for(&account, key)?; + contract + .call_from(&client, method_names::VOTE_ADD_DOMAINS, args.clone()) + .await + .with_context(|| format!("node {i} failed to vote add domains"))?; + } + + wait_for_contract_state(contract, Duration::from_secs(60), |s| { + matches!(s, ProtocolContractState::Initializing(_)) + }) + .await + .context("contract did not reach Initializing state after domain addition")?; + + wait_for_contract_state(contract, Duration::from_secs(120), |s| { + matches!(s, ProtocolContractState::Running(_)) + }) + .await + .context("contract did not reach Running state after key generation") +} + +fn start_mpc_nodes( + config: &MpcClusterConfig, + sandbox: &NearSandbox, + near_keys: &[SigningKey], + p2p_keys: &[SigningKey], + contract_account: &AccountId, + test_dir: &Path, + ports: &E2ePortAllocator, +) -> anyhow::Result> { + let chain_id = sandbox.chain_id()?; + let genesis_path = sandbox.genesis_path(); + let boot_nodes = sandbox.boot_nodes()?; + + tracing::info!(count = config.num_nodes, "starting MPC nodes"); + let mut nodes = Vec::new(); + for i in 0..config.num_nodes { + let binary_path = if config.binary_paths.len() == 1 { + config.binary_paths[0].clone() + } else { + config.binary_paths[i].clone() + }; + + let setup = MpcNodeSetup::new(MpcNodeSetupArgs { + node_index: i, + home_dir: test_dir.join(format!("node{i}")), + binary_path, + signer_account_id: format!("node{i}.{SANDBOX_ROOT_ACCOUNT}").parse()?, + p2p_signing_key: p2p_keys[i].clone(), + near_signer_key: near_keys[i].clone(), + ports: NodePorts::from_allocator(ports, i), + mpc_contract_id: contract_account.clone(), + triples_to_buffer: config.triples_to_buffer, + presignatures_to_buffer: config.presignatures_to_buffer, + chain_id: chain_id.clone(), + near_genesis_path: genesis_path.clone(), + near_boot_nodes: boot_nodes.clone(), + })?; + nodes.push(MpcNodeState::Running(setup.start()?)); + } + Ok(nodes) +} + +async fn create_user_account( + blockchain: &NearBlockchain, +) -> anyhow::Result> { + let key = generate_deterministic_key(200); + let account: AccountId = format!("user.{SANDBOX_ROOT_ACCOUNT}").parse()?; + blockchain + .create_account(account.as_ref(), 100, &key) + .await?; + let mut map = HashMap::new(); + map.insert(account, key); + Ok(map) +} + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +fn build_participants( + num_nodes: usize, + p2p_keys: &[SigningKey], + ports: &E2ePortAllocator, +) -> 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().to_bytes()); + list.push(( + account_id, + ParticipantId(i as u32), + ParticipantInfo { + url: format!("http://127.0.0.1:{}", ports.p2p_port(i)), + sign_pk: String::from(&pubkey), + }, + )); + } + Participants { + next_id: ParticipantId(num_nodes as u32), + participants: list, + } +} + +fn build_participants_from_nodes( + indices: &[usize], + nodes: &[MpcNodeState], + _ports: &E2ePortAllocator, +) -> Participants { + let mut list = Vec::new(); + for (new_idx, &old_idx) in indices.iter().enumerate() { + list.push(( + ContractAccountId(nodes[old_idx].account_id().to_string()), + ParticipantId(new_idx as u32), + ParticipantInfo { + url: nodes[old_idx].p2p_url(), + sign_pk: nodes[old_idx].p2p_public_key_str(), + }, + )); + } + Participants { + next_id: ParticipantId(indices.len() as u32), + participants: list, + } +} + +fn generate_deterministic_key(seed: usize) -> SigningKey { + let mut key_bytes = [0u8; 32]; + key_bytes[0] = (seed & 0xFF) as u8; + key_bytes[1] = ((seed >> 8) & 0xFF) as u8; + for (i, byte) in key_bytes.iter_mut().enumerate().skip(2) { + *byte = ((seed * 31 + i * 37) & 0xFF) as u8; + } + SigningKey::from_bytes(&key_bytes) +} + +async fn wait_for_contract_state( + contract: &DeployedContract, + timeout: Duration, + predicate: impl Fn(&ProtocolContractState) -> bool, +) -> anyhow::Result<()> { + let deadline = tokio::time::Instant::now() + timeout; + loop { + match contract.state().await { + Ok(state) if predicate(&state) => return Ok(()), + Ok(_) => {} + Err(e) => tracing::debug!(error = %e, "failed to query contract state (retrying)"), + } + if tokio::time::Instant::now() >= deadline { + bail!( + "contract state predicate not satisfied within {}s", + timeout.as_secs() + ); + } + tokio::time::sleep(Duration::from_millis(200)).await; + } +} diff --git a/crates/e2e-tests/src/lib.rs b/crates/e2e-tests/src/lib.rs index ebb4ca11e..5fbb2b49f 100644 --- a/crates/e2e-tests/src/lib.rs +++ b/crates/e2e-tests/src/lib.rs @@ -1,5 +1,10 @@ +pub mod blockchain; +pub mod cluster; pub mod mpc_node; -pub mod near_node; +pub mod near_sandbox; pub mod port_allocator; +pub use blockchain::{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 ad7a595d2..ccf40f40b 100644 --- a/crates/e2e-tests/src/mpc_node.rs +++ b/crates/e2e-tests/src/mpc_node.rs @@ -4,17 +4,18 @@ use std::process::{Child, Command, Stdio}; use anyhow::Context; use ed25519_dalek::SigningKey; +use near_kit::AccountId; use near_mpc_crypto_types::Ed25519PublicKey; -use near_workspaces::{Account, AccountId}; use serde::Serialize; use serde_json::json; -use crate::near_node::NearNode; use crate::port_allocator::E2ePortAllocator; const DUMMY_IMAGE_HASH: &str = "sha256:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; +const LISTEN_BLOCKS_FILE: &str = "listen_blocks"; + /// Handle to a running `mpc-node` OS process. Always represents a live process. /// Obtained by calling [`MpcNodeSetup::start()`]. /// The child process is killed automatically when this value is dropped. @@ -30,13 +31,47 @@ impl MpcNode { self.setup } - /// Kill then start. New process, same config and data directory. - pub fn restart(self) -> anyhow::Result { - self.kill().start() + /// Reference to the underlying setup (config, paths, ports). + pub fn setup(&self) -> &MpcNodeSetup { + &self.setup + } + + fn web_address(&self) -> String { + format!("127.0.0.1:{}", self.setup.ports.web_ui) + } + + /// Scrapes the node's `/metrics` HTTP endpoint and returns the value of + /// the named metric, parsed as `i64`. Returns `None` if the metric is not + /// found or the node is unreachable. + pub async fn get_metric(&self, name: &str) -> anyhow::Result> { + let url = format!("http://{}/metrics", self.web_address()); + let body = match reqwest::get(&url).await { + Ok(resp) => resp.text().await.context("failed to read metrics body")?, + Err(_) => return Ok(None), + }; + + for line in body.lines() { + if line.starts_with('#') { + continue; + } + // Match lines like "metric_name " or "metric_name{labels} " + let metric_key = line.split([' ', '{']).next().unwrap_or(""); + if metric_key == name { + let value_str = line.rsplit_once(' ').map(|(_, v)| v).unwrap_or("0"); + if let Ok(v) = value_str.parse::() { + return Ok(Some(v as i64)); + } + } + } + Ok(None) } - pub fn is_running(&mut self) -> bool { - !matches!(self.process.0.try_wait(), Ok(Some(_))) + /// Writes a flag file that controls block ingestion. Requires the + /// `network-hardship-simulation` feature on the mpc-node binary. + pub fn set_block_ingestion(&self, active: bool) -> anyhow::Result<()> { + let path = self.setup.home_dir.join(LISTEN_BLOCKS_FILE); + std::fs::write(&path, if active { "true" } else { "false" }) + .with_context(|| format!("failed to write {}", path.display())) } } @@ -68,11 +103,14 @@ pub struct MpcNodeSetup { ports: NodePorts, mpc_contract_id: AccountId, + // NEAR chain info (from sandbox) + chain_id: String, + near_genesis_path: PathBuf, + near_boot_nodes: String, + // Derived config values secret_store_key_hex: String, backup_encryption_key_hex: String, - near_node_genesis_path: PathBuf, - near_node_boot_nodes: String, triples_to_buffer: usize, presignatures_to_buffer: usize, @@ -82,10 +120,7 @@ pub struct MpcNodeSetup { impl MpcNodeSetup { /// Create a new node setup. Writes config files to disk immediately. - pub fn new(args: MpcNodeSetupArgs, near_node: &NearNode) -> anyhow::Result { - let near_node_genesis_path = near_node.genesis_path(); - let near_node_boot_nodes = near_node.boot_nodes()?; - + pub fn new(args: MpcNodeSetupArgs) -> anyhow::Result { // Deterministic secret keys for each node let secret_byte = b'A' .checked_add(u8::try_from(args.node_index).context("node_index too large")?) @@ -111,10 +146,11 @@ impl MpcNodeSetup { near_signer_key: args.near_signer_key, ports: args.ports, mpc_contract_id: args.mpc_contract_id, + chain_id: args.chain_id, + near_genesis_path: args.near_genesis_path, + near_boot_nodes: args.near_boot_nodes, secret_store_key_hex, backup_encryption_key_hex, - near_node_genesis_path, - near_node_boot_nodes, triples_to_buffer: args.triples_to_buffer, presignatures_to_buffer: args.presignatures_to_buffer, config_path, @@ -129,7 +165,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(), )) } @@ -138,6 +174,36 @@ impl MpcNodeSetup { format!("http://127.0.0.1:{}", self.ports.p2p) } + /// The NEAR account ID for this node. + pub fn account_id(&self) -> &AccountId { + &self.signer_account_id + } + + /// Deletes RocksDB files (.sst, MANIFEST, etc.) from the data directory. + /// Safe to call because the node is not running. + pub fn wipe_db(&self) -> anyhow::Result<()> { + let entries = std::fs::read_dir(&self.home_dir) + .with_context(|| format!("failed to read dir {}", self.home_dir.display()))?; + + for entry in entries { + let entry = entry?; + let name = entry.file_name(); + let name = name.to_string_lossy(); + + let should_remove = matches!(name.as_ref(), "CURRENT" | "IDENTITY" | "LOCK" | "LOG") + || name.starts_with("MANIFEST-") + || name.starts_with("OPTIONS-") + || name.ends_with(".log") + || name.ends_with(".sst"); + + if should_remove { + std::fs::remove_file(entry.path()) + .with_context(|| format!("failed to remove {}", entry.path().display()))?; + } + } + Ok(()) + } + /// Spawn the mpc-node process. Consumes self, returning an MpcNode handle. pub fn start(self) -> anyhow::Result { tracing::info!( @@ -181,8 +247,7 @@ impl MpcNodeSetup { } /// 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. + /// generating random ones. fn write_secrets_json(&self) -> anyhow::Result<()> { let secrets_path = self.home_dir.join("secrets.json"); @@ -226,16 +291,16 @@ impl MpcNodeSetup { filter: "debug".to_string(), }, near_init: NearInit { - chain_id: "mpc-localnet".to_string(), - boot_nodes: self.near_node_boot_nodes.clone(), - genesis_path: self.near_node_genesis_path.display().to_string(), + chain_id: self.chain_id.clone(), + boot_nodes: self.near_boot_nodes.clone(), + genesis_path: self.near_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: Node { my_near_account_id: signer.clone(), - near_responder_account_id: signer, + near_responder_account_id: signer.clone(), 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), @@ -285,9 +350,14 @@ pub struct MpcNodeSetupArgs { 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, + /// Chain ID from the sandbox's genesis.json. + pub chain_id: String, + /// Path to genesis.json on the host (copied from sandbox container). + pub near_genesis_path: PathBuf, + /// Boot nodes string: `"ed25519:@127.0.0.1:"`. + pub near_boot_nodes: String, } /// Ports allocated for a single MPC node. @@ -313,6 +383,10 @@ impl NodePorts { } } +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + // --------------------------------------------------------------------------- // TODO(#2560): Factor `StartConfig` out of `mpc-node` into a lightweight crate so we // can reuse it here instead of duplicating the structure. diff --git a/crates/e2e-tests/src/near_node.rs b/crates/e2e-tests/src/near_node.rs deleted file mode 100644 index 8c3784f49..000000000 --- a/crates/e2e-tests/src/near_node.rs +++ /dev/null @@ -1,80 +0,0 @@ -use std::path::{Path, PathBuf}; - -use anyhow::Context; - -use crate::port_allocator::E2ePortAllocator; - -/// Wraps a NEAR node process with controlled ports. -/// -/// The NEAR node is the single validator that all mpc-node indexers -/// connect to via P2P boot_nodes. -pub struct NearNode { - sandbox: near_sandbox::Sandbox, - rpc_port: u16, - network_port: u16, -} - -impl NearNode { - /// Start a NEAR validator with ports from the allocator. - pub async fn start(ports: &E2ePortAllocator) -> anyhow::Result { - let rpc_port = ports.near_node_rpc_port(); - let network_port = ports.near_node_network_port(); - - tracing::info!(rpc_port, network_port, "starting near-sandbox"); - - let config = near_sandbox::SandboxConfig { - rpc_port: Some(rpc_port), - net_port: Some(network_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, - network_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 network_port(&self) -> u16 { - self.network_port - } - - /// Path to the NEAR node 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 NEAR node 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.network_port)) - } -} diff --git a/crates/e2e-tests/src/near_sandbox.rs b/crates/e2e-tests/src/near_sandbox.rs new file mode 100644 index 000000000..8fc1960b8 --- /dev/null +++ b/crates/e2e-tests/src/near_sandbox.rs @@ -0,0 +1,34 @@ +use std::path::{Path, PathBuf}; + +use crate::port_allocator::E2ePortAllocator; + +pub struct NearSandbox { + _rpc_port: u16, + _network_port: u16, +} + +impl NearSandbox { + pub async fn start( + _ports: &E2ePortAllocator, + _image: &str, + _test_dir: &Path, + ) -> anyhow::Result { + unimplemented!("Docker sandbox implementation — see Change 2") + } + + pub fn rpc_url(&self) -> String { + unimplemented!() + } + + pub fn genesis_path(&self) -> PathBuf { + unimplemented!() + } + + pub fn boot_nodes(&self) -> anyhow::Result { + unimplemented!() + } + + pub fn chain_id(&self) -> anyhow::Result { + unimplemented!() + } +} From 5946bcf088054924930cd0d0a48b955d137b9bc2 Mon Sep 17 00:00:00 2001 From: Nodar Date: Wed, 25 Mar 2026 21:19:33 +0100 Subject: [PATCH 2/7] Address comments --- crates/e2e-tests/src/cluster.rs | 59 ++++++++++----------------------- 1 file changed, 18 insertions(+), 41 deletions(-) diff --git a/crates/e2e-tests/src/cluster.rs b/crates/e2e-tests/src/cluster.rs index 1b7379b6f..87485440d 100644 --- a/crates/e2e-tests/src/cluster.rs +++ b/crates/e2e-tests/src/cluster.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::time::Duration; -use anyhow::{Context, bail}; +use anyhow::Context; use ed25519_dalek::SigningKey; use near_kit::AccountId; use near_mpc_contract_interface::method_names; @@ -22,10 +22,6 @@ const DEFAULT_SANDBOX_IMAGE: &str = "nearprotocol/sandbox:2.10.7"; const SANDBOX_ROOT_ACCOUNT: &str = "sandbox"; const SANDBOX_ROOT_SECRET_KEY: &str = "ed25519:3JoAjwLppjgvxkk6kNsu5wQj3FfUJnpBKWieC73hVTpBeA6FZiCc5tfyZL3a3tHeQJegQe4qGSv8FLsYp7TYd1r6"; -// --------------------------------------------------------------------------- -// MpcClusterConfig -// --------------------------------------------------------------------------- - /// Configuration for creating a new [`MpcCluster`]. pub struct MpcClusterConfig { /// Number of MPC nodes to start. @@ -92,10 +88,6 @@ fn default_mpc_binary_path() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/release/mpc-node") } -// --------------------------------------------------------------------------- -// MpcCluster -// --------------------------------------------------------------------------- - /// A running MPC test cluster with a deployed contract and N mpc-node processes. /// /// Orchestrates the full test environment: Docker sandbox -> contract -> @@ -168,7 +160,7 @@ impl MpcCluster { &ports, )?; - let user_accounts = create_user_account(&blockchain).await?; + let user_accounts = create_user_accounts(&blockchain).await?; tracing::info!("MPC cluster is ready"); @@ -184,10 +176,11 @@ impl MpcCluster { }) } - // -- Node lifecycle ------------------------------------------------------ - pub fn kill_nodes(&mut self, indices: &[usize]) -> anyhow::Result<()> { for &idx in indices { + if idx >= self.nodes.len() { + anyhow::bail!("node index {idx} out of bounds (have {} nodes)", self.nodes.len()); + } let state = self.nodes.remove(idx); let new_state = match state { MpcNodeState::Running(node) => MpcNodeState::Stopped(node.kill()), @@ -216,13 +209,11 @@ impl MpcCluster { Ok(()) } - pub fn kill_all(&mut self) { + pub fn kill_all(&mut self) -> anyhow::Result<()> { let indices: Vec = (0..self.nodes.len()).collect(); - let _ = self.kill_nodes(&indices); + self.kill_nodes(&indices) } - // -- Contract operations ------------------------------------------------- - pub async fn get_contract_state(&self) -> anyhow::Result { self.contract.state().await } @@ -261,7 +252,7 @@ impl MpcCluster { let state = self.get_contract_state().await?; let epoch_id = match &state { ProtocolContractState::Running(r) => r.keyset.epoch_id, - _ => bail!("cannot reshare: contract not in Running state"), + _ => anyhow::bail!("cannot reshare: contract not in Running state"), }; let participants = @@ -290,8 +281,6 @@ impl MpcCluster { .await } - // -- Metrics ------------------------------------------------------------- - pub async fn get_metric_all_nodes(&self, name: &str) -> anyhow::Result>> { let mut results = Vec::new(); for node in &self.nodes { @@ -316,7 +305,7 @@ impl MpcCluster { return Ok(()); } if tokio::time::Instant::now() >= deadline { - bail!( + anyhow::bail!( "metric {name} did not reach {expected} on all nodes within {}s (values: {values:?})", timeout.as_secs() ); @@ -325,13 +314,11 @@ impl MpcCluster { } } - // -- Data management ----------------------------------------------------- - pub fn wipe_db(&self, indices: &[usize]) -> anyhow::Result<()> { for &idx in indices { match &self.nodes[idx] { MpcNodeState::Stopped(setup) => setup.wipe_db()?, - MpcNodeState::Running(_) => bail!("cannot wipe DB for running node {idx}"), + MpcNodeState::Running(_) => anyhow::bail!("cannot wipe DB for running node {idx}"), } } Ok(()) @@ -342,15 +329,13 @@ impl MpcCluster { match &self.nodes[idx] { MpcNodeState::Running(node) => node.set_block_ingestion(active)?, MpcNodeState::Stopped(_) => { - bail!("cannot set block ingestion for stopped node {idx}") + anyhow::bail!("cannot set block ingestion for stopped node {idx}") } } } Ok(()) } - // -- Helpers ------------------------------------------------------------- - async fn call_from_all_nodes_concurrently( &self, method: &str, @@ -403,14 +388,12 @@ impl MpcCluster { impl Drop for MpcCluster { fn drop(&mut self) { - self.kill_all(); + if let Err(e) = self.kill_all() { + tracing::error!(error = %e, "failed to kill all nodes during drop"); + } } } -// --------------------------------------------------------------------------- -// MpcNodeState -// --------------------------------------------------------------------------- - /// A node that is either running or stopped (killed). pub enum MpcNodeState { Running(MpcNode), @@ -440,10 +423,6 @@ impl MpcNodeState { } } -// --------------------------------------------------------------------------- -// Setup helpers (factored out of MpcCluster::start) -// --------------------------------------------------------------------------- - fn create_test_dir(home_base: &Option) -> anyhow::Result { match home_base { Some(base) => { @@ -610,7 +589,9 @@ fn start_mpc_nodes( Ok(nodes) } -async fn create_user_account( +/// Creates user accounts for test interactions. Currently only creates a single +/// default account under SANDBOX_ROOT_ACCOUNT. +async fn create_user_accounts( blockchain: &NearBlockchain, ) -> anyhow::Result> { let key = generate_deterministic_key(200); @@ -623,10 +604,6 @@ async fn create_user_account( Ok(map) } -// --------------------------------------------------------------------------- -// Shared helpers -// --------------------------------------------------------------------------- - fn build_participants( num_nodes: usize, p2p_keys: &[SigningKey], @@ -696,7 +673,7 @@ async fn wait_for_contract_state( Err(e) => tracing::debug!(error = %e, "failed to query contract state (retrying)"), } if tokio::time::Instant::now() >= deadline { - bail!( + anyhow::bail!( "contract state predicate not satisfied within {}s", timeout.as_secs() ); From 771aae545302413eec71e681e835e5b722053728 Mon Sep 17 00:00:00 2001 From: Nodar Date: Wed, 25 Mar 2026 21:22:00 +0100 Subject: [PATCH 3/7] cargo fmt --- crates/e2e-tests/src/cluster.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/e2e-tests/src/cluster.rs b/crates/e2e-tests/src/cluster.rs index 87485440d..103fdecc6 100644 --- a/crates/e2e-tests/src/cluster.rs +++ b/crates/e2e-tests/src/cluster.rs @@ -179,7 +179,10 @@ impl MpcCluster { pub fn kill_nodes(&mut self, indices: &[usize]) -> anyhow::Result<()> { for &idx in indices { if idx >= self.nodes.len() { - anyhow::bail!("node index {idx} out of bounds (have {} nodes)", self.nodes.len()); + anyhow::bail!( + "node index {idx} out of bounds (have {} nodes)", + self.nodes.len() + ); } let state = self.nodes.remove(idx); let new_state = match state { From 8981e2f9eb36d0cc242141c518a08859a7ac0276 Mon Sep 17 00:00:00 2001 From: Nodar Date: Thu, 26 Mar 2026 11:21:07 +0100 Subject: [PATCH 4/7] Address comments --- Cargo.lock | 1 + Makefile.toml | 11 +++--- crates/e2e-tests/Cargo.toml | 1 + crates/e2e-tests/src/cluster.rs | 46 +++++++++++++------------- crates/e2e-tests/src/mpc_node.rs | 11 +++--- scripts/check-sandbox-image-version.sh | 24 ++++++++++++++ 6 files changed, 62 insertions(+), 32 deletions(-) create mode 100755 scripts/check-sandbox-image-version.sh diff --git a/Cargo.lock b/Cargo.lock index b48a33288..1126d352b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3054,6 +3054,7 @@ dependencies = [ "near-kit", "near-mpc-contract-interface", "near-mpc-crypto-types", + "rand_chacha 0.3.1", "reqwest 0.13.2", "serde", "serde_json", diff --git a/Makefile.toml b/Makefile.toml index ad62c3e93..d7bccf7e3 100644 --- a/Makefile.toml +++ b/Makefile.toml @@ -13,6 +13,7 @@ dependencies = [ "cargo-deny-no-advisories", "zizmor", "check-markdown-links", + "sandbox-image-version", "check", "check-docs", "clippy", @@ -21,10 +22,7 @@ dependencies = [ [tasks.check-all] description = "Run all checks" -dependencies = [ - "check-all-fast", - "nextest", -] +dependencies = ["check-all-fast", "nextest"] [tasks.check-extra] description = "Run extra checks" @@ -129,5 +127,10 @@ RUSTDOCFLAGS = "-D warnings" command = "lychee" args = ["--no-progress", "."] +[tasks.sandbox-image-version] +description = "Check that sandbox Docker image version matches nearcore" +command = "bash" +args = ["scripts/check-sandbox-image-version.sh"] + [config] default_to_workspace = false diff --git a/crates/e2e-tests/Cargo.toml b/crates/e2e-tests/Cargo.toml index 241c63028..09753db92 100644 --- a/crates/e2e-tests/Cargo.toml +++ b/crates/e2e-tests/Cargo.toml @@ -13,6 +13,7 @@ hex = { workspace = true } near-kit = { workspace = true } near-mpc-contract-interface = { workspace = true } near-mpc-crypto-types = { workspace = true } +rand_chacha = { workspace = true } reqwest = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/e2e-tests/src/cluster.rs b/crates/e2e-tests/src/cluster.rs index 103fdecc6..365c77a5e 100644 --- a/crates/e2e-tests/src/cluster.rs +++ b/crates/e2e-tests/src/cluster.rs @@ -18,9 +18,11 @@ use crate::mpc_node::{MpcNode, MpcNodeSetup, MpcNodeSetupArgs, NodePorts}; use crate::near_sandbox::NearSandbox; use crate::port_allocator::E2ePortAllocator; -const DEFAULT_SANDBOX_IMAGE: &str = "nearprotocol/sandbox:2.10.7"; +const DEFAULT_SANDBOX_IMAGE: &str = "nearprotocol/sandbox:2.11.0-rc.3"; const SANDBOX_ROOT_ACCOUNT: &str = "sandbox"; const SANDBOX_ROOT_SECRET_KEY: &str = "ed25519:3JoAjwLppjgvxkk6kNsu5wQj3FfUJnpBKWieC73hVTpBeA6FZiCc5tfyZL3a3tHeQJegQe4qGSv8FLsYp7TYd1r6"; +// Polling interval for waiting contract state. +const POLL_INTERVAL: Duration = Duration::from_millis(200); /// Configuration for creating a new [`MpcCluster`]. pub struct MpcClusterConfig { @@ -40,7 +42,7 @@ pub struct MpcClusterConfig { pub triples_to_buffer: usize, /// Presignature buffer size per node. pub presignatures_to_buffer: usize, - /// Docker image for the NEAR sandbox (e.g. `"nearprotocol/sandbox:2.10.7"`). + /// Docker image for the NEAR sandbox (e.g. `"nearprotocol/sandbox:2.11.0-rc.3"`). pub sandbox_image: String, /// Root directory for all test artifacts (logs, configs, DB). If `None`, a temp dir is created. pub home_base: Option, @@ -160,7 +162,7 @@ impl MpcCluster { &ports, )?; - let user_accounts = create_user_accounts(&blockchain).await?; + let user_accounts = create_user_accounts(&blockchain, 1).await?; tracing::info!("MPC cluster is ready"); @@ -441,8 +443,8 @@ fn generate_node_keys(num_nodes: usize) -> (Vec, Vec, Ve let mut near_keys = Vec::new(); let mut p2p_keys = Vec::new(); for i in 0..num_nodes { - let near_key = generate_deterministic_key(i); - let p2p_key = generate_deterministic_key(100 + i); + let near_key = generate_deterministic_key(i as u64); + let p2p_key = generate_deterministic_key(100 + i as u64); node_keys.push(near_key.clone()); near_keys.push(near_key); p2p_keys.push(p2p_key); @@ -592,18 +594,20 @@ fn start_mpc_nodes( Ok(nodes) } -/// Creates user accounts for test interactions. Currently only creates a single -/// default account under SANDBOX_ROOT_ACCOUNT. +/// Creates user accounts for test interactions under SANDBOX_ROOT_ACCOUNT. async fn create_user_accounts( blockchain: &NearBlockchain, + num_accounts: usize, ) -> anyhow::Result> { - let key = generate_deterministic_key(200); - let account: AccountId = format!("user.{SANDBOX_ROOT_ACCOUNT}").parse()?; - blockchain - .create_account(account.as_ref(), 100, &key) - .await?; let mut map = HashMap::new(); - map.insert(account, key); + for i in 0..num_accounts { + let key = generate_deterministic_key(200 + i as u64); + let account: AccountId = format!("user{i}.{SANDBOX_ROOT_ACCOUNT}").parse()?; + blockchain + .create_account(account.as_ref(), 100, &key) + .await?; + map.insert(account, key); + } Ok(map) } @@ -615,7 +619,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().to_bytes()); + let pubkey = near_mpc_crypto_types::Ed25519PublicKey::from(&key.verifying_key()); list.push(( account_id, ParticipantId(i as u32), @@ -653,14 +657,10 @@ fn build_participants_from_nodes( } } -fn generate_deterministic_key(seed: usize) -> SigningKey { - let mut key_bytes = [0u8; 32]; - key_bytes[0] = (seed & 0xFF) as u8; - key_bytes[1] = ((seed >> 8) & 0xFF) as u8; - for (i, byte) in key_bytes.iter_mut().enumerate().skip(2) { - *byte = ((seed * 31 + i * 37) & 0xFF) as u8; - } - SigningKey::from_bytes(&key_bytes) +fn generate_deterministic_key(seed: u64) -> SigningKey { + use rand_chacha::{ChaCha20Rng, rand_core::SeedableRng}; + let mut rng = ChaCha20Rng::seed_from_u64(seed); + SigningKey::generate(&mut rng) } async fn wait_for_contract_state( @@ -681,6 +681,6 @@ async fn wait_for_contract_state( timeout.as_secs() ); } - tokio::time::sleep(Duration::from_millis(200)).await; + tokio::time::sleep(POLL_INTERVAL).await; } } diff --git a/crates/e2e-tests/src/mpc_node.rs b/crates/e2e-tests/src/mpc_node.rs index ccf40f40b..041943395 100644 --- a/crates/e2e-tests/src/mpc_node.rs +++ b/crates/e2e-tests/src/mpc_node.rs @@ -31,6 +31,11 @@ impl MpcNode { self.setup } + /// Kill then start. New process, same config and data directory. + pub fn restart(self) -> anyhow::Result { + self.kill().start() + } + /// Reference to the underlying setup (config, paths, ports). pub fn setup(&self) -> &MpcNodeSetup { &self.setup @@ -165,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().to_bytes(), + &self.p2p_signing_key.verifying_key(), )) } @@ -383,10 +388,6 @@ impl NodePorts { } } -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - // --------------------------------------------------------------------------- // TODO(#2560): Factor `StartConfig` out of `mpc-node` into a lightweight crate so we // can reuse it here instead of duplicating the structure. diff --git a/scripts/check-sandbox-image-version.sh b/scripts/check-sandbox-image-version.sh new file mode 100755 index 000000000..77aeccd65 --- /dev/null +++ b/scripts/check-sandbox-image-version.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +NEARCORE_TOML="libs/nearcore/Cargo.toml" +CLUSTER_RS="crates/e2e-tests/src/cluster.rs" + +# Extract version from nearcore workspace Cargo.toml +NEARCORE_VERSION=$(grep -m1 '^version' "$NEARCORE_TOML" | sed 's/.*"\(.*\)".*/\1/') + +# Extract version from DEFAULT_SANDBOX_IMAGE constant +SANDBOX_VERSION=$(grep '^const DEFAULT_SANDBOX_IMAGE' "$CLUSTER_RS" | sed 's/.*nearprotocol\/sandbox:\([^"]*\).*/\1/') + +if [ "$NEARCORE_VERSION" != "$SANDBOX_VERSION" ]; then + echo "❌ Sandbox image version mismatch" + echo "" + echo " nearcore version: $NEARCORE_VERSION (from $NEARCORE_TOML)" + echo " DEFAULT_SANDBOX_IMAGE: $SANDBOX_VERSION (from $CLUSTER_RS)" + echo "" + echo "Update DEFAULT_SANDBOX_IMAGE in $CLUSTER_RS to match." + exit 1 +fi + +echo "✅ Sandbox image version matches nearcore ($NEARCORE_VERSION)" +exit 0 From df2150cd7f109dc7f2fa068b11c6469d9a35a1ec Mon Sep 17 00:00:00 2001 From: Nodar Date: Thu, 26 Mar 2026 11:33:23 +0100 Subject: [PATCH 5/7] cleanup --- crates/e2e-tests/src/cluster.rs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/crates/e2e-tests/src/cluster.rs b/crates/e2e-tests/src/cluster.rs index 365c77a5e..87c642940 100644 --- a/crates/e2e-tests/src/cluster.rs +++ b/crates/e2e-tests/src/cluster.rs @@ -11,6 +11,7 @@ use near_mpc_contract_interface::types::{ ParticipantInfo, Participants, ProtocolContractState, SignatureScheme, Threshold, ThresholdParameters, }; +use rand_chacha::{ChaCha20Rng, rand_core::SeedableRng}; use serde_json::json; use crate::blockchain::{ClientHandle, DeployedContract, NearBlockchain}; @@ -125,7 +126,8 @@ impl MpcCluster { let contract_key = generate_deterministic_key(255); let contract_account: AccountId = format!("mpc.{SANDBOX_ROOT_ACCOUNT}").parse()?; - let (node_keys, node_near_keys, node_p2p_keys) = generate_node_keys(config.num_nodes); + let (node_keys, node_near_keys, node_p2p_keys) = + generate_node_keys(u64::try_from(config.num_nodes).unwrap()); let contract = deploy_contract( &blockchain, @@ -438,13 +440,13 @@ fn create_test_dir(home_base: &Option) -> anyhow::Result (Vec, Vec, Vec) { +fn generate_node_keys(num_nodes: u64) -> (Vec, Vec, Vec) { let mut node_keys = Vec::new(); let mut near_keys = Vec::new(); let mut p2p_keys = Vec::new(); for i in 0..num_nodes { - let near_key = generate_deterministic_key(i as u64); - let p2p_key = generate_deterministic_key(100 + i as u64); + let near_key = generate_deterministic_key(i); + let p2p_key = generate_deterministic_key(100 + i); node_keys.push(near_key.clone()); near_keys.push(near_key); p2p_keys.push(p2p_key); @@ -597,11 +599,11 @@ fn start_mpc_nodes( /// Creates user accounts for test interactions under SANDBOX_ROOT_ACCOUNT. async fn create_user_accounts( blockchain: &NearBlockchain, - num_accounts: usize, + num_accounts: u64, ) -> anyhow::Result> { let mut map = HashMap::new(); for i in 0..num_accounts { - let key = generate_deterministic_key(200 + i as u64); + let key = generate_deterministic_key(200 + i); let account: AccountId = format!("user{i}.{SANDBOX_ROOT_ACCOUNT}").parse()?; blockchain .create_account(account.as_ref(), 100, &key) @@ -658,7 +660,6 @@ fn build_participants_from_nodes( } fn generate_deterministic_key(seed: u64) -> SigningKey { - use rand_chacha::{ChaCha20Rng, rand_core::SeedableRng}; let mut rng = ChaCha20Rng::seed_from_u64(seed); SigningKey::generate(&mut rng) } From 8284b902634d5e9af173b12d53407f8057905329 Mon Sep 17 00:00:00 2001 From: Nodar Date: Thu, 26 Mar 2026 11:39:25 +0100 Subject: [PATCH 6/7] Check nearcore versions in root Cargo.toml not in submodule --- scripts/check-sandbox-image-version.sh | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/scripts/check-sandbox-image-version.sh b/scripts/check-sandbox-image-version.sh index 77aeccd65..89ab59272 100755 --- a/scripts/check-sandbox-image-version.sh +++ b/scripts/check-sandbox-image-version.sh @@ -1,19 +1,24 @@ #!/usr/bin/env bash set -euo pipefail -NEARCORE_TOML="libs/nearcore/Cargo.toml" +CARGO_TOML="Cargo.toml" CLUSTER_RS="crates/e2e-tests/src/cluster.rs" -# Extract version from nearcore workspace Cargo.toml -NEARCORE_VERSION=$(grep -m1 '^version' "$NEARCORE_TOML" | sed 's/.*"\(.*\)".*/\1/') +# Extract nearcore tag from a near-* dependency in workspace Cargo.toml +NEARCORE_VERSION=$(grep -m1 'git = "https://github.com/near/nearcore", tag' "$CARGO_TOML" | sed 's/.*tag = "\([^"]*\)".*/\1/') # Extract version from DEFAULT_SANDBOX_IMAGE constant SANDBOX_VERSION=$(grep '^const DEFAULT_SANDBOX_IMAGE' "$CLUSTER_RS" | sed 's/.*nearprotocol\/sandbox:\([^"]*\).*/\1/') +if [ -z "$NEARCORE_VERSION" ]; then + echo "❌ Could not extract nearcore tag from $CARGO_TOML" + exit 1 +fi + if [ "$NEARCORE_VERSION" != "$SANDBOX_VERSION" ]; then echo "❌ Sandbox image version mismatch" echo "" - echo " nearcore version: $NEARCORE_VERSION (from $NEARCORE_TOML)" + echo " nearcore tag: $NEARCORE_VERSION (from $CARGO_TOML)" echo " DEFAULT_SANDBOX_IMAGE: $SANDBOX_VERSION (from $CLUSTER_RS)" echo "" echo "Update DEFAULT_SANDBOX_IMAGE in $CLUSTER_RS to match." From d452b78f4a0d38df105dbb601056ae20ae2ca244 Mon Sep 17 00:00:00 2001 From: Nodar Date: Thu, 26 Mar 2026 12:11:54 +0100 Subject: [PATCH 7/7] rand_chacha -> rand --- Cargo.lock | 2 +- crates/e2e-tests/Cargo.toml | 2 +- crates/e2e-tests/src/cluster.rs | 5 +++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1126d352b..399dd6957 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3054,7 +3054,7 @@ dependencies = [ "near-kit", "near-mpc-contract-interface", "near-mpc-crypto-types", - "rand_chacha 0.3.1", + "rand 0.8.5", "reqwest 0.13.2", "serde", "serde_json", diff --git a/crates/e2e-tests/Cargo.toml b/crates/e2e-tests/Cargo.toml index 09753db92..4eb12d897 100644 --- a/crates/e2e-tests/Cargo.toml +++ b/crates/e2e-tests/Cargo.toml @@ -13,7 +13,7 @@ hex = { workspace = true } near-kit = { workspace = true } near-mpc-contract-interface = { workspace = true } near-mpc-crypto-types = { workspace = true } -rand_chacha = { workspace = true } +rand = { workspace = true } reqwest = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/e2e-tests/src/cluster.rs b/crates/e2e-tests/src/cluster.rs index 87c642940..3987d6888 100644 --- a/crates/e2e-tests/src/cluster.rs +++ b/crates/e2e-tests/src/cluster.rs @@ -11,7 +11,8 @@ use near_mpc_contract_interface::types::{ ParticipantInfo, Participants, ProtocolContractState, SignatureScheme, Threshold, ThresholdParameters, }; -use rand_chacha::{ChaCha20Rng, rand_core::SeedableRng}; +use rand::SeedableRng; +use rand::rngs::StdRng; use serde_json::json; use crate::blockchain::{ClientHandle, DeployedContract, NearBlockchain}; @@ -660,7 +661,7 @@ fn build_participants_from_nodes( } fn generate_deterministic_key(seed: u64) -> SigningKey { - let mut rng = ChaCha20Rng::seed_from_u64(seed); + let mut rng = StdRng::seed_from_u64(seed); SigningKey::generate(&mut rng) }