use crate::{Mutex, RestError};
use anyhow::anyhow;
use lru::LruCache;
use serde::de::DeserializeOwned;
use std::{
collections::{HashMap, hash_map::Entry},
num::NonZeroUsize,
sync::Arc,
time::Duration,
};
use tokio::sync::Semaphore;
const SNAPSHOT_CACHE_SIZE: usize = 256;
const UPSTREAM_TIMEOUT: Duration = Duration::from_secs(20);
const MAX_CONCURRENT_UPSTREAM_REQUESTS: usize = 8;
const MAX_SNAPSHOT_BYTES: usize = 64 << 20;
pub(crate) const SUPPORTED_PROGRAM: &str = "credits.aleo";
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(crate) enum SnapshotMapping {
Bonded,
Delegated,
Metadata,
Unbonding,
Withdraw,
}
impl SnapshotMapping {
pub(crate) const ALL: [Self; 5] = [Self::Bonded, Self::Delegated, Self::Metadata, Self::Unbonding, Self::Withdraw];
pub(crate) const fn name(self) -> &'static str {
match self {
Self::Bonded => "bonded",
Self::Delegated => "delegated",
Self::Metadata => "metadata",
Self::Unbonding => "unbonding",
Self::Withdraw => "withdraw",
}
}
pub(crate) fn from_name(name: &str) -> Option<Self> {
Self::ALL.into_iter().find(|mapping| mapping.name() == name)
}
}
pub(crate) type MappingSnapshot = HashMap<String, String>;
pub(crate) type StakingRewardsSnapshot = HashMap<String, (String, u64)>;
#[derive(Clone, Debug)]
enum FetchError {
NotFound(String),
Unavailable(String),
}
impl From<FetchError> for RestError {
fn from(error: FetchError) -> Self {
match error {
FetchError::NotFound(message) => RestError::not_found(anyhow!("{message}")),
FetchError::Unavailable(message) => {
RestError::service_unavailable(anyhow!("The historical data upstream is unavailable: {message}"))
}
}
}
}
type Flight<T> = tokio::sync::Mutex<Option<Result<Arc<T>, FetchError>>>;
struct SnapshotCache<K, T> {
snapshots: Mutex<LruCache<K, Arc<T>>>,
in_flight: Mutex<HashMap<K, Arc<Flight<T>>>>,
}
struct FlightGuard<'a, K: Copy + Eq + std::hash::Hash, T> {
cache: &'a SnapshotCache<K, T>,
key: K,
flight: Arc<Flight<T>>,
}
impl<K: Copy + Eq + std::hash::Hash, T> Drop for FlightGuard<'_, K, T> {
fn drop(&mut self) {
if let Entry::Occupied(entry) = self.cache.in_flight.lock().entry(self.key)
&& Arc::ptr_eq(entry.get(), &self.flight)
{
entry.remove();
}
}
}
impl<K: Copy + Eq + std::hash::Hash, T> SnapshotCache<K, T> {
fn new() -> Self {
Self {
snapshots: Mutex::new(LruCache::new(NonZeroUsize::new(SNAPSHOT_CACHE_SIZE).expect("nonzero"))),
in_flight: Mutex::new(HashMap::new()),
}
}
async fn get_or_fetch<F, Fut>(&self, key: K, fetch: F) -> Result<Arc<T>, FetchError>
where
F: FnOnce() -> Fut,
Fut: Future<Output = Result<T, FetchError>>,
{
if let Some(snapshot) = self.snapshots.lock().get(&key) {
return Ok(snapshot.clone());
}
let flight = self.in_flight.lock().entry(key).or_default().clone();
let mut outcome = flight.lock().await;
if let Some(result) = &*outcome {
return result.clone();
}
let _guard = FlightGuard { cache: self, key, flight: flight.clone() };
let result = fetch().await.map(Arc::new);
if let Ok(snapshot) = &result {
self.snapshots.lock().put(key, snapshot.clone());
}
*outcome = Some(result.clone());
result
}
}
pub(crate) struct HistoryCompat {
client: reqwest::Client,
base_url: String,
network: &'static str,
upstream_requests: Semaphore,
mappings: SnapshotCache<(u32, SnapshotMapping), MappingSnapshot>,
staking_rewards: SnapshotCache<u32, StakingRewardsSnapshot>,
}
impl HistoryCompat {
pub(crate) fn new(base_url: &str, network: &'static str) -> anyhow::Result<Self> {
let client = reqwest::Client::builder()
.timeout(UPSTREAM_TIMEOUT)
.user_agent(concat!("snarkos/", env!("SNARKOS_VERSION")))
.build()?;
Ok(Self {
client,
base_url: base_url.trim_end_matches('/').to_string(),
network,
upstream_requests: Semaphore::new(MAX_CONCURRENT_UPSTREAM_REQUESTS),
mappings: SnapshotCache::new(),
staking_rewards: SnapshotCache::new(),
})
}
fn snapshot_url(&self, height: u32, name: &str) -> String {
format!("{}/{}/block/{height}/history/{name}", self.base_url, self.network)
}
pub(crate) async fn mapping(
&self,
height: u32,
mapping: SnapshotMapping,
) -> Result<Arc<MappingSnapshot>, RestError> {
let snapshot = self
.mappings
.get_or_fetch((height, mapping), || async move {
let entries: Vec<(String, String)> = self.fetch(height, mapping.name()).await?;
Ok(entries.into_iter().collect())
})
.await?;
Ok(snapshot)
}
pub(crate) async fn staking_rewards(&self, height: u32) -> Result<Arc<StakingRewardsSnapshot>, RestError> {
Ok(self.staking_rewards.get_or_fetch(height, || self.fetch(height, "stakingrewards")).await?)
}
async fn fetch<T: DeserializeOwned>(&self, height: u32, name: &str) -> Result<T, FetchError> {
let _permit = self.upstream_requests.acquire().await.expect("the semaphore is never closed");
let url = self.snapshot_url(height, name);
let unavailable = |message: String| FetchError::Unavailable(format!("{url}: {message}"));
let response = self.client.get(&url).send().await.map_err(|error| unavailable(error.to_string()))?;
let status = response.status();
if response.content_length().is_some_and(|length| length > MAX_SNAPSHOT_BYTES as u64) {
return Err(unavailable(format!("the snapshot exceeds {MAX_SNAPSHOT_BYTES} bytes")));
}
let body = response.bytes().await.map_err(|error| unavailable(error.to_string()))?;
if body.len() > MAX_SNAPSHOT_BYTES {
return Err(unavailable(format!("the snapshot exceeds {MAX_SNAPSHOT_BYTES} bytes")));
}
let missing = status == reqwest::StatusCode::NOT_FOUND
|| (status.is_server_error() && body.starts_with(b"Could not load mapping"));
if missing {
return Err(FetchError::NotFound(format!("No snapshot of '{name}' is recorded for block {height}")));
}
if !status.is_success() {
return Err(unavailable(format!("answered {status}")));
}
serde_json::from_slice(&body).map_err(|error| unavailable(error.to_string()))
}
}
#[cfg(test)]
pub(crate) mod fixtures {
pub(crate) const UNBONDING: &str = r#"[
[
"aleo1qgtvgvzkxqyh0jc7wxv3zjzjcd5epll38uv4wmmxjt8hexjluygqu4ukl2",
"{\n microcredits: 31712836548u64,\n height: 883089u32\n}"
],
[
"aleo1sdjqhlcm9qltpu74ek0vxewt52zsdmn6swmpjn6m0tp9xf57dvpq740r8j",
"{\n microcredits: 10113730488u64,\n height: 621255u32\n}"
]
]"#;
pub(crate) const BONDED: &str = r#"[
[
"aleo1qy4qufq03wcph05fdf5aj09ez67vcmmlrzqf0zza352qwaq43gyqt3wdf6",
"{\n validator: aleo1vfukg8ky2mhfprw63s0k0hl4vvd8573s6fkn8cv9y0ca6q27eq8qwdnxls,\n microcredits: 141347021440u64\n}"
]
]"#;
pub(crate) const STAKING_REWARDS: &str = r#"{
"aleo1qy4qufq03wcph05fdf5aj09ez67vcmmlrzqf0zza352qwaq43gyqt3wdf6": [
"aleo1vfukg8ky2mhfprw63s0k0hl4vvd8573s6fkn8cv9y0ca6q27eq8qwdnxls",
6477
]
}"#;
pub(crate) const METADATA: &str = r#"[
["aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc", "16u32"],
["aleo1qgqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqanmpl0", "138u32"]
]"#;
pub(crate) const MISSING: &str =
"Could not load mapping 'withdraw' from block '0' — No such file or directory (os error 2)";
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::sync::Notify;
#[test]
fn test_mapping_names() {
for mapping in SnapshotMapping::ALL {
assert_eq!(SnapshotMapping::from_name(mapping.name()), Some(mapping));
}
assert!(SnapshotMapping::from_name("committee").is_none());
assert!(SnapshotMapping::from_name("account").is_none());
assert!(SnapshotMapping::from_name("stakingrewards").is_none());
}
#[test]
fn test_snapshot_url() {
let compat = HistoryCompat::new("https://example.com/", "mainnet").unwrap();
assert_eq!(
compat.snapshot_url(1_000_000, "stakingrewards"),
"https://example.com/mainnet/block/1000000/history/stakingrewards"
);
assert_eq!(compat.snapshot_url(7, "unbonding"), "https://example.com/mainnet/block/7/history/unbonding");
}
struct HeldFetch {
started: Notify,
release: Notify,
fetches: AtomicUsize,
}
impl HeldFetch {
fn new() -> Arc<Self> {
Arc::new(Self { started: Notify::new(), release: Notify::new(), fetches: AtomicUsize::new(0) })
}
async fn fetch(self: Arc<Self>, result: Result<u32, FetchError>) -> Result<u32, FetchError> {
self.fetches.fetch_add(1, Ordering::SeqCst);
self.started.notify_one();
self.release.notified().await;
result
}
}
#[tokio::test]
async fn test_concurrent_requests_share_one_fetch() {
let cache = Arc::new(SnapshotCache::<u32, u32>::new());
let held = HeldFetch::new();
let first = tokio::spawn({
let (cache, held) = (cache.clone(), held.clone());
async move { cache.get_or_fetch(1, || held.fetch(Ok(10))).await }
});
held.started.notified().await;
let second = tokio::spawn({
let (cache, held) = (cache.clone(), held.clone());
async move { cache.get_or_fetch(1, || held.fetch(Ok(10))).await }
});
let third = tokio::spawn({
let (cache, held) = (cache.clone(), held.clone());
async move { cache.get_or_fetch(1, || held.fetch(Ok(10))).await }
});
let other = cache.get_or_fetch(2, || async { Ok(20) }).await.unwrap();
assert_eq!(*other, 20);
for _ in 0..10 {
tokio::task::yield_now().await;
}
assert_eq!(held.fetches.load(Ordering::SeqCst), 1);
held.release.notify_one();
let (first, second, third) =
(first.await.unwrap().unwrap(), second.await.unwrap().unwrap(), third.await.unwrap().unwrap());
assert!(Arc::ptr_eq(&first, &second) && Arc::ptr_eq(&second, &third));
assert_eq!(*first, 10);
assert_eq!(held.fetches.load(Ordering::SeqCst), 1);
assert_eq!(*cache.get_or_fetch(1, || async { Ok(99) }).await.unwrap(), 10);
assert!(cache.in_flight.lock().is_empty());
}
#[tokio::test]
async fn test_concurrent_requests_share_one_failure() {
let cache = Arc::new(SnapshotCache::<u32, u32>::new());
let held = HeldFetch::new();
let first = tokio::spawn({
let (cache, held) = (cache.clone(), held.clone());
async move { cache.get_or_fetch(1, || held.fetch(Err(FetchError::NotFound("missing".into())))).await }
});
held.started.notified().await;
let second = tokio::spawn({
let (cache, held) = (cache.clone(), held.clone());
async move { cache.get_or_fetch(1, || held.fetch(Ok(10))).await }
});
for _ in 0..10 {
tokio::task::yield_now().await;
}
held.release.notify_one();
assert!(matches!(first.await.unwrap(), Err(FetchError::NotFound(_))));
assert!(matches!(second.await.unwrap(), Err(FetchError::NotFound(_))));
assert_eq!(held.fetches.load(Ordering::SeqCst), 1);
assert_eq!(*cache.get_or_fetch(1, || async { Ok(30) }).await.unwrap(), 30);
assert!(cache.in_flight.lock().is_empty());
}
#[tokio::test]
async fn test_cancelled_fetch_leaves_no_flight_behind() {
let cache = Arc::new(SnapshotCache::<u32, u32>::new());
let held = HeldFetch::new();
let request = tokio::spawn({
let (cache, held) = (cache.clone(), held.clone());
async move { cache.get_or_fetch(1, || held.fetch(Ok(10))).await }
});
held.started.notified().await;
request.abort();
assert!(request.await.unwrap_err().is_cancelled());
assert!(cache.in_flight.lock().is_empty());
assert_eq!(*cache.get_or_fetch(1, || async { Ok(11) }).await.unwrap(), 11);
}
}
#[cfg(test)]
mod live_upstream_sweep {
use super::*;
use snarkvm::prelude::{Identifier, Literal, MainnetV0, Plaintext, Value};
use rand::{RngExt, SeedableRng, rngs::StdRng};
use std::{collections::HashSet, str::FromStr};
type N = MainnetV0;
const METADATA_KEYS: [&str; 2] = [
"aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc",
"aleo1qgqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqanmpl0",
];
const TIP_LAG: u32 = 10;
fn env_or<T: FromStr>(name: &str, default: T) -> T {
std::env::var(name).ok().and_then(|value| value.parse().ok()).unwrap_or(default)
}
fn check_canonical(
height: u32,
mapping: SnapshotMapping,
snapshot: &MappingSnapshot,
failures: &mut Vec<String>,
) -> Vec<(String, Plaintext<N>)> {
let mut parsed = Vec::with_capacity(snapshot.len());
for (key, value) in snapshot {
match Plaintext::<N>::from_str(key) {
Ok(plaintext) if plaintext.to_string() == *key => {}
Ok(plaintext) => failures.push(format!(
"{height}/{}: key {key:?} is not canonical (reprints as {:?})",
mapping.name(),
plaintext.to_string()
)),
Err(error) => {
failures.push(format!("{height}/{}: key {key:?} does not parse: {error}", mapping.name()))
}
}
match Value::<N>::from_str(value) {
Ok(Value::Plaintext(plaintext)) if plaintext.to_string() == *value => {
parsed.push((key.clone(), plaintext))
}
Ok(other) => failures.push(format!(
"{height}/{}: value for {key} is not a canonical plaintext: {:?}",
mapping.name(),
other.to_string()
)),
Err(error) => {
failures.push(format!("{height}/{}: value for {key} does not parse: {error}", mapping.name()))
}
}
}
parsed
}
fn u64_member(plaintext: &Plaintext<N>, member: &str) -> Option<u64> {
match plaintext.find(&[Identifier::from_str(member).unwrap()]).ok()? {
Plaintext::Literal(Literal::U64(value), _) => Some(*value),
_ => None,
}
}
fn address_member(plaintext: &Plaintext<N>, member: &str) -> Option<String> {
match plaintext.find(&[Identifier::from_str(member).unwrap()]).ok()? {
Plaintext::Literal(Literal::Address(address), _) => Some(address.to_string()),
_ => None,
}
}
async fn check_height(compat: &HistoryCompat, height: u32, check_join: bool) -> Vec<String> {
let mut failures = Vec::new();
let mut fetch = |name: &str, result: Result<Arc<MappingSnapshot>, RestError>| match result {
Ok(snapshot) => Some(snapshot),
Err(error) => {
failures.push(format!("{height}/{name}: {error}"));
None
}
};
let bonded = fetch("bonded", compat.mapping(height, SnapshotMapping::Bonded).await);
let delegated = fetch("delegated", compat.mapping(height, SnapshotMapping::Delegated).await);
let metadata = fetch("metadata", compat.mapping(height, SnapshotMapping::Metadata).await);
let unbonding = fetch("unbonding", compat.mapping(height, SnapshotMapping::Unbonding).await);
let withdraw = fetch("withdraw", compat.mapping(height, SnapshotMapping::Withdraw).await);
let rewards = match compat.staking_rewards(height).await {
Ok(rewards) => Some(rewards),
Err(error) => {
failures.push(format!("{height}/stakingrewards: {error}"));
None
}
};
let mut bonded_parsed = HashMap::new();
if let Some(bonded) = &bonded {
for (key, plaintext) in check_canonical(height, SnapshotMapping::Bonded, bonded, &mut failures) {
match (address_member(&plaintext, "validator"), u64_member(&plaintext, "microcredits")) {
(Some(validator), Some(microcredits)) => {
if let Some(delegated) = &delegated
&& !delegated.contains_key(&validator)
{
failures.push(format!(
"{height}/bonded: {key} is bonded to {validator}, which `delegated` lacks"
));
}
bonded_parsed.insert(key, (validator, microcredits));
}
_ => failures.push(format!("{height}/bonded: unexpected value shape for {key}: {plaintext}")),
}
}
if bonded.is_empty() {
failures.push(format!("{height}/bonded: empty"));
}
}
if let Some(delegated) = &delegated {
for (key, plaintext) in check_canonical(height, SnapshotMapping::Delegated, delegated, &mut failures) {
if !matches!(plaintext, Plaintext::Literal(Literal::U64(_), _)) {
failures.push(format!("{height}/delegated: unexpected value shape for {key}: {plaintext}"));
}
}
}
if let Some(metadata) = &metadata {
let keys: HashSet<&str> = metadata.keys().map(String::as_str).collect();
if keys != HashSet::from(METADATA_KEYS) {
failures.push(format!("{height}/metadata: unexpected keys {keys:?}"));
}
for (key, plaintext) in check_canonical(height, SnapshotMapping::Metadata, metadata, &mut failures) {
if !matches!(plaintext, Plaintext::Literal(Literal::U32(_), _)) {
failures.push(format!("{height}/metadata: unexpected value shape for {key}: {plaintext}"));
}
}
}
if let Some(unbonding) = &unbonding {
for (key, plaintext) in check_canonical(height, SnapshotMapping::Unbonding, unbonding, &mut failures) {
let height_member = plaintext.find(&[Identifier::<N>::from_str("height").unwrap()]).ok();
if u64_member(&plaintext, "microcredits").is_none()
|| !matches!(height_member, Some(Plaintext::Literal(Literal::U32(_), _)))
{
failures.push(format!("{height}/unbonding: unexpected value shape for {key}: {plaintext}"));
}
if let Some(withdraw) = &withdraw
&& !withdraw.contains_key(&key)
{
failures.push(format!("{height}/unbonding: {key} is unbonding but `withdraw` lacks it"));
}
}
}
if let Some(withdraw) = &withdraw {
for (key, plaintext) in check_canonical(height, SnapshotMapping::Withdraw, withdraw, &mut failures) {
if !matches!(plaintext, Plaintext::Literal(Literal::Address(_), _)) {
failures.push(format!("{height}/withdraw: unexpected value shape for {key}: {plaintext}"));
}
}
}
if let (Some(rewards), Some(_)) = (&rewards, &bonded) {
let reward_stakers: HashSet<&String> = rewards.keys().collect();
let bonded_stakers: HashSet<&String> = bonded_parsed.keys().collect();
if reward_stakers != bonded_stakers {
let only_rewards = reward_stakers.difference(&bonded_stakers).count();
let only_bonded = bonded_stakers.difference(&reward_stakers).count();
failures.push(format!(
"{height}/stakingrewards: stakers differ from bonded ({only_rewards} only in rewards, {only_bonded} only in bonded)"
));
}
for (staker, (validator, _)) in rewards.iter() {
if let Some((bonded_validator, _)) = bonded_parsed.get(staker)
&& bonded_validator != validator
{
failures.push(format!(
"{height}/stakingrewards: {staker} rewarded via {validator} but bonded to {bonded_validator}"
));
}
}
if check_join && height > 1 {
match compat.mapping(height - 1, SnapshotMapping::Bonded).await {
Ok(previous) => {
let mut mismatches = Vec::new();
for (staker, (_, reward)) in rewards.iter() {
let Some((_, now)) = bonded_parsed.get(staker) else { continue };
let Some(before) = previous
.get(staker)
.and_then(|value| u64_member(&Plaintext::<N>::from_str(value).ok()?, "microcredits"))
else {
continue;
};
if before + reward != *now {
mismatches.push(format!("{staker}: {before} + {reward} != {now}"));
}
}
if mismatches.len() > rewards.len() / 100 + 3 {
failures.push(format!(
"{height}/stakingrewards: bonded@h != bonded@(h-1) + reward for {} of {} stakers, e.g. {:?}",
mismatches.len(),
rewards.len(),
&mismatches[..mismatches.len().min(3)]
));
}
}
Err(error) => failures.push(format!("{}/bonded (for the join check): {error}", height - 1)),
}
}
}
failures
}
#[tokio::test(flavor = "multi_thread")]
#[ignore = "makes ~1,000 requests to the live upstream; run explicitly"]
async fn sweep() {
let base_url = std::env::var("HISTORY_API_URL")
.unwrap_or_else(|_| "https://mainnet.historical-staking.provable.com".to_string());
let compat = Arc::new(HistoryCompat::new(&base_url, "mainnet").unwrap());
let tip: u32 = match std::env::var("HISTORY_SWEEP_TIP") {
Ok(tip) => tip.parse().unwrap(),
Err(_) => reqwest::get("https://api.explorer.provable.com/v1/mainnet/latest/height")
.await
.unwrap()
.text()
.await
.unwrap()
.trim()
.parse()
.unwrap(),
};
let seed: u64 = env_or("HISTORY_SWEEP_SEED", 0);
let random: usize = env_or("HISTORY_SWEEP_RANDOM", 100);
println!("upstream {base_url}, tip {tip}, {random} random heights, seed {seed}");
let mut deliberate: Vec<u32> = vec![1, 2, 3, 10, 100, 1_000, 65_535, 65_536, 65_537];
for (_, boundary) in snarkvm::console::network::MAINNET_V0_CONSENSUS_VERSION_HEIGHTS {
if boundary > 0 && boundary <= tip - TIP_LAG {
deliberate.extend([boundary - 1, boundary, boundary + 1]);
}
}
deliberate.extend([tip - TIP_LAG, tip - TIP_LAG - 1, tip - 50, tip - 1_000]);
let mut rng = StdRng::seed_from_u64(seed);
let random_heights: Vec<u32> = (0..random).map(|_| rng.random_range(1..tip - TIP_LAG)).collect();
let mut tasks = tokio::task::JoinSet::new();
for (index, height) in deliberate.iter().chain(&random_heights).copied().enumerate() {
let compat = compat.clone();
let check_join = index < deliberate.len() || index % 5 == 0;
tasks.spawn(async move { (height, check_height(&compat, height, check_join).await) });
}
let mut failures = Vec::new();
let mut checked = 0;
while let Some(result) = tasks.join_next().await {
let (height, height_failures) = result.unwrap();
checked += 1;
if height_failures.is_empty() {
println!("ok {height}");
} else {
println!("FAIL {height}: {}", height_failures.join("; "));
}
failures.extend(height_failures);
}
match compat.mapping(tip + 1_000_000, SnapshotMapping::Unbonding).await {
Err(RestError::NotFound(_)) => println!("ok {} (missing, as expected)", tip + 1_000_000),
other => failures.push(format!("{}: expected not-found, got {other:?}", tip + 1_000_000)),
}
println!("checked {checked} heights, {} failures", failures.len());
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
}