use crate::event::ChainEvent;
use hmac::{Mac, SimpleHmac};
use newton_metric::record_chain_watcher_gateway_relay_duration;
use sha2::Sha256;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tracing::{error, info, warn};
const DEFAULT_MAX_RETRIES: u32 = 3;
const INITIAL_BACKOFF_MS: u64 = 100;
const MAX_BACKOFF_MS: u64 = 5000;
pub const WATCHER_TS_HEADER: &str = "x-newton-watcher-timestamp";
pub const WATCHER_SIG_HEADER: &str = "x-newton-watcher-signature";
#[derive(Debug, Clone)]
pub struct GatewayClient {
client: reqwest::Client,
watcher_url: String,
max_retries: u32,
psk: Option<Vec<u8>>,
}
impl GatewayClient {
pub fn new(watcher_url: String, max_retries: Option<u32>) -> Self {
Self::with_psk(watcher_url, max_retries, None)
}
pub fn with_psk(watcher_url: String, max_retries: Option<u32>, psk: Option<&str>) -> Self {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.pool_max_idle_per_host(10)
.tcp_keepalive(Duration::from_secs(60))
.tcp_nodelay(true)
.build()
.expect("reqwest client should build with default TLS");
let psk = psk.and_then(|s| match hex::decode(s.trim_start_matches("0x")) {
Ok(bytes) if !bytes.is_empty() => Some(bytes),
Ok(_) => {
warn!("watcher PSK decoded to empty bytes; ignoring");
None
}
Err(e) => {
warn!(error = %e, "watcher PSK is not valid hex; ignoring");
None
}
});
Self {
client,
watcher_url,
max_retries: max_retries.unwrap_or(DEFAULT_MAX_RETRIES),
psk,
}
}
fn sign(psk: &[u8], ts: &str, body: &[u8]) -> String {
let mut mac = <SimpleHmac<Sha256> as Mac>::new_from_slice(psk).expect("HMAC accepts any key length");
mac.update(ts.as_bytes());
mac.update(b"\n");
mac.update(body);
hex::encode(mac.finalize().into_bytes())
}
pub async fn submit_event(&self, event: &ChainEvent) -> eyre::Result<()> {
let body = serde_json::to_vec(event)?;
let start = Instant::now();
let mut attempt = 0u32;
let mut delay = Duration::from_millis(INITIAL_BACKOFF_MS);
loop {
attempt += 1;
let mut req = self
.client
.post(&self.watcher_url)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(body.clone());
if let Some(psk) = &self.psk {
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
.to_string();
let sig = Self::sign(psk, &ts, &body);
req = req.header(WATCHER_TS_HEADER, ts).header(WATCHER_SIG_HEADER, sig);
}
match req.send().await {
Ok(response) => {
if response.status().is_success() {
record_chain_watcher_gateway_relay_duration(event.chain_id, start.elapsed().as_secs_f64());
info!(
chain_id = event.chain_id,
block_number = event.block_number,
attempt,
"event submitted to gateway"
);
return Ok(());
}
let status = response.status();
let body = response.text().await.unwrap_or_default();
if status.is_client_error() {
return Err(eyre::eyre!("gateway rejected event: status={}, body={}", status, body));
}
if attempt >= self.max_retries {
return Err(eyre::eyre!(
"gateway returned {} after {} attempts: {}",
status,
attempt,
body
));
}
warn!(
chain_id = event.chain_id,
status = %status,
attempt,
"gateway returned server error, retrying"
);
}
Err(e) => {
if attempt >= self.max_retries {
return Err(eyre::eyre!("failed to reach gateway after {} attempts: {}", attempt, e));
}
warn!(
chain_id = event.chain_id,
attempt,
error = %e,
"failed to reach gateway, retrying"
);
}
}
tokio::time::sleep(delay).await;
delay = delay.saturating_mul(2).min(Duration::from_millis(MAX_BACKOFF_MS));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::{ChainEvent, ChainEventType};
use alloy::primitives::{Address, FixedBytes, B256};
use wiremock::{
matchers::{method, path},
Mock, MockServer, ResponseTemplate,
};
fn test_event() -> ChainEvent {
ChainEvent {
chain_id: 31337,
event_type: ChainEventType::OperatorAdded {
operator: Address::ZERO,
operator_set_avs: Address::ZERO,
operator_set_id: 0,
},
block_number: 100,
tx_hash: B256::ZERO,
}
}
#[tokio::test]
async fn submit_event_succeeds_on_200() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/watcher"))
.respond_with(ResponseTemplate::new(200).set_body_string("ok"))
.expect(1)
.mount(&server)
.await;
let client = GatewayClient::new(format!("{}/watcher", server.uri()), Some(3));
let result = client.submit_event(&test_event()).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn submit_event_fails_immediately_on_4xx() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/watcher"))
.respond_with(ResponseTemplate::new(400).set_body_string("bad request"))
.expect(1)
.mount(&server)
.await;
let client = GatewayClient::new(format!("{}/watcher", server.uri()), Some(3));
let result = client.submit_event(&test_event()).await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("rejected"), "error should mention rejection: {err}");
}
#[tokio::test]
async fn submit_event_retries_on_5xx_then_fails() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/watcher"))
.respond_with(ResponseTemplate::new(500).set_body_string("internal error"))
.expect(2)
.mount(&server)
.await;
let client = GatewayClient::new(format!("{}/watcher", server.uri()), Some(2));
let result = client.submit_event(&test_event()).await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("after 2 attempts"),
"error should mention attempt count: {err}"
);
}
#[tokio::test]
async fn submit_event_retries_on_network_error_then_fails() {
let client = GatewayClient::new("http://127.0.0.1:1/watcher".to_string(), Some(2));
let result = client.submit_event(&test_event()).await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("after 2 attempts"),
"error should mention attempt count: {err}"
);
}
#[tokio::test]
async fn default_max_retries_is_3() {
let client = GatewayClient::new("http://localhost:1234/watcher".to_string(), None);
assert_eq!(client.max_retries, DEFAULT_MAX_RETRIES);
assert_eq!(client.max_retries, 3);
}
#[tokio::test]
async fn submit_event_succeeds_after_retry() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/watcher"))
.respond_with(ResponseTemplate::new(500).set_body_string("error"))
.up_to_n_times(1)
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/watcher"))
.respond_with(ResponseTemplate::new(200).set_body_string("ok"))
.expect(1)
.mount(&server)
.await;
let client = GatewayClient::new(format!("{}/watcher", server.uri()), Some(3));
let result = client.submit_event(&test_event()).await;
assert!(result.is_ok());
}
}