Skip to content

Commit 1a01e6e

Browse files
test: cover all foreign provider auth kinds in verify-foreign-tx e2e
Bitcoin, Base and BNB mock providers now require path, header and query credentials respectively; requests without them get the 401 real providers return instead of a valid RPC response. Verification can only succeed if the node injects each configured credential, covering the path-auth API key substitution end to end. Closes #2786 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent c18e3ec commit 1a01e6e

2 files changed

Lines changed: 158 additions & 29 deletions

File tree

crates/e2e-tests/src/foreign_chain_mock.rs

Lines changed: 71 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,62 @@
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. Requests without the
13+
/// expected credentials are answered with 401 like real providers do, so
14+
/// verification can only succeed if the node applied the configured
15+
/// authentication.
16+
#[derive(Clone, Debug)]
17+
pub enum MockAuthExpectation {
18+
None,
19+
ApiKeyInPath { key: String },
20+
Header { name: String, value: String },
21+
QueryParam { name: String, value: String },
22+
}
23+
24+
impl MockAuthExpectation {
25+
fn apply(&self, when: When) -> When {
26+
match self {
27+
Self::None => when.path("/"),
28+
Self::ApiKeyInPath { key } => when.path(format!("/{key}")),
29+
Self::Header { name, value } => when.path("/").header(name, value),
30+
Self::QueryParam { name, value } => when.path("/").query_param(name, value),
31+
}
32+
}
33+
}
34+
35+
/// Rejects requests missing the expected credentials the way real providers
36+
/// do.
37+
/// Must be registered AFTER the credentialed mock — httpmock picks the
38+
/// first-registered matching mock, so authenticated requests keep hitting it.
39+
fn register_unauthorized_catch_all(server: &MockServer, auth: &MockAuthExpectation) {
40+
if matches!(auth, MockAuthExpectation::None) {
41+
return;
42+
}
43+
server.mock(|when, then| {
44+
when.any_request();
45+
then.respond_with(move |req: &HttpMockRequest| {
46+
let id = serde_json::from_slice::<serde_json::Value>(req.body().as_ref())
47+
.map(|body| body["id"].clone())
48+
.unwrap_or(serde_json::Value::Null);
49+
let response_body = serde_json::json!({
50+
"jsonrpc": "2.0",
51+
"id": id,
52+
"error": { "code": -32600, "message": "Must be authenticated!" },
53+
});
54+
HttpMockResponse::builder()
55+
.status(401)
56+
.header("content-type", "application/json")
57+
.body(serde_json::to_string(&response_body).unwrap())
58+
.build()
59+
});
60+
});
61+
}
62+
1063
/// A [`MockServer`] paired with the id of a registered [`httpmock::Mock`], so
1164
/// tests can recover the `Mock<'_>` (which borrows from the server) on demand
1265
/// and read its hit count. Storing the `Mock` directly would make this struct
@@ -47,10 +100,10 @@ fn jsonrpc_error(id: serde_json::Value, method: &str) -> HttpMockResponse {
47100
.build()
48101
}
49102

