Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 71 additions & 12 deletions crates/e2e-tests/src/foreign_chain_mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,62 @@
//! and Starknet JSON-RPC endpoints, returning hardcoded responses for the methods
//! the MPC nodes call during `verify_foreign_transaction`.

use httpmock::When;
use httpmock::prelude::*;
use httpmock::{HttpMockRequest, HttpMockResponse};

/// Client credentials a mock server requires, mirroring the node's
/// [`AuthConfig`](mpc_node_config::AuthConfig) kinds. Requests without the
/// expected credentials are answered with 401 like real providers do, so
/// verification can only succeed if the node applied the configured
/// authentication.
Comment thread
haiyuechen-nearone marked this conversation as resolved.
Outdated
#[derive(Clone, Debug)]
pub enum MockAuthExpectation {
None,
ApiKeyInPath { key: String },
Header { name: String, value: String },
QueryParam { name: String, value: String },
}

impl MockAuthExpectation {
fn apply(&self, when: When) -> When {
match self {
Self::None => when.path("/"),
Self::ApiKeyInPath { key } => when.path(format!("/{key}")),
Self::Header { name, value } => when.path("/").header(name, value),
Self::QueryParam { name, value } => when.path("/").query_param(name, value),
}
}
}
Comment on lines +21 to +30

/// Rejects requests missing the expected credentials the way real providers
/// do.
/// Must be registered AFTER the credentialed mock — httpmock picks the
/// first-registered matching mock, so authenticated requests keep hitting it.
Comment thread
haiyuechen-nearone marked this conversation as resolved.
Outdated
fn register_unauthorized_catch_all(server: &MockServer, auth: &MockAuthExpectation) {
if matches!(auth, MockAuthExpectation::None) {
return;
}
server.mock(|when, then| {
when.any_request();
then.respond_with(move |req: &HttpMockRequest| {
let id = serde_json::from_slice::<serde_json::Value>(req.body().as_ref())
.map(|body| body["id"].clone())
.unwrap_or(serde_json::Value::Null);
let response_body = serde_json::json!({
"jsonrpc": "2.0",
"id": id,
"error": { "code": -32600, "message": "Must be authenticated!" },
});
HttpMockResponse::builder()
.status(401)
.header("content-type", "application/json")
.body(serde_json::to_string(&response_body).unwrap())
.build()
});
});
}

/// A [`MockServer`] paired with the id of a registered [`httpmock::Mock`], so
/// tests can recover the `Mock<'_>` (which borrows from the server) on demand
/// and read its hit count. Storing the `Mock` directly would make this struct
Expand Down Expand Up @@ -47,10 +100,10 @@ fn jsonrpc_error(id: serde_json::Value, method: &str) -> HttpMockResponse {
.build()
}

