diff --git a/Cargo.lock b/Cargo.lock index 4011d92fa92..c6784ae761f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5480,6 +5480,7 @@ dependencies = [ "metrics-derive", "ntest", "parity-scale-codec", + "proptest", "serde", "sp-core", "tokio", diff --git a/ethexe/rpc/Cargo.toml b/ethexe/rpc/Cargo.toml index 7398dc0ef79..2f1c3e3a1b7 100644 --- a/ethexe/rpc/Cargo.toml +++ b/ethexe/rpc/Cargo.toml @@ -36,6 +36,7 @@ gear-workspace-hack.workspace = true jsonrpsee = { workspace = true, features = ["client"] } ethexe-common = { workspace = true, features = ["std", "mock"] } ntest.workspace = true +proptest.workspace = true tracing-subscriber.workspace = true ethexe-db = {workspace = true, features = ["mock"]} diff --git a/ethexe/rpc/tests/blackbox.rs b/ethexe/rpc/tests/blackbox.rs new file mode 100644 index 00000000000..4113da22f86 --- /dev/null +++ b/ethexe/rpc/tests/blackbox.rs @@ -0,0 +1,465 @@ +// This file is part of Gear. +// +// Copyright (C) 2026 Gear Technologies Inc. +// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +use ethexe_common::{ + HashOf, + db::{CodesStorageRW, InjectedStorageRW}, + ecdsa::PrivateKey, + gear::MAX_BLOCK_GAS_LIMIT, + injected::{ + AddressedInjectedTransaction, InjectedTransaction, InjectedTransactionAcceptance, Promise, + SignedInjectedTransaction, SignedPromise, + }, + mock::Mock, +}; +use ethexe_db::Database; +use ethexe_rpc::{RpcConfig, RpcEvent, RpcServer, RpcService}; +use futures::StreamExt; +use gear_core::{ + code::{InstantiatedSectionSizes, InstrumentedCode}, + message::{ReplyCode, SuccessReplyReason}, + rpc::ReplyInfo, +}; +use gprimitives::H256; +use jsonrpsee::{ + core::client::{ClientT, SubscriptionClientT}, + http_client::{HttpClient, HttpClientBuilder}, + rpc_params, + server::ServerHandle, + ws_client::{WsClient, WsClientBuilder}, +}; +use parity_scale_codec::Encode; +use proptest::{ + collection, + prelude::{Just, Strategy, any}, + prop_assert_eq, + test_runner::{Config as ProptestConfig, FileFailurePersistence, TestRunner}, +}; +use sp_core::Bytes; +use std::{ + net::{Ipv4Addr, SocketAddr, TcpListener}, + time::Duration, +}; +use tokio::task::JoinHandle; + +struct BlackBoxRpc { + addr: SocketAddr, + handle: ServerHandle, + service: Option, + service_task: Option>, +} + +impl BlackBoxRpc { + async fn start() -> Self { + Self::start_with_db(Database::memory()).await + } + + async fn start_with_db(db: Database) -> Self { + let addr = unused_local_addr(); + let config = RpcConfig { + listen_addr: addr, + cors: None, + gas_allowance: MAX_BLOCK_GAS_LIMIT, + chunk_size: 2, + with_dev_api: false, + }; + + let (handle, service) = RpcServer::new(config, db) + .run_server() + .await + .expect("RPC server must start"); + + Self { + addr, + handle, + service: Some(service), + service_task: None, + } + } + + fn http_url(&self) -> String { + format!("http://{}", self.addr) + } + + fn ws_url(&self) -> String { + format!("ws://{}", self.addr) + } + + fn spawn_accepting_service(&mut self) { + let mut service = self + .service + .take() + .expect("service can only be spawned once"); + + self.service_task = Some(tokio::spawn(async move { + while let Some(event) = service.next().await { + let RpcEvent::InjectedTransaction { + transaction, + response_sender, + } = event; + + response_sender + .send(InjectedTransactionAcceptance::Accept) + .expect("RPC response receiver must be alive"); + + let promise = promise_for(transaction); + + // The RPC method registers its promise waiter after the service accepts + // the transaction, so publish from the next task turn. + tokio::time::sleep(Duration::from_millis(10)).await; + service.provide_promise(promise); + } + })); + } + + fn stop(self) { + self.handle.stop().expect("RPC server must stop"); + if let Some(service_task) = self.service_task { + service_task.abort(); + } + } +} + +fn unused_local_addr() -> SocketAddr { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) + .expect("ephemeral localhost port must be available"); + listener + .local_addr() + .expect("ephemeral localhost address must be available") +} + +fn promise_for(transaction: AddressedInjectedTransaction) -> SignedPromise { + let promise = Promise { + tx_hash: transaction.tx.data().to_hash(), + reply: ReplyInfo { + payload: Vec::new(), + value: 0, + code: ReplyCode::Success(SuccessReplyReason::Manual), + }, + }; + + SignedPromise::create(PrivateKey::random(), promise).expect("promise signing must succeed") +} + +async fn http_client(rpc: &BlackBoxRpc) -> HttpClient { + HttpClientBuilder::default() + .build(rpc.http_url()) + .expect("HTTP client must connect") +} + +async fn ws_client(rpc: &BlackBoxRpc) -> WsClient { + WsClientBuilder::default() + .build(rpc.ws_url()) + .await + .expect("WS client must connect") +} + +#[tokio::test] +#[ntest::timeout(30_000)] +async fn original_code_request_returns_stored_code_to_http_client() { + let db = Database::memory(); + let code = [0, b'a', b's', b'm', 1, 0, 0, 0]; + let code_id = db.set_original_code(&code); + let rpc = BlackBoxRpc::start_with_db(db).await; + let client = http_client(&rpc).await; + + let returned_code = client + .request::("code_getOriginal", rpc_params![H256::from(code_id)]) + .await + .expect("stored original code must be returned"); + + assert_eq!(returned_code.0, code.to_vec().encode()); + + rpc.stop(); +} + +#[tokio::test] +#[ntest::timeout(30_000)] +async fn instrumented_code_request_returns_stored_code_to_http_client() { + let db = Database::memory(); + let runtime_id = 7; + let code_id = db.set_original_code(b"original"); + let instrumented = InstrumentedCode::new( + vec![1, 2, 3, 4], + InstantiatedSectionSizes::new(10, 20, 30, 40, 50, 60), + ); + db.set_instrumented_code(runtime_id, code_id, instrumented.clone()); + + let rpc = BlackBoxRpc::start_with_db(db).await; + let client = http_client(&rpc).await; + + let returned_code = client + .request::( + "code_getInstrumented", + rpc_params![runtime_id, H256::from(code_id)], + ) + .await + .expect("stored instrumented code must be returned"); + + assert_eq!(returned_code.0, instrumented.encode()); + + rpc.stop(); +} + +#[tokio::test] +#[ntest::timeout(30_000)] +async fn missing_code_request_returns_public_json_rpc_error() { + let rpc = BlackBoxRpc::start().await; + let client = http_client(&rpc).await; + + let error = client + .request::, _>("code_getOriginal", rpc_params![H256::zero()]) + .await + .expect_err("missing code must be reported as a JSON-RPC error"); + + assert!( + error + .to_string() + .contains("Failed to get code by supplied id"), + "unexpected error: {error}" + ); + + rpc.stop(); +} + +#[tokio::test] +#[ntest::timeout(30_000)] +async fn send_transaction_returns_acceptance_to_http_client() { + let mut rpc = BlackBoxRpc::start().await; + rpc.spawn_accepting_service(); + + let client = http_client(&rpc).await; + let acceptance = client + .request::( + "injected_sendTransaction", + rpc_params![AddressedInjectedTransaction::mock(())], + ) + .await + .expect("accepted transaction must return a client-visible response"); + + assert_eq!(acceptance, InjectedTransactionAcceptance::Accept); + + rpc.stop(); +} + +#[tokio::test] +#[ntest::timeout(30_000)] +async fn send_transaction_and_watch_yields_promise_to_ws_client() { + let mut rpc = BlackBoxRpc::start().await; + rpc.spawn_accepting_service(); + + let client = ws_client(&rpc).await; + let transaction = AddressedInjectedTransaction::mock(()); + let expected_tx_hash = transaction.tx.data().to_hash(); + + let mut subscription = client + .subscribe::( + "injected_sendTransactionAndWatch", + rpc_params![transaction], + "injected_sendTransactionAndWatchUnsubscribe", + ) + .await + .expect("subscription must be created"); + + let promise = subscription + .next() + .await + .expect("subscription must yield a promise") + .expect("promise item must be valid"); + + assert_eq!(promise.data().tx_hash, expected_tx_hash); + assert_eq!( + promise.data().reply.code, + ReplyCode::Success(SuccessReplyReason::Manual) + ); + + rpc.stop(); +} + +#[tokio::test] +#[ntest::timeout(30_000)] +async fn get_transactions_returns_stored_signed_transaction_to_http_client() { + let db = Database::memory(); + let transaction = AddressedInjectedTransaction::mock(()).tx; + let tx_hash = transaction.data().to_hash(); + db.set_injected_transaction(transaction.clone()); + + let rpc = BlackBoxRpc::start_with_db(db).await; + let client = http_client(&rpc).await; + + let transactions = client + .request::>, _>( + "injected_getTransactions", + rpc_params![vec![tx_hash]], + ) + .await + .expect("stored injected transaction must be returned"); + + assert_eq!(transactions, vec![Some(transaction)]); + + rpc.stop(); +} + +#[tokio::test] +#[ntest::timeout(30_000)] +async fn get_transactions_preserves_missing_entries_to_http_client() { + let rpc = BlackBoxRpc::start().await; + let client = http_client(&rpc).await; + let missing_hash = unsafe { HashOf::::new(H256::repeat_byte(1)) }; + + let transactions = client + .request::>, _>( + "injected_getTransactions", + rpc_params![vec![missing_hash]], + ) + .await + .expect("missing transaction lookup must return a successful response"); + + assert_eq!(transactions, vec![None]); + + rpc.stop(); +} + +#[tokio::test] +#[ntest::timeout(30_000)] +async fn get_transactions_preserves_requested_order_hits_misses_and_duplicates() { + let handle = tokio::runtime::Handle::current(); + + tokio::task::spawn_blocking(move || { + let mut runner = TestRunner::new(ProptestConfig { + cases: 16, + failure_persistence: Some(Box::new(FileFailurePersistence::Off)), + ..ProptestConfig::default() + }); + + let strategy = collection::vec(any::(), 0..=32); + + runner + .run(&strategy, |is_stored_flags| { + handle.block_on(async { + let db = Database::memory(); + let stored_transactions: Vec<_> = (0..3) + .map(|_| AddressedInjectedTransaction::mock(()).tx) + .collect(); + let stored_hashes: Vec<_> = stored_transactions + .iter() + .map(|transaction| transaction.data().to_hash()) + .collect(); + + for transaction in &stored_transactions { + db.set_injected_transaction(transaction.clone()); + } + + let missing_hashes = [ + unsafe { HashOf::::new(H256::repeat_byte(1)) }, + unsafe { HashOf::::new(H256::repeat_byte(2)) }, + unsafe { HashOf::::new(H256::repeat_byte(3)) }, + ]; + + let requested_hashes: Vec<_> = is_stored_flags + .iter() + .enumerate() + .map(|(index, is_stored)| { + if *is_stored { + stored_hashes[index % stored_hashes.len()] + } else { + missing_hashes[index % missing_hashes.len()] + } + }) + .collect(); + + let expected: Vec<_> = is_stored_flags + .iter() + .enumerate() + .map(|(index, is_stored)| { + is_stored.then(|| { + stored_transactions[index % stored_transactions.len()].clone() + }) + }) + .collect(); + + let rpc = BlackBoxRpc::start_with_db(db).await; + let client = http_client(&rpc).await; + + let transactions = client + .request::>, _>( + "injected_getTransactions", + rpc_params![requested_hashes], + ) + .await + .expect("mixed transaction lookup must return a successful response"); + + rpc.stop(); + + prop_assert_eq!(transactions, expected); + + Ok(()) + }) + }) + .expect("generated transaction lookup cases must satisfy the public RPC contract"); + }) + .await + .expect("blocking proptest runner must not panic"); +} + +#[tokio::test] +#[ntest::timeout(30_000)] +async fn get_transactions_accepts_up_to_public_limit() { + let handle = tokio::runtime::Handle::current(); + + tokio::task::spawn_blocking(move || { + let mut runner = TestRunner::new(ProptestConfig { + cases: 8, + failure_persistence: Some(Box::new(FileFailurePersistence::Off)), + ..ProptestConfig::default() + }); + + let missing_hash = unsafe { HashOf::::new(H256::repeat_byte(4)) }; + let strategy = (Just(missing_hash), 0usize..=100).prop_map(|(hash, len)| vec![hash; len]); + + runner + .run(&strategy, |requested_hashes| { + handle.block_on(async { + let expected = vec![None::; requested_hashes.len()]; + let rpc = BlackBoxRpc::start().await; + let client = http_client(&rpc).await; + + let transactions = client + .request::>, _>( + "injected_getTransactions", + rpc_params![requested_hashes], + ) + .await + .expect( + "requests at or below the public transaction lookup limit must succeed", + ); + + rpc.stop(); + + prop_assert_eq!(transactions, expected); + + Ok(()) + }) + }) + .expect( + "generated in-limit transaction lookup cases must satisfy the public RPC contract", + ); + }) + .await + .expect("blocking proptest runner must not panic"); +} diff --git a/utils/gear-workspace-hack/Cargo.toml b/utils/gear-workspace-hack/Cargo.toml index fad54de2c84..d148da49993 100644 --- a/utils/gear-workspace-hack/Cargo.toml +++ b/utils/gear-workspace-hack/Cargo.toml @@ -214,7 +214,7 @@ branch = "gear-polkadot-stable2409-wasm32v1-none" features = ["test-helpers"] ### BEGIN HAKARI SECTION -[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +[dependencies] aes = { version = "0.8", default-features = false, features = ["zeroize"] } ahash = { version = "0.8" } alloy = { version = "2", features = ["kzg", "node-bindings", "provider-anvil-api", "provider-ws", "rpc-types-beacon", "rpc-types-eth", "signer-mnemonic"] } @@ -486,7 +486,7 @@ wasmtime-runtime = { version = "8", default-features = false, features = ["async winnow = { version = "0.7" } zeroize = { version = "1", features = ["derive", "std"] } -[target.'cfg(not(target_arch = "wasm32"))'.build-dependencies] +[build-dependencies] aes = { version = "0.8", default-features = false, features = ["zeroize"] } ahash = { version = "0.8" } alloy = { version = "2", features = ["kzg", "node-bindings", "provider-anvil-api", "provider-ws", "rpc-types-beacon", "rpc-types-eth", "signer-mnemonic"] }