50-
pub fn setup_bitcoin_mock(server: &MockServer) -> usize {
51-
server
103+
pub fn setup_bitcoin_mock(server: &MockServer, auth: MockAuthExpectation) -> usize {
104+
let mock_id = server
52105
.mock(|when, then| {
53-
when.method(POST).path("/");
106+
auth.apply(when.method(POST));
54107
then.respond_with(move |req: &HttpMockRequest| {
55108
let body: serde_json::Value =
56109
serde_json::from_slice(req.body().as_ref()).expect("valid json-rpc request");
@@ -83,12 +136,14 @@ pub fn setup_bitcoin_mock(server: &MockServer) -> usize {
83136
.build()
84137
});
85138
})
86-
.id
139+
.id;
140+
register_unauthorized_catch_all(server, &auth);
141+
mock_id
87142
}
88143

89-
pub fn setup_evm_mock(server: &MockServer) -> usize {
90-
server.mock(|when, then| {
91-
when.method(POST).path("/");
144+
pub fn setup_evm_mock(server: &MockServer, auth: MockAuthExpectation) -> usize {
145+
let mock_id = server.mock(|when, then| {
146+
auth.apply(when.method(POST));
92147
then.respond_with(move |req: &HttpMockRequest| {
93148
let body: serde_json::Value =
94149
serde_json::from_slice(req.body().as_ref()).expect("valid json-rpc request");
@@ -155,13 +210,15 @@ pub fn setup_evm_mock(server: &MockServer) -> usize {
155210
.build()
156211
});
157212
})
158-
.id
213+
.id;
214+
register_unauthorized_catch_all(server, &auth);
215+
mock_id
159216
}
160217

161-
pub fn setup_starknet_mock(server: &MockServer) -> usize {
162-
server
218+
pub fn setup_starknet_mock(server: &MockServer, auth: MockAuthExpectation) -> usize {
219+
let mock_id = server
163220
.mock(|when, then| {
164-
when.method(POST).path("/");
221+
auth.apply(when.method(POST));
165222
then.respond_with(move |req: &HttpMockRequest| {
166223
let body: serde_json::Value =
167224
serde_json::from_slice(req.body().as_ref()).expect("valid json-rpc request");
@@ -190,7 +247,9 @@ pub fn setup_starknet_mock(server: &MockServer) -> usize {
190247
.build()
191248
});
192249
})
193-
.id
250+
.id;
251+
register_unauthorized_catch_all(server, &auth);
252+
mock_id
194253
}
195254

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

crates/e2e-tests/tests/foreign_chain_tx_validation.rs

Lines changed: 87 additions & 17 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,25 @@ 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_SCHEME: &str = "Bearer";
29+
const HEADER_AUTH_TOKEN: &str = "base-bearer-token";
30+
const QUERY_AUTH_PARAM: &str = "apikey";
31+
const QUERY_AUTH_TOKEN: &str = "bnb-query-token";
32+
2233
struct ForeignTxTestEnv {
2334
cluster: e2e_tests::MpcCluster,
2435
foreign_tx_domain_id: DomainId,
2536
_mock_servers: Vec<MockServer>,
2637
/// Polygon is configured with multiple RPC providers so the test can verify
2738
/// that `FanOut` queries every one of them.
2839
polygon_mocks: Vec<MockServerExt>,
40+
bitcoin_mock: MockServerExt,
41+
base_mock: MockServerExt,
42+
bnb_mock: MockServerExt,
2943
}
3044

3145
struct MockServerUrls {
@@ -48,7 +62,12 @@ fn build_foreign_chains_config(urls: &MockServerUrls) -> ForeignChainsConfig {
4862
"mock".to_string().into(),
4963
ForeignChainProviderConfig {
5064
rpc_url: urls.bitcoin.clone(),
51-
auth: Default::default(),
65+
auth: AuthConfig::Path {
66+
placeholder: PATH_AUTH_PLACEHOLDER.to_string(),
67+
token: TokenConfig::Val {
68+
val: PATH_AUTH_API_KEY.to_string(),
69+
},
70+
},
5271
},
5372
),
5473
}),
@@ -70,7 +89,12 @@ fn build_foreign_chains_config(urls: &MockServerUrls) -> ForeignChainsConfig {
7089
"mock".to_string().into(),
7190
ForeignChainProviderConfig {
7291
rpc_url: urls.bnb.clone(),
73-
auth: Default::default(),
92+
auth: AuthConfig::Query {
93+
name: QUERY_AUTH_PARAM.to_string(),
94+
token: TokenConfig::Val {
95+
val: QUERY_AUTH_TOKEN.to_string(),
96+
},
97+
},
7498
},
7599
),
76100
}),
@@ -92,7 +116,13 @@ fn build_foreign_chains_config(urls: &MockServerUrls) -> ForeignChainsConfig {
92116
"mock".to_string().into(),
93117
ForeignChainProviderConfig {
94118
rpc_url: urls.base.clone(),
95-
auth: Default::default(),
119+
auth: AuthConfig::Header {
120+
name: "authorization".parse().expect("valid header name"),
121+
scheme: Some(HEADER_AUTH_SCHEME.to_string()),
122+
token: TokenConfig::Val {
123+
val: HEADER_AUTH_TOKEN.to_string(),
124+
},
125+
},
96126
},
97127
),
98128
}),
@@ -158,26 +188,45 @@ async fn setup_foreign_tx_cluster() -> anyhow::Result<ForeignTxTestEnv> {
158188
let arbitrum_server = MockServer::start();
159189
let hyper_evm_server = MockServer::start();
160190

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);
191+
let bitcoin_mock_id = setup_bitcoin_mock(
192+
&bitcoin_server,
193+
MockAuthExpectation::ApiKeyInPath {
194+
key: PATH_AUTH_API_KEY.to_string(),
195+
},
196+
);
197+
let base_mock_id = setup_evm_mock(
198+
&base_server,
199+
MockAuthExpectation::Header {
200+
name: "authorization".to_string(),
201+
value: format!("{HEADER_AUTH_SCHEME} {HEADER_AUTH_TOKEN}"),
202+
},
203+
);
204+
let bnb_mock_id = setup_evm_mock(
205+
&bnb_server,
206+
MockAuthExpectation::QueryParam {
207+
name: QUERY_AUTH_PARAM.to_string(),
208+
value: QUERY_AUTH_TOKEN.to_string(),
209+
},
210+
);
211+
setup_evm_mock(&abstract_server, MockAuthExpectation::None);
212+
setup_starknet_mock(&starknet_server, MockAuthExpectation::None);
213+
setup_evm_mock(&arbitrum_server, MockAuthExpectation::None);
214+
setup_evm_mock(&hyper_evm_server, MockAuthExpectation::None);
168215

169216
// Polygon is configured with three RPC providers so the test can assert
170217
// that `FanOut` queries every one of them.
171218
let polygon_mocks: Vec<MockServerExt> = (0..3)
172219
.map(|_| {
173220
let server = MockServer::start();
174-
let mock_id = setup_evm_mock(&server);
221+
let mock_id = setup_evm_mock(&server, MockAuthExpectation::None);
175222
MockServerExt::new(server, mock_id)
176223
})
177224
.collect();
178225

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

239+
let bitcoin_mock = MockServerExt::new(bitcoin_server, bitcoin_mock_id);
240+
let base_mock = MockServerExt::new(base_server, base_mock_id);
241+
let bnb_mock = MockServerExt::new(bnb_server, bnb_mock_id);
190242
let mock_servers = vec![
191-
bitcoin_server,
192243
abstract_server,
193-
bnb_server,
194244
starknet_server,
195-
base_server,
196245
arbitrum_server,
197246
hyper_evm_server,
198247
];
@@ -272,6 +321,9 @@ async fn setup_foreign_tx_cluster() -> anyhow::Result<ForeignTxTestEnv> {
272321
foreign_tx_domain_id,
273322
_mock_servers: mock_servers,
274323
polygon_mocks,
324+
bitcoin_mock,
325+
base_mock,
326+
bnb_mock,
275327
})
276328
}
277329

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

498+
/// A successful verification implies the credentialed mock answered,
499+
/// so this is a backstop against the mock setup being loosened to answer unauthenticated requests.
500+
fn assert_authenticated_provider_was_queried(mock: &MockServerExt, provider: &str) {
501+
let calls = mock.calls();
502+
assert!(
503+
calls > 0,
504+
"the {provider} mock was never hit with the expected credentials; \
505+
expected >= 1 matching RPC request, got {calls}"
506+
);
507+
}
508+
446509
/// Verifies that every Polygon RPC provider configured in the fan-out received
447510
/// at least one HTTP request during the preceding `verify_polygon` call.
448511
///
@@ -482,6 +545,10 @@ async fn verify_polygon(env: &ForeignTxTestEnv) -> anyhow::Result<()> {
482545
/// BNB, Base, Starknet, Arbitrum, HyperEVM, and Polygon and verifies the MPC
483546
/// nodes return valid signed responses. Also verifies rejection for unsupported
484547
/// chains and non-existent domains.
548+
///
549+
/// Bitcoin, Base and BNB providers require authentication (one per
550+
/// credential-carrying `AuthConfig` kind), so the test also proves the node
551+
/// applies configured RPC credentials end to end.
485552
#[tokio::test]
486553
#[expect(non_snake_case)]
487554
async fn verify_foreign_transaction__should_sign_all_supported_chains() {
@@ -495,11 +562,14 @@ async fn verify_foreign_transaction__should_sign_all_supported_chains() {
495562
verify_bitcoin(&env)
496563
.await
497564
.expect("bitcoin verification failed");
565+
assert_authenticated_provider_was_queried(&env.bitcoin_mock, "bitcoin (path auth)");
498566
verify_abstract(&env)
499567
.await
500568
.expect("abstract verification failed");
501569
verify_bnb(&env).await.expect("bnb verification failed");
570+
assert_authenticated_provider_was_queried(&env.bnb_mock, "bnb (query auth)");
502571
verify_base(&env).await.expect("base verification failed");
572+
assert_authenticated_provider_was_queried(&env.base_mock, "base (header auth)");
503573
verify_starknet(&env)
504574
.await
505575
.expect("starknet verification failed");

0 commit comments

Comments
 (0)