use std::{
env,
sync::{Arc, OnceLock, PoisonError, RwLock},
thread,
time::Instant,
};
use serde::{Deserialize, Serialize};
use tracing::warn;
use crate::encoding::{
errors::EncodingError,
evm::constants::{
ANGSTROM_API_TIMEOUT, ANGSTROM_ATTESTATION_MAX_AGE, ANGSTROM_ATTESTATION_REFRESH_INTERVAL,
ANGSTROM_ATTESTATION_SIZE, ANGSTROM_DEFAULT_API_URL, ANGSTROM_DEFAULT_BLOCKS_IN_FUTURE,
},
};
static CACHE: OnceLock<Arc<AttestationCache>> = OnceLock::new();
type WindowFetcher = Box<dyn Fn() -> Result<AttestationResponse, EncodingError> + Send + Sync>;
pub(crate) struct AttestationCache {
fetcher: Result<WindowFetcher, String>,
window: RwLock<Option<CachedWindow>>,
}
impl AttestationCache {
pub(crate) fn global() -> &'static Arc<Self> {
CACHE.get_or_init(|| {
let fetcher = ApiConfig::from_env().map(ApiConfig::into_fetcher);
let cache = Arc::new(Self { fetcher, window: RwLock::new(None) });
Arc::clone(&cache).spawn_refresher();
cache
})
}
pub(crate) fn hook_data(&self) -> Result<Vec<u8>, EncodingError> {
let cached = self
.window
.read()
.unwrap_or_else(PoisonError::into_inner);
if let Some(window) = cached.as_ref() {
if window.fetched_at.elapsed() <= ANGSTROM_ATTESTATION_MAX_AGE {
return Ok(window.encoded.clone());
}
}
drop(cached);
if let Err(reason) = &self.fetcher {
return Err(EncodingError::FatalError(reason.clone()));
}
warn!("Angstrom attestation cache is cold or stale, fetching while encoding");
self.refresh()
}
fn refresh(&self) -> Result<Vec<u8>, EncodingError> {
let fetcher = self
.fetcher
.as_ref()
.map_err(|reason| EncodingError::FatalError(reason.clone()))?;
let encoded = encode_attestations(&fetcher()?)?;
*self
.window
.write()
.unwrap_or_else(PoisonError::into_inner) =
Some(CachedWindow { encoded: encoded.clone(), fetched_at: Instant::now() });
Ok(encoded)
}
fn spawn_refresher(self: Arc<Self>) {
if self.fetcher.is_err() {
return;
}
let spawned = thread::Builder::new()
.name("angstrom-attestations".to_string())
.spawn(move || loop {
if let Err(e) = self.refresh() {
warn!("Angstrom attestation refresh failed: {e}");
}
thread::sleep(ANGSTROM_ATTESTATION_REFRESH_INTERVAL);
});
if let Err(e) = spawned {
warn!(
"Failed to start the Angstrom attestation refresher: {e}. Attestations will be \
fetched while encoding instead."
);
}
}
}
struct CachedWindow {
encoded: Vec<u8>,
fetched_at: Instant,
}
struct ApiConfig {
client: reqwest::blocking::Client,
url: String,
key: String,
blocks_in_future: u64,
}
impl ApiConfig {
fn from_env() -> Result<Self, String> {
let key = env::var("ANGSTROM_API_KEY").map_err(|_| {
"ANGSTROM_API_KEY environment variable is required for Angstrom swaps".to_string()
})?;
let client = reqwest::blocking::Client::builder()
.timeout(ANGSTROM_API_TIMEOUT)
.build()
.map_err(|e| format!("Failed to build the Angstrom API client: {e}"))?;
let url =
env::var("ANGSTROM_API_URL").unwrap_or_else(|_| ANGSTROM_DEFAULT_API_URL.to_string());
let blocks_in_future = match env::var("ANGSTROM_BLOCKS_IN_FUTURE") {
Ok(blocks) => blocks.parse().map_err(|e| {
format!("ANGSTROM_BLOCKS_IN_FUTURE is set to '{blocks}', not a block count: {e}")
})?,
Err(_) => ANGSTROM_DEFAULT_BLOCKS_IN_FUTURE,
};
Ok(Self { client, url, key, blocks_in_future })
}
fn into_fetcher(self) -> WindowFetcher {
Box::new(move || self.fetch_off_runtime())
}
fn fetch_off_runtime(&self) -> Result<AttestationResponse, EncodingError> {
thread::scope(|scope| {
scope
.spawn(|| self.fetch())
.join()
.map_err(|_| {
EncodingError::RecoverableError(
"Angstrom attestation fetch panicked".to_string(),
)
})
})?
}
fn fetch(&self) -> Result<AttestationResponse, EncodingError> {
let response = self
.client
.post(&self.url)
.header("accept", "application/json")
.header("X-Api-Key", &self.key)
.header("Content-Type", "application/json")
.json(&serde_json::json!({ "blocks_in_future": self.blocks_in_future }))
.send()
.map_err(|e| {
EncodingError::RecoverableError(format!("Failed to fetch attestations: {e}"))
})?;
if !response.status().is_success() {
let status = response.status();
let error_text = response
.text()
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(EncodingError::RecoverableError(format!(
"Angstrom API request failed with status {status}: {error_text}"
)));
}
let response: AttestationResponse = response.json().map_err(|e| {
EncodingError::RecoverableError(format!("Failed to parse attestation response: {e}"))
})?;
if !response.success {
return Err(EncodingError::RecoverableError(
"Angstrom API returned success=false".to_string(),
));
}
Ok(response)
}
}
fn encode_attestations(response: &AttestationResponse) -> Result<Vec<u8>, EncodingError> {
if response.attestations.is_empty() {
return Err(EncodingError::RecoverableError(
"Angstrom API returned an empty attestation window".to_string(),
));
}
let mut encoded =
Vec::with_capacity(response.attestations.len() * (8 + ANGSTROM_ATTESTATION_SIZE));
for data in &response.attestations {
let attestation_hex = data
.attestation
.strip_prefix("0x")
.unwrap_or(&data.attestation);
let attestation = hex::decode(attestation_hex).map_err(|e| {
EncodingError::FatalError(format!(
"Failed to decode Angstrom attestation for block {}: {}",
data.block_number, e
))
})?;
if attestation.len() != ANGSTROM_ATTESTATION_SIZE {
return Err(EncodingError::FatalError(format!(
"Angstrom attestation for block {} is {} bytes, expected {}",
data.block_number,
attestation.len(),
ANGSTROM_ATTESTATION_SIZE
)));
}
encoded.extend_from_slice(&data.block_number.to_be_bytes());
encoded.extend_from_slice(&attestation);
}
Ok(encoded)
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub(crate) struct AttestationResponse {
pub(crate) success: bool,
pub(crate) attestations: Vec<AttestationData>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub(crate) struct AttestationData {
#[serde(rename = "blockNumber")]
pub(crate) block_number: u64,
#[serde(rename = "unlockData")]
pub(crate) attestation: String,
}
#[cfg(test)]
mod tests {
use std::{
sync::atomic::{AtomicUsize, Ordering},
time::Duration,
};
use super::*;
fn attestation_response() -> AttestationResponse {
AttestationResponse {
success: true,
attestations: vec![
AttestationData {
block_number: 12345678,
attestation: "0xd437f3372f3add2c2bc3245e6bd6f9c202e61bb367c79a6f740c7c12ca9c54a760bead943516fafaf8a4fe65a907b31d45c2ab4b525f9f32ec2771033e0832359ceb2e38d9288a755c7c366ce889b0df24b5821b1c".to_string(),
},
AttestationData {
block_number: 12345679,
attestation: "0xd437f3372f3add2c2bc3245e6bd6f9c202e61bb30c337ddae661e68cc6986c7784cd0aaec455b1f7514b6cd91bff26f002ce7cb42b3b1e2092ea4d1c1fb1e0641cbccfb021b31de25462f25b355cc99c7d509cdc1b".to_string(),
},
],
}
}
fn cache_with(fetcher: WindowFetcher, window: Option<CachedWindow>) -> AttestationCache {
AttestationCache { fetcher: Ok(fetcher), window: RwLock::new(window) }
}
fn counted_fetch() -> (WindowFetcher, Arc<AtomicUsize>) {
let calls = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&calls);
let fetcher: WindowFetcher = Box::new(move || {
counter.fetch_add(1, Ordering::SeqCst);
Ok(attestation_response())
});
(fetcher, calls)
}
fn unconfigured_cache() -> AttestationCache {
AttestationCache { fetcher: Err("no API key".to_string()), window: RwLock::new(None) }
}
fn failing_fetch() -> WindowFetcher {
Box::new(|| Err(EncodingError::RecoverableError("the API is down".to_string())))
}
fn fetched_window() -> Vec<u8> {
encode_attestations(&attestation_response()).unwrap()
}
fn window_fetched_ago(age: Duration) -> Option<CachedWindow> {
let fetched_at = Instant::now()
.checked_sub(age)
.expect("the monotonic clock is past the requested age");
Some(CachedWindow { encoded: vec![1, 2, 3], fetched_at })
}
fn stale() -> Option<CachedWindow> {
window_fetched_ago(ANGSTROM_ATTESTATION_MAX_AGE + Duration::from_secs(1))
}
#[test]
fn test_fresh_window_is_served_without_a_fetch() {
let (fetch, calls) = counted_fetch();
let cache = cache_with(fetch, window_fetched_ago(Duration::ZERO));
assert_eq!(cache.hook_data().unwrap(), vec![1, 2, 3]);
assert_eq!(calls.load(Ordering::SeqCst), 0);
}
#[test]
fn test_stale_window_is_replaced_by_a_fetch() {
let (fetch, calls) = counted_fetch();
let cache = cache_with(fetch, stale());
assert_eq!(cache.hook_data().unwrap(), fetched_window());
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[test]
fn test_cold_cache_is_filled_by_a_fetch() {
let (fetch, calls) = counted_fetch();
let cache = cache_with(fetch, None);
assert_eq!(cache.hook_data().unwrap(), fetched_window());
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[test]
fn test_fetched_window_is_served_to_the_next_caller() {
let (fetch, calls) = counted_fetch();
let cache = cache_with(fetch, None);
cache.hook_data().unwrap();
assert_eq!(cache.hook_data().unwrap(), fetched_window());
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[test]
fn test_unconfigured_cache_reports_the_configuration_error() {
let err = unconfigured_cache()
.hook_data()
.unwrap_err();
assert_eq!(err, EncodingError::FatalError("no API key".to_string()));
}
#[test]
fn test_failed_refresh_keeps_the_previous_window() {
let cache = cache_with(failing_fetch(), window_fetched_ago(Duration::ZERO));
let err = cache.refresh().unwrap_err();
assert_eq!(err, EncodingError::RecoverableError("the API is down".to_string()));
assert_eq!(cache.hook_data().unwrap(), vec![1, 2, 3]);
}
#[test]
fn test_failed_fetch_surfaces_on_a_stale_window() {
let cache = cache_with(failing_fetch(), stale());
let err = cache.hook_data().unwrap_err();
assert_eq!(err, EncodingError::RecoverableError("the API is down".to_string()));
}
#[test]
fn test_unparsable_blocks_in_future_is_rejected() {
env::set_var("ANGSTROM_API_KEY", "test-key");
env::set_var("ANGSTROM_BLOCKS_IN_FUTURE", "five");
let err = ApiConfig::from_env().err();
env::remove_var("ANGSTROM_BLOCKS_IN_FUTURE");
env::remove_var("ANGSTROM_API_KEY");
let err = err.expect("an unparsable block count must be rejected");
assert!(
err.contains("ANGSTROM_BLOCKS_IN_FUTURE is set to 'five'"),
"unexpected error: {err}"
);
}
fn current_block_number() -> u64 {
let url = env::var("RPC_URL").expect("RPC_URL must be set");
let response: serde_json::Value = reqwest::blocking::Client::new()
.post(url)
.json(&serde_json::json!({
"jsonrpc": "2.0", "method": "eth_blockNumber", "params": [], "id": 1
}))
.send()
.expect("the RPC must answer")
.json()
.expect("the RPC must return JSON");
let block_number = response["result"]
.as_str()
.expect("eth_blockNumber must return a result");
u64::from_str_radix(block_number.trim_start_matches("0x"), 16)
.expect("eth_blockNumber must return a hex number")
}
#[test]
#[ignore] fn test_live_window_brackets_the_current_block() {
let api = ApiConfig::from_env().expect("ANGSTROM_API_KEY must be set");
let response = api
.fetch_off_runtime()
.expect("the Angstrom API must answer");
let head = current_block_number();
let mut blocks: Vec<u64> = response
.attestations
.iter()
.map(|attestation| attestation.block_number)
.collect();
blocks.sort_unstable();
assert_eq!(
blocks.len() as u64,
ANGSTROM_DEFAULT_BLOCKS_IN_FUTURE + 1,
"expected the current block plus {ANGSTROM_DEFAULT_BLOCKS_IN_FUTURE} future ones, \
got {blocks:?}"
);
assert!(
blocks
.windows(2)
.all(|pair| pair[1] == pair[0] + 1),
"attested blocks are not consecutive: {blocks:?}"
);
assert!(
blocks.contains(&head),
"window {blocks:?} does not cover the current block {head}"
);
assert!(
blocks
.last()
.is_some_and(|last| *last > head),
"window {blocks:?} leaves no future block for the transaction to land in, head {head}"
);
}
#[test]
fn test_encode_attestations_format() {
let encoded = encode_attestations(&attestation_response()).unwrap();
assert_eq!(encoded.len(), 186);
assert_eq!(
hex::encode(&encoded),
String::from(concat!(
"0000000000bc614e",
"d437f3372f3add2c2bc3245e6bd6f9c202e61bb367c79a6f740c7c12ca9c54a760bead943516fafaf8a4fe65a907b31d45c2ab4b525f9f32ec2771033e0832359ceb2e38d9288a755c7c366ce889b0df24b5821b1c",
"0000000000bc614f",
"d437f3372f3add2c2bc3245e6bd6f9c202e61bb30c337ddae661e68cc6986c7784cd0aaec455b1f7514b6cd91bff26f002ce7cb42b3b1e2092ea4d1c1fb1e0641cbccfb021b31de25462f25b355cc99c7d509cdc1b"
))
);
}
#[test]
fn test_encode_attestations_rejects_empty_window() {
let empty = AttestationResponse { success: true, attestations: vec![] };
let err = encode_attestations(&empty).unwrap_err();
assert_eq!(
err,
EncodingError::RecoverableError(
"Angstrom API returned an empty attestation window".to_string()
)
);
}
#[test]
fn test_encode_attestations_rejects_wrong_size() {
let response = AttestationResponse {
success: true,
attestations: vec![AttestationData {
block_number: 12345678,
attestation: "0xdeadbeef".to_string(),
}],
};
let err = encode_attestations(&response).unwrap_err();
assert!(format!("{err}").contains("is 4 bytes, expected 85"));
}
#[test]
fn test_encode_attestations_rejects_invalid_hex() {
let response = AttestationResponse {
success: true,
attestations: vec![AttestationData {
block_number: 12345678,
attestation: "0xnothex".to_string(),
}],
};
let err = encode_attestations(&response).unwrap_err();
assert!(format!("{err}").contains("Failed to decode Angstrom attestation for block"));
}
}