pub fn setup_bitcoin_mock(server: &MockServer) -> usize {
server
pub fn setup_bitcoin_mock(server: &MockServer, auth: MockAuthExpectation) -> usize {
let mock_id = server
.mock(|when, then| {
when.method(POST).path("/");
auth.apply(when.method(POST));
then.respond_with(move |req: &HttpMockRequest| {
let body: serde_json::Value =
serde_json::from_slice(req.body().as_ref()).expect("valid json-rpc request");
Expand Down Expand Up @@ -83,12 +136,14 @@ pub fn setup_bitcoin_mock(server: &MockServer) -> usize {
.build()
});
})
.id
.id;
register_unauthorized_catch_all(server, &auth);
mock_id
}

pub fn setup_evm_mock(server: &MockServer) -> usize {
server.mock(|when, then| {
when.method(POST).path("/");
pub fn setup_evm_mock(server: &MockServer, auth: MockAuthExpectation) -> usize {
let mock_id = server.mock(|when, then| {
auth.apply(when.method(POST));
then.respond_with(move |req: &HttpMockRequest| {
let body: serde_json::Value =
serde_json::from_slice(req.body().as_ref()).expect("valid json-rpc request");
Expand Down Expand Up @@ -155,13 +210,15 @@ pub fn setup_evm_mock(server: &MockServer) -> usize {
.build()
});
})
.id
.id;
register_unauthorized_catch_all(server, &auth);
mock_id
}

pub fn setup_starknet_mock(server: &MockServer) -> usize {
server
pub fn setup_starknet_mock(server: &MockServer, auth: MockAuthExpectation) -> usize {
let mock_id = server
.mock(|when, then| {
when.method(POST).path("/");
auth.apply(when.method(POST));
then.respond_with(move |req: &HttpMockRequest| {
let body: serde_json::Value =
serde_json::from_slice(req.body().as_ref()).expect("valid json-rpc request");
Expand Down Expand Up @@ -190,7 +247,9 @@ pub fn setup_starknet_mock(server: &MockServer) -> usize {
.build()
});
})
.id
.id;
register_unauthorized_catch_all(server, &auth);
mock_id
}

fn starknet_receipt_result() -> serde_json::Value {
Expand Down
104 changes: 87 additions & 17 deletions crates/e2e-tests/tests/foreign_chain_tx_validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ use anyhow::{Context, bail};
use backon::{ConstantBuilder, Retryable};
use e2e_tests::CLUSTER_WAIT_TIMEOUT;
use e2e_tests::foreign_chain_mock::{
MockServerExt, setup_bitcoin_mock, setup_evm_mock, setup_starknet_mock,
MockAuthExpectation, MockServerExt, setup_bitcoin_mock, setup_evm_mock, setup_starknet_mock,
};
use httpmock::prelude::*;
use mpc_node_config::{ForeignChainConfig, ForeignChainProviderConfig, ForeignChainsConfig};
use mpc_node_config::{
AuthConfig, ForeignChainConfig, ForeignChainProviderConfig, ForeignChainsConfig, TokenConfig,
};
use near_mpc_bounded_collections::NonEmptyBTreeMap;
use near_mpc_contract_interface::types::{
BitcoinExtractor, BitcoinRpcRequest, BitcoinTxId, BlockConfirmations, DomainConfig, DomainId,
Expand All @@ -19,13 +21,25 @@ use near_mpc_contract_interface::types::{
VerifyForeignTransactionRequestArgs,
};

/// One chain per credential-carrying `AuthConfig` kind: Bitcoin uses `path`,
/// Base `header`, BNB `query`; the remaining chains use `None`.
const PATH_AUTH_PLACEHOLDER: &str = "{api_key}";
const PATH_AUTH_API_KEY: &str = "bitcoin-path-api-key";
const HEADER_AUTH_SCHEME: &str = "Bearer";
const HEADER_AUTH_TOKEN: &str = "base-bearer-token";
const QUERY_AUTH_PARAM: &str = "apikey";
const QUERY_AUTH_TOKEN: &str = "bnb-query-token";

struct ForeignTxTestEnv {
cluster: e2e_tests::MpcCluster,
foreign_tx_domain_id: DomainId,
_mock_servers: Vec<MockServer>,
/// Polygon is configured with multiple RPC providers so the test can verify
/// that `FanOut` queries every one of them.
polygon_mocks: Vec<MockServerExt>,
bitcoin_mock: MockServerExt,
base_mock: MockServerExt,
bnb_mock: MockServerExt,
}

struct MockServerUrls {
Expand All @@ -48,7 +62,12 @@ fn build_foreign_chains_config(urls: &MockServerUrls) -> ForeignChainsConfig {
"mock".to_string().into(),
ForeignChainProviderConfig {
rpc_url: urls.bitcoin.clone(),
auth: Default::default(),
auth: AuthConfig::Path {
placeholder: PATH_AUTH_PLACEHOLDER.to_string(),
token: TokenConfig::Val {
val: PATH_AUTH_API_KEY.to_string(),
},
},
},
),
}),
Expand All @@ -70,7 +89,12 @@ fn build_foreign_chains_config(urls: &MockServerUrls) -> ForeignChainsConfig {
"mock".to_string().into(),
ForeignChainProviderConfig {
rpc_url: urls.bnb.clone(),
auth: Default::default(),
auth: AuthConfig::Query {
name: QUERY_AUTH_PARAM.to_string(),
token: TokenConfig::Val {
val: QUERY_AUTH_TOKEN.to_string(),
},
},
},
),
}),
Expand All @@ -92,7 +116,13 @@ fn build_foreign_chains_config(urls: &MockServerUrls) -> ForeignChainsConfig {
"mock".to_string().into(),
ForeignChainProviderConfig {
rpc_url: urls.base.clone(),
auth: Default::default(),
auth: AuthConfig::Header {
name: "authorization".parse().expect("valid header name"),
scheme: Some(HEADER_AUTH_SCHEME.to_string()),
token: TokenConfig::Val {
val: HEADER_AUTH_TOKEN.to_string(),
},
},
},
),
}),
Expand Down Expand Up @@ -158,26 +188,45 @@ async fn setup_foreign_tx_cluster() -> anyhow::Result<ForeignTxTestEnv> {
let arbitrum_server = MockServer::start();
let hyper_evm_server = MockServer::start();

setup_bitcoin_mock(&bitcoin_server);
setup_evm_mock(&abstract_server);
setup_evm_mock(&bnb_server);
setup_starknet_mock(&starknet_server);
setup_evm_mock(&base_server);
setup_evm_mock(&arbitrum_server);
setup_evm_mock(&hyper_evm_server);
let bitcoin_mock_id = setup_bitcoin_mock(
&bitcoin_server,
MockAuthExpectation::ApiKeyInPath {
key: PATH_AUTH_API_KEY.to_string(),
},
);
let base_mock_id = setup_evm_mock(
&base_server,
MockAuthExpectation::Header {
name: "authorization".to_string(),
value: format!("{HEADER_AUTH_SCHEME} {HEADER_AUTH_TOKEN}"),
},
);
let bnb_mock_id = setup_evm_mock(
&bnb_server,
MockAuthExpectation::QueryParam {
name: QUERY_AUTH_PARAM.to_string(),
value: QUERY_AUTH_TOKEN.to_string(),
},
);
setup_evm_mock(&abstract_server, MockAuthExpectation::None);
setup_starknet_mock(&starknet_server, MockAuthExpectation::None);
setup_evm_mock(&arbitrum_server, MockAuthExpectation::None);
setup_evm_mock(&hyper_evm_server, MockAuthExpectation::None);

// Polygon is configured with three RPC providers so the test can assert
// that `FanOut` queries every one of them.
let polygon_mocks: Vec<MockServerExt> = (0..3)
.map(|_| {
let server = MockServer::start();
let mock_id = setup_evm_mock(&server);
let mock_id = setup_evm_mock(&server, MockAuthExpectation::None);
MockServerExt::new(server, mock_id)
})
.collect();

let urls = MockServerUrls {
bitcoin: bitcoin_server.url("/"),
// The configured URL carries the literal placeholder; the node must
// substitute the API key into it before any request can match the mock.
bitcoin: bitcoin_server.url(format!("/{PATH_AUTH_PLACEHOLDER}")),
abstract_chain: abstract_server.url("/"),
bnb: bnb_server.url("/"),
starknet: starknet_server.url("/"),
Expand All @@ -187,12 +236,12 @@ async fn setup_foreign_tx_cluster() -> anyhow::Result<ForeignTxTestEnv> {
polygon: polygon_mocks.iter().map(|m| m.server.url("/")).collect(),
};

let bitcoin_mock = MockServerExt::new(bitcoin_server, bitcoin_mock_id);
let base_mock = MockServerExt::new(base_server, base_mock_id);
let bnb_mock = MockServerExt::new(bnb_server, bnb_mock_id);
let mock_servers = vec![
bitcoin_server,
abstract_server,
bnb_server,
starknet_server,
base_server,
arbitrum_server,
hyper_evm_server,
];
Expand Down Expand Up @@ -272,6 +321,9 @@ async fn setup_foreign_tx_cluster() -> anyhow::Result<ForeignTxTestEnv> {
foreign_tx_domain_id,
_mock_servers: mock_servers,
polygon_mocks,
bitcoin_mock,
base_mock,
bnb_mock,
})
}

Expand Down Expand Up @@ -443,6 +495,17 @@ async fn verify_hyper_evm(env: &ForeignTxTestEnv) -> anyhow::Result<()> {
verify_foreign_tx_response(&outcome)
}

/// A successful verification implies the credentialed mock answered,
/// so this is a backstop against the mock setup being loosened to answer unauthenticated requests.
fn assert_authenticated_provider_was_queried(mock: &MockServerExt, provider: &str) {
let calls = mock.calls();
assert!(
calls > 0,
"the {provider} mock was never hit with the expected credentials; \
expected >= 1 matching RPC request, got {calls}"
);
}

/// Verifies that every Polygon RPC provider configured in the fan-out received
/// at least one HTTP request during the preceding `verify_polygon` call.
///
Expand Down Expand Up @@ -482,6 +545,10 @@ async fn verify_polygon(env: &ForeignTxTestEnv) -> anyhow::Result<()> {
/// BNB, Base, Starknet, Arbitrum, HyperEVM, and Polygon and verifies the MPC
/// nodes return valid signed responses. Also verifies rejection for unsupported
/// chains and non-existent domains.
///
/// Bitcoin, Base and BNB providers require authentication (one per
/// credential-carrying `AuthConfig` kind), so the test also proves the node
/// applies configured RPC credentials end to end.
Comment thread
haiyuechen-nearone marked this conversation as resolved.
Outdated
#[tokio::test]
#[expect(non_snake_case)]
async fn verify_foreign_transaction__should_sign_all_supported_chains() {
Expand All @@ -495,11 +562,14 @@ async fn verify_foreign_transaction__should_sign_all_supported_chains() {
verify_bitcoin(&env)
.await
.expect("bitcoin verification failed");
assert_authenticated_provider_was_queried(&env.bitcoin_mock, "bitcoin (path auth)");
verify_abstract(&env)
.await
.expect("abstract verification failed");
verify_bnb(&env).await.expect("bnb verification failed");
assert_authenticated_provider_was_queried(&env.bnb_mock, "bnb (query auth)");
verify_base(&env).await.expect("base verification failed");
assert_authenticated_provider_was_queried(&env.base_mock, "base (header auth)");
verify_starknet(&env)
.await
.expect("starknet verification failed");
Expand Down
Loading