Skip to content

Commit e577de2

Browse files
test: cover all foreign provider auth kinds in verify-foreign-tx e2e (#3759)
1 parent e0ec710 commit e577de2

2 files changed

Lines changed: 154 additions & 34 deletions

File tree

crates/e2e-tests/src/foreign_chain_mock.rs

Lines changed: 66 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,57 @@
44
//! and Starknet JSON-RPC endpoints, returning hardcoded responses for the methods
55
//! the MPC nodes call during `verify_foreign_transaction`.
66
7+
use httpmock::When;
78
use httpmock::prelude::*;
89
use httpmock::{HttpMockRequest, HttpMockResponse};
910

11+
/// Client credentials a mock server requires, mirroring the node's
12+
/// [`AuthConfig`](mpc_node_config::AuthConfig) kinds.
13+
#[derive(Clone, Debug)]
14+
pub enum MockAuthExpectation {
15+
None,
16+
ApiKeyInPath { key: String },
17+
Header { name: String, value: String },
18+
QueryParam { name: String, value: String },
19+
}
20+
21+
impl MockAuthExpectation {
22+
fn apply(&self, when: When) -> When {
23+
match self {
24+
Self::None => when.path("/"),
25+
Self::ApiKeyInPath { key } => when.path(format!("/{key}")),
26+
Self::Header { name, value } => when.path("/").header(name, value),
27+
Self::QueryParam { name, value } => when.path("/").query_param(name, value),
28+
}
29+
}
30+
}
31+
32+
/// Rejects requests missing the expected credentials the way real providers
33+
/// do.
34+
fn register_unauthorized_catch_all(server: &MockServer, auth: &MockAuthExpectation) {
35+
if matches!(auth, MockAuthExpectation::None) {
36+
return;
37+
}
38+
server.mock(|when, then| {
39+
when.any_request();
40+
then.respond_with(move |req: &HttpMockRequest| {
41+
let id = serde_json::from_slice::<serde_json::Value>(req.body().as_ref())
42+
.map(|body| body["id"].clone())
43+
.unwrap_or(serde_json::Value::Null);
44+
let response_body = serde_json::json!({
45+
"jsonrpc": "2.0",
46+
"id": id,
47+
"error": { "code": -32600, "message": "Must be authenticated!" },
48+
});
49+
HttpMockResponse::builder()
50+
.status(401)
51+
.header("content-type", "application/json")
52+
.body(serde_json::to_string(&response_body).unwrap())
53+
.build()
54+
});
55+
});
56+
}
57+
1058
/// A [`MockServer`] paired with the id of a registered [`httpmock::Mock`], so
1159
/// tests can recover the `Mock<'_>` (which borrows from the server) on demand
1260
/// and read its hit count. Storing the `Mock` directly would make this struct
@@ -47,10 +95,10 @@ fn jsonrpc_error(id: serde_json::Value, method: &str) -> HttpMockResponse {
4795
.build()
4896
}
4997

50-
pub fn setup_bitcoin_mock(server: &MockServer) -> usize {
51-
server
98+
pub fn setup_bitcoin_mock(server: &MockServer, auth: MockAuthExpectation) -> usize {
99+
let mock_id = server
52100
.mock(|when, then| {
53-
when.method(POST).path("/");
101+
auth.apply(when.method(POST));
54102
then.respond_with(move |req: &HttpMockRequest| {
55103
let body: serde_json::Value =
56104
serde_json::from_slice(req.body().as_ref()).expect("valid json-rpc request");
@@ -83,12 +131,14 @@ pub fn setup_bitcoin_mock(server: &MockServer) -> usize {
83131
.build()
84132
});
85133
})
86-
.id
134+
.id;
135+
register_unauthorized_catch_all(server, &auth);
136+
mock_id
87137
}
88138

89-
pub fn setup_evm_mock(server: &MockServer) -> usize {
90-
server.mock(|when, then| {
91-
when.method(POST).path("/");
139+
pub fn setup_evm_mock(server: &MockServer, auth: MockAuthExpectation) -> usize {
140+
let mock_id = server.mock(|when, then| {
141+
auth.apply(when.method(POST));
92142
then.respond_with(move |req: &HttpMockRequest| {
93143
let body: serde_json::Value =
94144
serde_json::from_slice(req.body().as_ref()).expect("valid json-rpc request");
@@ -160,13 +210,15 @@ pub fn setup_evm_mock(server: &MockServer) -> usize {
160210
.build()
161211
});
162212
})
163-
.id
213+
.id;
214+
register_unauthorized_catch_all(server, &auth);
215+
mock_id
164216
}
165217

166-
pub fn setup_starknet_mock(server: &MockServer) -> usize {
167-
server
218+
pub fn setup_starknet_mock(server: &MockServer, auth: MockAuthExpectation) -> usize {
219+
let mock_id = server
168220
.mock(|when, then| {
169-
when.method(POST).path("/");
221+
auth.apply(when.method(POST));
170222
then.respond_with(move |req: &HttpMockRequest| {
171223
let body: serde_json::Value =
172224
serde_json::from_slice(req.body().as_ref()).expect("valid json-rpc request");
@@ -195,7 +247,9 @@ pub fn setup_starknet_mock(server: &MockServer) -> usize {
195247
.build()
196248
});
197249
})
198-
.id
250+
.id;
251+
register_unauthorized_catch_all(server, &auth);
252+
mock_id
199253
}
200254

201255
fn starknet_receipt_result() -> serde_json::Value {

crates/e2e-tests/tests/foreign_chain_tx_validation.rs

Lines changed: 88 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@ use anyhow::{Context, bail};
66
use backon::{ConstantBuilder, Retryable};
77
use e2e_tests::CLUSTER_WAIT_TIMEOUT;
88
use e2e_tests::foreign_chain_mock::{
9-
MockServerExt, setup_bitcoin_mock, setup_evm_mock, setup_starknet_mock,
9+
MockAuthExpectation, MockServerExt, setup_bitcoin_mock, setup_evm_mock, setup_starknet_mock,
1010
};
1111
use httpmock::prelude::*;
12-
use mpc_node_config::{ForeignChainConfig, ForeignChainProviderConfig, ForeignChainsConfig};
12+
use mpc_node_config::{
13+
AuthConfig, ForeignChainConfig, ForeignChainProviderConfig, ForeignChainsConfig, TokenConfig,
14+
};
1315
use near_mpc_bounded_collections::NonEmptyBTreeMap;
1416
use near_mpc_contract_interface::types::{
1517
BitcoinExtractor, BitcoinRpcRequest, BitcoinTxId, BlockConfirmations, DomainConfig, DomainId,
@@ -19,13 +21,26 @@ use near_mpc_contract_interface::types::{
1921
VerifyForeignTransactionRequestArgs,
2022
};
2123

24+
/// One chain per credential-carrying [`AuthConfig`] kind: Bitcoin uses `path`,
25+
/// Base `header`, BNB `query`; the remaining chains use `None`.
26+
const PATH_AUTH_PLACEHOLDER: &str = "{api_key}";
27+
const PATH_AUTH_API_KEY: &str = "bitcoin-path-api-key";
28+
const HEADER_AUTH_NAME: &str = "authorization";
29+
const HEADER_AUTH_SCHEME: &str = "Bearer";
30+
const HEADER_AUTH_TOKEN: &str = "base-bearer-token";
31+
const QUERY_AUTH_PARAM: &str = "apikey";
32+
const QUERY_AUTH_TOKEN: &str = "bnb-query-token";
33+
2234
struct ForeignTxTestEnv {
2335
cluster: e2e_tests::MpcCluster,
2436
foreign_tx_domain_id: DomainId,
2537
_mock_servers: Vec<MockServer>,
2638
/// Polygon is configured with multiple RPC providers so the test can verify
2739
/// that `FanOut` queries every one of them.
2840
polygon_mocks: Vec<MockServerExt>,
41+
bitcoin_mock: MockServerExt,
42+
base_mock: MockServerExt,
43+
bnb_mock: MockServerExt,
2944
}
3045

3146
struct MockServerUrls {
@@ -48,7 +63,12 @@ fn build_foreign_chains_config(urls: &MockServerUrls) -> ForeignChainsConfig {
4863
"mock".to_string().into(),
4964
ForeignChainProviderConfig {
5065
rpc_url: urls.bitcoin.clone(),
51-
auth: Default::default(),
66+
auth: AuthConfig::Path {
67+
placeholder: PATH_AUTH_PLACEHOLDER.to_string(),
68+
token: TokenConfig::Val {
69+
val: PATH_AUTH_API_KEY.to_string(),
70+
},
71+
},
5272
},
5373
),
5474
}),
@@ -70,7 +90,12 @@ fn build_foreign_chains_config(urls: &MockServerUrls) -> ForeignChainsConfig {
7090
"mock".to_string().into(),
7191
ForeignChainProviderConfig {
7292
rpc_url: urls.bnb.clone(),
73-
auth: Default::default(),
93+
auth: AuthConfig::Query {
94+
name: QUERY_AUTH_PARAM.to_string(),
95+
token: TokenConfig::Val {
96+
val: QUERY_AUTH_TOKEN.to_string(),
97+
},
98+
},
7499
},
75100
),
76101
}),
@@ -92,7 +117,13 @@ fn build_foreign_chains_config(urls: &MockServerUrls) -> ForeignChainsConfig {
92117
"mock".to_string().into(),
93118
ForeignChainProviderConfig {
94119
rpc_url: urls.base.clone(),
95-
auth: Default::default(),
120+
auth: AuthConfig::Header {
121+
name: HEADER_AUTH_NAME.parse().expect("valid header name"),
122+
scheme: Some(HEADER_AUTH_SCHEME.to_string()),
123+
token: TokenConfig::Val {
124+
val: HEADER_AUTH_TOKEN.to_string(),
125+
},
126+
},
96127
},
97128
),
98129
}),
@@ -158,26 +189,45 @@ async fn setup_foreign_tx_cluster() -> anyhow::Result<ForeignTxTestEnv> {
158189
let arbitrum_server = MockServer::start();
159190
let hyper_evm_server = MockServer::start();
160191

161-
setup_bitcoin_mock(&bitcoin_server);
162-
setup_evm_mock(&abstract_server);
163-
setup_evm_mock(&bnb_server);
164-
setup_starknet_mock(&starknet_server);
165-
setup_evm_mock(&base_server);
166-
setup_evm_mock(&arbitrum_server);
167-
setup_evm_mock(&hyper_evm_server);
192+
let bitcoin_mock_id = setup_bitcoin_mock(
193+
&bitcoin_server,
194+
MockAuthExpectation::ApiKeyInPath {
195+
key: PATH_AUTH_API_KEY.to_string(),
196+
},
197+
);
198+
let base_mock_id = setup_evm_mock(
199+
&base_server,
200+
MockAuthExpectation::Header {
201+
name: HEADER_AUTH_NAME.to_string(),
202+
value: format!("{HEADER_AUTH_SCHEME} {HEADER_AUTH_TOKEN}"),
203+
},
204+
);
205+
let bnb_mock_id = setup_evm_mock(
206+
&bnb_server,
207+
MockAuthExpectation::QueryParam {
208+
name: QUERY_AUTH_PARAM.to_string(),
209+
value: QUERY_AUTH_TOKEN.to_string(),
210+
},
211+
);
212+
setup_evm_mock(&abstract_server, MockAuthExpectation::None);
213+
setup_starknet_mock(&starknet_server, MockAuthExpectation::None);
214+
setup_evm_mock(&arbitrum_server, MockAuthExpectation::None);
215+
setup_evm_mock(&hyper_evm_server, MockAuthExpectation::None);
168216

169217
// Polygon is configured with three RPC providers so the test can assert
170218
// that `FanOut` queries every one of them.
171219
let polygon_mocks: Vec<MockServerExt> = (0..3)
172220
.map(|_| {
173221
let server = MockServer::start();
174-
let mock_id = setup_evm_mock(&server);
222+
let mock_id = setup_evm_mock(&server, MockAuthExpectation::None);
175223
MockServerExt::new(server, mock_id)
176224
})
177225
.collect();
178226

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

240+
let bitcoin_mock = MockServerExt::new(bitcoin_server, bitcoin_mock_id);
241+
let base_mock = MockServerExt::new(base_server, base_mock_id);
242+
let bnb_mock = MockServerExt::new(bnb_server, bnb_mock_id);
190243
let mock_servers = vec![
191-
bitcoin_server,
192244
abstract_server,
193-
bnb_server,
194245
starknet_server,
195-
base_server,
196246
arbitrum_server,
197247
hyper_evm_server,
198248
];
@@ -272,6 +322,9 @@ async fn setup_foreign_tx_cluster() -> anyhow::Result<ForeignTxTestEnv> {
272322
foreign_tx_domain_id,
273323
_mock_servers: mock_servers,
274324
polygon_mocks,
325+
bitcoin_mock,
326+
base_mock,
327+
bnb_mock,
275328
})
276329
}
277330

@@ -443,6 +496,17 @@ async fn verify_hyper_evm(env: &ForeignTxTestEnv) -> anyhow::Result<()> {
443496
verify_foreign_tx_response(&outcome)
444497
}
445498

499+
/// A successful verification implies the credentialed mock answered,
500+
/// so this is a backstop against the mock setup being loosened to answer unauthenticated requests.
501+
fn assert_authenticated_provider_was_queried(mock: &MockServerExt, provider: &str) {
502+
let calls = mock.calls();
503+
assert!(
504+
calls > 0,
505+
"the {provider} mock was never hit with the expected credentials; \
506+
expected >= 1 matching RPC request, got {calls}"
507+
);
508+
}
509+
446510
/// Verifies that every Polygon RPC provider configured in the fan-out received
447511
/// at least one HTTP request during the preceding `verify_polygon` call.
448512
///
@@ -477,11 +541,10 @@ async fn verify_polygon(env: &ForeignTxTestEnv) -> anyhow::Result<()> {
477541
verify_foreign_tx_response(&outcome)
478542
}
479543

480-
/// Sets up a single 2-node cluster with mock RPC servers for all chains,
481-
/// then submits verify_foreign_transaction requests for Bitcoin, Abstract,
482-
/// BNB, Base, Starknet, Arbitrum, HyperEVM, and Polygon and verifies the MPC
483-
/// nodes return valid signed responses. Also verifies rejection for unsupported
484-
/// chains and non-existent domains.
544+
/// Verifies all supported chains sign, and unsupported chains and non-existent
545+
/// domains are rejected. Bitcoin, Base and BNB require authentication (one per
546+
/// credential-carrying [`AuthConfig`] kind), proving the node applies configured
547+
/// RPC credentials end to end.
485548
#[tokio::test]
486549
#[expect(non_snake_case)]
487550
async fn verify_foreign_transaction__should_sign_all_supported_chains() {
@@ -495,11 +558,14 @@ async fn verify_foreign_transaction__should_sign_all_supported_chains() {
495558
verify_bitcoin(&env)
496559
.await
497560
.expect("bitcoin verification failed");
561+
assert_authenticated_provider_was_queried(&env.bitcoin_mock, "bitcoin (path auth)");
498562
verify_abstract(&env)
499563
.await
500564
.expect("abstract verification failed");
501565
verify_bnb(&env).await.expect("bnb verification failed");
566+
assert_authenticated_provider_was_queried(&env.bnb_mock, "bnb (query auth)");
502567
verify_base(&env).await.expect("base verification failed");
568+
assert_authenticated_provider_was_queried(&env.base_mock, "base (header auth)");
503569
verify_starknet(&env)
504570
.await
505571
.expect("starknet verification failed");

0 commit comments

Comments
 (0)