diff --git a/crates/cardano/src/hacks.rs b/crates/cardano/src/hacks.rs index 2026ab97..9c0647eb 100644 --- a/crates/cardano/src/hacks.rs +++ b/crates/cardano/src/hacks.rs @@ -101,6 +101,29 @@ pub mod proposals { pub fn outcome(protocol: u16, proposal: &str) -> ProposalOutcome { match proposal { + // Committee Update enacted at epoch 1370 + "06dcb60f4b6ee78024bd4c7978e8e093437903198cf06c3c5d34bf825129bc73#0" => { + Ratified(1369) + } + // Parameter Change enacted at epoch 1367 + "dd4fbc61680bd7fa3cf97d815bdfaf54bc9872d2fd0c6f5e9bdb86fef0260c51#0" => { + Ratified(1366) + } + // Committee Update enacted at epoch 1361 + "65c41d163aceb2be0b821b79fa71ee1ead7ecd13bd1ae812baddf5c962c9d62b#0" => { + Ratified(1360) + } + // Parameter Changes superseded at epoch 1367 + "8009f3a24731320244568273a4d7eed1b436067c91aeb98bdb2872e45ef5b1d6#0" + | "8b5f096ad63618f1b73112d8ac46fb59519897cbfa318fdb9ffed2dfcee782e4#0" + | "8bd727c7aab758be609958b9bdaf84096b8f30bc7cfa0956d6a3ddc91b816550#0" => { + Canceled(1367) + } + // Committee actions superseded at epoch 1361 + "1160cd45096980e1985628521aa6a84ac693e2616401dc459b3abe97a140b934#0" + | "2c3657d09e194507a4b120c3aeed4616818ad3875382ba68b4e668e6d3d5d625#0" => { + Canceled(1361) + } // Parameter Change enacted at epoch 1270 "014c32e57347d114744210e1934a2084c5d0052a2312170d93758bfd566f3956#0" => { Ratified(1269) @@ -296,6 +319,18 @@ pub mod proposals { pub fn outcome(protocol: u16, proposal: &str) -> ProposalOutcome { match proposal { + // Parameter Change superseded at epoch 305 + "38b9c1901e472f1318f44a324e74589466d77ff44a63cd35f034aa8998fc53aa#0" => { + Canceled(305) + } + // Parameter Change enacted at epoch 305 + "e641ec802bb109e150e920c6c0387e85f2efd30944a46d08d08212bde540f69c#0" => { + Ratified(304) + } + // Committee Update enacted at epoch 304 + "bbfa303aa35d19919934fb5fe36b174609a0c1dfb47a546ccd6ea58aa752e2aa#0" => { + Ratified(303) + } // Byron intra-era hardfork "9972ffaee13b4afcf1a133434161ce25e8ecaf34b7a76e06b0c642125cf911a9#0" => Ratified(1), // Shelley hardfork @@ -359,6 +394,30 @@ pub mod proposals { pub fn outcome(protocol: u16, proposal: &str) -> ProposalOutcome { match proposal { + // Treasury Withdrawal enacted at epoch 647 + "d8de068952df50c862fa1bce9b8180d3387976cbae0fb2c3d9ef84f0faaf64d6#0" => { + Ratified(646) + } + + // Treasury Withdrawals enacted at epoch 646 + "b3d452bff7769d7f557ec6b8974760ee6c5e496c276652b654032966621e0ccf#2" + | "b3d452bff7769d7f557ec6b8974760ee6c5e496c276652b654032966621e0ccf#3" + | "b3d452bff7769d7f557ec6b8974760ee6c5e496c276652b654032966621e0ccf#4" + | "b3d452bff7769d7f557ec6b8974760ee6c5e496c276652b654032966621e0ccf#5" + | "b3d452bff7769d7f557ec6b8974760ee6c5e496c276652b654032966621e0ccf#6" => { + Ratified(645) + } + + // Treasury Withdrawals enacted at epoch 645 + "fbb8d1a4a8d6b62f8cd706944a0582b884c2b90187b8fada7953d5c6a33eb5a7#0" + | "b3d452bff7769d7f557ec6b8974760ee6c5e496c276652b654032966621e0ccf#1" + | "b3d452bff7769d7f557ec6b8974760ee6c5e496c276652b654032966621e0ccf#7" + | "b3d452bff7769d7f557ec6b8974760ee6c5e496c276652b654032966621e0ccf#8" + | "b3d452bff7769d7f557ec6b8974760ee6c5e496c276652b654032966621e0ccf#9" + | "b3d452bff7769d7f557ec6b8974760ee6c5e496c276652b654032966621e0ccf#10" => { + Ratified(644) + } + // Replace Interim Constitutional Committee "47a0e7a4f9383b1afc2192b23b41824d65ac978d7741aca61fc1fa16833d1111#0" => { Ratified(580) diff --git a/crates/cardano/src/model/epochs.rs b/crates/cardano/src/model/epochs.rs index 07e21f33..695fc6e6 100644 --- a/crates/cardano/src/model/epochs.rs +++ b/crates/cardano/src/model/epochs.rs @@ -24,6 +24,32 @@ pub type Lovelace = u64; pub const CURRENT_EPOCH_KEY: &[u8] = b"0"; +/// CBOR codec for `u128` fields. This version of minicbor has no `u128` type. +/// The codec stores the value as a 16-byte big-endian byte string. +mod cbor_u128 { + use pallas::codec::minicbor::{ + decode::{Decoder, Error as DecodeError}, + encode::{Encoder, Error as EncodeError, Write}, + }; + + pub fn encode( + v: &u128, + e: &mut Encoder, + _ctx: &mut C, + ) -> Result<(), EncodeError> { + e.bytes(&v.to_be_bytes())?; + Ok(()) + } + + pub fn decode(d: &mut Decoder<'_>, _ctx: &mut C) -> Result { + let bytes = d.bytes()?; + let arr: [u8; 16] = bytes + .try_into() + .map_err(|_| DecodeError::message("expected 16-byte u128"))?; + Ok(u128::from_be_bytes(arr)) + } +} + #[derive(Debug, Encode, Decode, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct Nonces { #[n(0)] @@ -140,6 +166,31 @@ pub struct RollingStats { #[n(21)] #[cbor(default)] pub treasury_mirs: Lovelace, + + /// Number of transactions across all blocks minted this epoch. + #[n(22)] + #[cbor(default)] + pub tx_count: u64, + + /// Gross sum of all lovelace in transaction outputs this epoch (matches + /// db-sync's `epoch.out_sum`). The type is u128 because the sum for one + /// epoch can be more than u64 allows, although the outputs of one + /// transaction always fit in u64. + #[n(23)] + #[cbor(default, with = "cbor_u128")] + pub output: u128, + + /// Slot of the first block minted this epoch (0 if the epoch has no block). + /// The reader converts this slot to a wall-clock time with `ChainSummary`. + #[n(24)] + #[cbor(default)] + pub first_block_slot: u64, + + /// Slot of the last block minted this epoch (0 if the epoch has no block). + /// The reader converts this slot to a wall-clock time with `ChainSummary`. + #[n(25)] + #[cbor(default)] + pub last_block_slot: u64, } impl TransitionDefault for RollingStats { @@ -343,12 +394,20 @@ pub(crate) mod testing { consumed_utxos in root::any_lovelace(), gathered_fees in root::any_lovelace(), blocks_minted in 0u32..1000u32, + tx_count in 0u64..100_000u64, + output in 0u128..u128::from(u64::MAX), + first_block_slot in root::any_slot(), + last_block_slot in root::any_slot(), ) -> RollingStats { RollingStats { produced_utxos, consumed_utxos, gathered_fees, blocks_minted, + tx_count, + output, + first_block_slot, + last_block_slot, ..Default::default() } } @@ -442,11 +501,20 @@ pub struct EpochStatsUpdate { pub(crate) reserve_mirs: Lovelace, pub(crate) treasury_mirs: Lovelace, pub(crate) non_overlay_blocks_minted: u32, + pub(crate) tx_count: u64, + pub(crate) output: u128, + pub(crate) block_slot: u64, // undo: did apply create rolling.live from default? Plus the pre-union pool set, which // can't be recovered by set subtraction (a pool in both prev and self would be removed). pub(crate) was_new: bool, pub(crate) prev_registered_pools: HashSet, + + // Undo data for the first and last block slots. A min or max operation is + // not reversible by arithmetic. So `apply` keeps the earlier values here, + // and `undo` restores them. + pub(crate) prev_first_block_slot: u64, + pub(crate) prev_last_block_slot: u64, } impl dolos_core::EntityDelta for EpochStatsUpdate { @@ -484,6 +552,22 @@ impl dolos_core::EntityDelta for EpochStatsUpdate { stats.reserve_mirs += self.reserve_mirs; stats.treasury_mirs += self.treasury_mirs; stats.non_overlay_blocks_minted += self.non_overlay_blocks_minted; + stats.tx_count += self.tx_count; + stats.output += self.output; + + // Keep the earlier slots so `undo` can restore them. The first and last + // slots are a min and a max, which arithmetic cannot reverse. + self.prev_first_block_slot = stats.first_block_slot; + self.prev_last_block_slot = stats.last_block_slot; + + // `first_block_slot` gets a value only once, at the earliest block of + // the epoch. `last_block_slot` moves forward to the most recent block. + // Blocks roll in slot order, so these values are the correct min and + // max. + if stats.first_block_slot == 0 { + stats.first_block_slot = self.block_slot; + } + stats.last_block_slot = self.block_slot; stats.registered_pools = stats .registered_pools @@ -522,6 +606,11 @@ impl dolos_core::EntityDelta for EpochStatsUpdate { stats.reserve_mirs -= self.reserve_mirs; stats.treasury_mirs -= self.treasury_mirs; stats.non_overlay_blocks_minted -= self.non_overlay_blocks_minted; + stats.tx_count -= self.tx_count; + stats.output -= self.output; + + stats.first_block_slot = self.prev_first_block_slot; + stats.last_block_slot = self.prev_last_block_slot; stats.registered_pools = self.prev_registered_pools.clone(); } @@ -1549,10 +1638,14 @@ mod prop_tests { new_accounts in 0u64..100u64, removed_accounts in 0u64..100u64, withdrawals in root::any_lovelace(), + tx_count in 0u64..1000u64, + output in 0u128..u128::from(u64::MAX), + block_slot in root::any_slot(), ) -> EpochStatsUpdate { EpochStatsUpdate { epoch, block_fees, utxo_delta, new_accounts, removed_accounts, withdrawals, + tx_count, output, block_slot, ..EpochStatsUpdate::default() } } diff --git a/crates/cardano/src/roll/epochs.rs b/crates/cardano/src/roll/epochs.rs index 693c3445..d21c661b 100644 --- a/crates/cardano/src/roll/epochs.rs +++ b/crates/cardano/src/roll/epochs.rs @@ -98,6 +98,7 @@ impl BlockVisitor for EpochStateVisitor { ) -> Result<(), ChainError> { self.stats_delta = Some(EpochStatsUpdate { epoch, + block_slot: block.header().slot(), ..Default::default() }); if let Some(stats) = self.stats_delta.as_mut() { @@ -132,6 +133,7 @@ impl BlockVisitor for EpochStateVisitor { let fees = define_tx_fees(tx, utxos)?; self.stats_delta.as_mut().unwrap().block_fees += fees; + self.stats_delta.as_mut().unwrap().tx_count += 1; if let Some(donation) = pallas_extras::tx_treasury_donation(tx) { self.stats_delta.as_mut().unwrap().treasury_donations += donation; @@ -163,7 +165,13 @@ impl BlockVisitor for EpochStateVisitor { output: &pallas::ledger::traverse::MultiEraOutput, ) -> Result<(), ChainError> { let amount = output.value().coin(); - self.stats_delta.as_mut().unwrap().utxo_delta += amount as i64; + let stats = self.stats_delta.as_mut().unwrap(); + stats.utxo_delta += amount as i64; + // Gross, unsigned sum of all produced outputs (db-sync `epoch.out_sum`). + // The driver iterates `tx.produces()`. So this sum includes the regular + // outputs of valid transactions and the collateral-return outputs of + // phase-2 failures. + stats.output += amount as u128; Ok(()) } diff --git a/crates/minibf/src/error.rs b/crates/minibf/src/error.rs index 355cea55..01b5e8d1 100644 --- a/crates/minibf/src/error.rs +++ b/crates/minibf/src/error.rs @@ -16,6 +16,7 @@ pub enum Error { InvalidPoolId, InvalidBlockNumber, InvalidBlockHash, + InvalidEpochNumber, } #[derive(Serialize)] @@ -99,6 +100,15 @@ impl IntoResponse for Error { )), ) .into_response(), + Error::InvalidEpochNumber => ( + StatusCode::BAD_REQUEST, + Json(ErrorBody::new( + 400, + "Bad Request", + "Missing, out of range or malformed epoch_number.", + )), + ) + .into_response(), } } } diff --git a/crates/minibf/src/lib.rs b/crates/minibf/src/lib.rs index e9b033bb..df7d9597 100644 --- a/crates/minibf/src/lib.rs +++ b/crates/minibf/src/lib.rs @@ -6,7 +6,7 @@ use axum::{ }; use dolos_cardano::{ model::{AccountState, AssetState, DRepState, EpochState, FixedNamespace, PoolState}, - ChainSummary, PParamsSet, + ChainSummary, PParamsSet, StakeLog, }; use pallas::{ crypto::hash::Hash, @@ -257,6 +257,62 @@ impl Facade { Ok(out) } + /// The log key is `(slot, pool)`. The range `slot..slot + 1`, with a zeroed + /// entity key on each bound, includes every pool at the start slot of this + /// epoch and no pool from the next epoch. Returns `None` when no log + /// exists. + fn stake_logs_sum_at_epoch( + &self, + epoch: Epoch, + chain_summary: &ChainSummary, + ) -> Result, StatusCode> { + let slot = chain_summary.epoch_start(epoch); + + let start = LogKey::from(TemporalKey::from(slot)); + let end = LogKey::from(TemporalKey::from(slot + 1)); + + let iter = self + .archive() + .iter_logs_typed::(StakeLog::NS, Some(start..end)) + .map_err(log_and_500("failed to iterate stake logs for epoch"))?; + + let mut total = 0u64; + let mut found = false; + for entry in iter { + let (_, log) = entry.map_err(log_and_500("failed to read stake log for epoch"))?; + total += log.total_stake; + found = true; + } + + Ok(found.then_some(total)) + } + + // Dolos starts to write `StakeLog`s only after the stake-snapshot pipeline + // is ready. So the first snapshot epoch has logs, but the epoch just before + // it has none. Both epochs share the same genesis stake distribution, and + // the reference implementation reports the same value for both. For that one + // epoch, this function reads the logs of the next epoch instead. Each + // earlier epoch has no active stake and stays `None`. + pub fn sum_active_stake_for_epoch( + &self, + epoch: Epoch, + chain_summary: &ChainSummary, + ) -> Result, StatusCode> { + if let Some(total) = self.stake_logs_sum_at_epoch(epoch, chain_summary)? { + return Ok(Some(total)); + } + + // This epoch has no logs. The next epoch can be the earliest one with + // logs: this epoch has none, but `epoch + 1` has them. Then this epoch + // shares the genesis stake of that first snapshot. An earlier epoch, + // where neither it nor its successor has logs, has no active stake. + if let Some(next_total) = self.stake_logs_sum_at_epoch(epoch + 1, chain_summary)? { + return Ok(Some(next_total)); + } + + Ok(None) + } + pub fn read_cardano_entity(&self, key: impl Into) -> Result, StatusCode> where T: FixedNamespace, @@ -410,6 +466,16 @@ where "/blocks/slot/{slot_number}", get(routes::blocks::by_slot::), ) + .route("/epochs/latest", get(routes::epochs::latest::)) + .route("/epochs/{epoch}", get(routes::epochs::by_number::)) + .route( + "/epochs/{epoch}/next", + get(routes::epochs::by_number_next::), + ) + .route( + "/epochs/{epoch}/previous", + get(routes::epochs::by_number_previous::), + ) .route( "/epochs/{epoch}/blocks", get(routes::epochs::by_number_blocks::), diff --git a/crates/minibf/src/routes/epochs/mapping.rs b/crates/minibf/src/routes/epochs/mapping.rs index df49ffaa..e9acdee9 100644 --- a/crates/minibf/src/routes/epochs/mapping.rs +++ b/crates/minibf/src/routes/epochs/mapping.rs @@ -4,8 +4,10 @@ use crate::{ mapping::{rational_to_f64, IntoModel}, routes::epochs::cost_models::get_named_cost_model, }; -use blockfrost_openapi::models::epoch_param_content::EpochParamContent; -use dolos_cardano::PParamsSet; +use blockfrost_openapi::models::{ + epoch_content::EpochContent, epoch_param_content::EpochParamContent, +}; +use dolos_cardano::{model::EpochState, PParamsSet}; use dolos_core::Genesis; use pallas::ledger::primitives::{conway::CostModels, Epoch}; @@ -196,3 +198,52 @@ impl<'a> IntoModel for ParametersModelBuilder<'a> { Ok(out) } } + +pub struct EpochContentModelBuilder { + pub state: EpochState, + pub start_time: u64, + pub end_time: u64, + pub first_block_time: u64, + pub last_block_time: u64, + pub tx_count: u64, + pub output: u128, + pub active_stake: Option, +} + +impl IntoModel for EpochContentModelBuilder { + type SortKey = Epoch; + + fn sort_key(&self) -> Option { + Some(self.state.number) + } + + fn into_model(self) -> Result { + let Self { + state, + start_time, + end_time, + first_block_time, + last_block_time, + tx_count, + output, + active_stake, + } = self; + + let rolling = state.rolling.live().cloned().unwrap_or_default(); + + let out = EpochContent { + epoch: state.number as i32, + start_time: start_time as i32, + end_time: end_time as i32, + first_block_time: first_block_time as i32, + last_block_time: last_block_time as i32, + block_count: rolling.blocks_minted as i32, + tx_count: tx_count as i32, + output: output.to_string(), + fees: rolling.gathered_fees.to_string(), + active_stake: active_stake.map(|x| x.to_string()), + }; + + Ok(out) + } +} diff --git a/crates/minibf/src/routes/epochs/mod.rs b/crates/minibf/src/routes/epochs/mod.rs index 8e009c1e..1938c801 100644 --- a/crates/minibf/src/routes/epochs/mod.rs +++ b/crates/minibf/src/routes/epochs/mod.rs @@ -3,9 +3,12 @@ use axum::{ http::StatusCode, Json, }; -use blockfrost_openapi::models::epoch_param_content::EpochParamContent; +use blockfrost_openapi::models::{ + epoch_content::EpochContent, epoch_param_content::EpochParamContent, +}; use pallas::ledger::{primitives::Epoch, traverse::MultiEraBlock}; +use dolos_cardano::{model::EpochState, rupd::StakeSnapshot, ChainSummary, EraProtocol}; use dolos_core::{archive::Skippable as _, ArchiveStore, Domain}; use crate::{ @@ -18,6 +21,263 @@ use crate::{ pub mod cost_models; pub mod mapping; +const MAX_EPOCH_NUMBER: Epoch = i32::MAX as Epoch; + +fn ensure_epoch_in_range(epoch: Epoch) -> Result<(), Error> { + if epoch > MAX_EPOCH_NUMBER { + return Err(Error::InvalidEpochNumber); + } + + Ok(()) +} + +fn build_epoch_content( + domain: &Facade, + chain: &ChainSummary, + epoch: Epoch, + mut state: EpochState, + active_stake: Option, +) -> Result { + // Use the epoch from the caller, not `state.number`. The live `EpochState` + // of the current epoch can hold a number that differs from the number that + // the tip resolves. + state.number = epoch; + + let start_time = chain.slot_time(chain.epoch_start(epoch)); + let end_time = chain.slot_time(chain.epoch_start(epoch + 1)); + + // The roll pipeline precomputes the block aggregates on `RollingStats`, so + // this request needs no block scan. The first and last block times are + // slots, and this function converts them here. A zero slot means the epoch + // had no block. + // + // A Byron epoch boundary block (EBB) does not pass through the roll + // pipeline. So `first_block_slot` is the first *regular* block of the epoch. + // Every Byron epoch opens with an EBB. For these epochs, Blockfrost reports + // the time of the EBB, so `first_block_time` differs. See the systemic EBB + // omission tracked for `/epochs/{n}/blocks` and `/blocks/{block}`. + let rolling = state.rolling.live().cloned().unwrap_or_default(); + let first_block_time = if rolling.first_block_slot == 0 { + 0 + } else { + chain.slot_time(rolling.first_block_slot) + }; + let last_block_time = if rolling.last_block_slot == 0 { + 0 + } else { + chain.slot_time(rolling.last_block_slot) + }; + + let active_stake = match active_stake { + Some(active_stake) => Some(active_stake), + None => domain.sum_active_stake_for_epoch(epoch, chain)?, + }; + + Ok(mapping::EpochContentModelBuilder { + state, + start_time, + end_time, + first_block_time, + last_block_time, + tx_count: rolling.tx_count, + output: rolling.output, + active_stake, + }) +} + +async fn derive_current_active_stake( + domain: &Facade, + chain: &ChainSummary, + current: Epoch, +) -> Result { + // A stake distribution becomes active three epoch boundaries after it is + // live (live -> mark -> set -> go). So the active stake for epoch E is the + // stake that was live at E-2. RUPD applies this same offset one epoch back + // (it scores E-1 from the snapshot at E-3); here we target the current + // epoch, so we read the snapshot at `current - 2`. + let stake_epoch = current.saturating_sub(2); + let protocol = EraProtocol::from(chain.era_for_epoch(stake_epoch.saturating_add(1)).protocol); + let domain = domain.clone(); + + tokio::task::spawn_blocking(move || { + StakeSnapshot::load_globals::(domain.state(), current, stake_epoch, protocol) + .map(|snapshot| snapshot.active_stake_sum) + }) + .await + .map_err(crate::log_and_500( + "failed to join current active stake scan", + ))? + .map_err(crate::log_and_500("failed to derive current active stake")) +} + +fn load_epoch_state( + domain: &Facade, + chain: &ChainSummary, + current: Epoch, + epoch: Epoch, +) -> Result +where + Option: From, +{ + if epoch == current { + dolos_cardano::load_epoch::(domain.state()) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) + } else { + domain + .get_epoch_log(epoch, chain)? + .ok_or(StatusCode::NOT_FOUND) + } +} + +pub async fn latest(State(domain): State>) -> Result, Error> +where + Option: From, +{ + let tip = domain.get_tip_slot()?; + let chain = domain.get_chain_summary()?; + let (current, _) = chain.slot_epoch(tip); + + // The current epoch always has a live `EpochState`, so this never returns a + // 404 error. + let state = load_epoch_state(&domain, &chain, current, current)?; + let active_stake = derive_current_active_stake(&domain, &chain, current).await?; + let model = build_epoch_content(&domain, &chain, current, state, Some(active_stake))?; + + Ok(model.into_response()?) +} + +pub async fn by_number( + State(domain): State>, + Path(epoch): Path, +) -> Result, Error> +where + Option: From, +{ + ensure_epoch_in_range(epoch)?; + + let tip = domain.get_tip_slot()?; + let chain = domain.get_chain_summary()?; + let (current, _) = chain.slot_epoch(tip); + + if epoch > current { + return Err(StatusCode::NOT_FOUND.into()); + } + + let state = load_epoch_state(&domain, &chain, current, epoch)?; + let active_stake = if epoch == current { + Some(derive_current_active_stake(&domain, &chain, current).await?) + } else { + None + }; + let model = build_epoch_content(&domain, &chain, epoch, state, active_stake)?; + + Ok(model.into_response()?) +} + +pub async fn by_number_next( + State(domain): State>, + Path(epoch): Path, + Query(params): Query, +) -> Result>, Error> +where + Option: From, +{ + let pagination = Pagination::try_from(params)?; + ensure_epoch_in_range(epoch)?; + let tip = domain.get_tip_slot()?; + let chain = domain.get_chain_summary()?; + let (current, _) = chain.slot_epoch(tip); + + // The reference epoch must exist for the listing to be valid. + if epoch > current { + return Err(StatusCode::NOT_FOUND.into()); + } + + // Collect the epochs after `epoch`, up to and including the current epoch, + // in ascending order. The pagination selects the window. + let epochs: Vec = ((epoch + 1)..=current) + .skip(pagination.skip()) + .take(pagination.count) + .collect(); + + collect_epoch_contents(&domain, &chain, current, epochs).await +} + +pub async fn by_number_previous( + State(domain): State>, + Path(epoch): Path, + Query(params): Query, +) -> Result>, Error> +where + Option: From, +{ + let pagination = Pagination::try_from(params)?; + ensure_epoch_in_range(epoch)?; + let tip = domain.get_tip_slot()?; + let chain = domain.get_chain_summary()?; + let (current, _) = chain.slot_epoch(tip); + + if epoch > current { + return Err(StatusCode::NOT_FOUND.into()); + } + + // Collect the epochs before `epoch`, from `epoch - 1` backward. The result + // is always in ascending order, the same as the reference implementation. + let count = pagination.count as u64; + let skip = pagination.skip() as u64; + + // The highest and lowest epoch in the page. Both bounds are inclusive. + let high = epoch.saturating_sub(1 + skip); + let low = high.saturating_sub(count.saturating_sub(1)); + + let epochs: Vec = if epoch == 0 || epoch.saturating_sub(1) < skip { + Vec::new() + } else { + (low..=high).collect() + }; + + collect_epoch_contents(&domain, &chain, current, epochs).await +} + +async fn collect_epoch_contents( + domain: &Facade, + chain: &ChainSummary, + current: Epoch, + epochs: Vec, +) -> Result>, Error> +where + Option: From, +{ + let current_active_stake = if epochs.contains(¤t) { + Some(derive_current_active_stake(domain, chain, current).await?) + } else { + None + }; + + let mut out = Vec::with_capacity(epochs.len()); + for epoch in epochs { + let state = if epoch == current { + dolos_cardano::load_epoch::(domain.state()) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + } else { + match domain.get_epoch_log(epoch, chain)? { + Some(state) => state, + None => continue, + } + }; + + let active_stake = if epoch == current { + current_active_stake + } else { + None + }; + let model = build_epoch_content(domain, chain, epoch, state, active_stake)?; + out.push(model.into_model()?); + } + + Ok(Json(out)) +} + pub async fn latest_parameters( State(domain): State>, ) -> Result, Error> { @@ -181,4 +441,203 @@ mod tests { let path = "/epochs/latest/parameters"; assert_status(&app, path, StatusCode::INTERNAL_SERVER_ERROR).await; } + + #[tokio::test] + async fn epochs_latest_happy_path() { + let app = TestApp::new(); + let path = "/epochs/latest"; + let (status, bytes) = app.get_bytes(path).await; + + assert_eq!( + status, + StatusCode::OK, + "unexpected status {status} with body: {}", + String::from_utf8_lossy(&bytes) + ); + + let content: EpochContent = + serde_json::from_slice(&bytes).expect("failed to parse epoch content"); + // The tip of the synthetic chain is in epoch 2, so `latest` resolves to + // epoch 2. + assert_eq!(content.epoch, 2); + assert!(content.start_time < content.end_time); + assert!(content.active_stake.is_some()); + } + + #[tokio::test] + async fn epochs_latest_internal_error() { + let app = TestApp::new_with_fault(Some(TestFault::StateStoreError)); + let path = "/epochs/latest"; + assert_status(&app, path, StatusCode::INTERNAL_SERVER_ERROR).await; + } + + #[tokio::test] + async fn epochs_by_number_happy_path() { + let app = TestApp::new(); + let path = "/epochs/1"; + let (status, bytes) = app.get_bytes(path).await; + + assert_eq!( + status, + StatusCode::OK, + "unexpected status {status} with body: {}", + String::from_utf8_lossy(&bytes) + ); + + let content: EpochContent = + serde_json::from_slice(&bytes).expect("failed to parse epoch content"); + assert_eq!(content.epoch, 1); + // The synthetic chain puts all blocks in epoch 2, so epoch 1 has no + // block. Its aggregates and rolling stats are zero. + assert!(content.start_time < content.end_time); + } + + #[tokio::test] + async fn epochs_by_number_current_has_active_stake() { + let app = TestApp::new(); + let path = "/epochs/2"; + let (status, bytes) = app.get_bytes(path).await; + + assert_eq!( + status, + StatusCode::OK, + "unexpected status {status} with body: {}", + String::from_utf8_lossy(&bytes) + ); + + let content: EpochContent = + serde_json::from_slice(&bytes).expect("failed to parse epoch content"); + assert!(content.active_stake.is_some()); + } + + #[tokio::test] + async fn epochs_by_number_bad_request() { + let app = TestApp::new(); + let path = "/epochs/not-a-number"; + assert_status(&app, path, StatusCode::BAD_REQUEST).await; + } + + #[tokio::test] + async fn epochs_by_number_not_found() { + let app = TestApp::new(); + let path = "/epochs/999999"; + assert_status(&app, path, StatusCode::NOT_FOUND).await; + } + + #[tokio::test] + async fn epochs_by_number_out_of_range() { + // An epoch number greater than the `i32` range of the reference API gets a + // bad-request error, not a 404 error for a missing epoch. + let app = TestApp::new(); + assert_status(&app, "/epochs/696969696969", StatusCode::BAD_REQUEST).await; + assert_status(&app, "/epochs/696969696969/next", StatusCode::BAD_REQUEST).await; + assert_status( + &app, + "/epochs/696969696969/previous", + StatusCode::BAD_REQUEST, + ) + .await; + } + + #[tokio::test] + async fn epochs_by_number_internal_error() { + let app = TestApp::new_with_fault(Some(TestFault::StateStoreError)); + let path = "/epochs/1"; + assert_status(&app, path, StatusCode::INTERNAL_SERVER_ERROR).await; + } + + #[tokio::test] + async fn epochs_by_number_next_happy_path() { + let app = TestApp::new(); + let path = "/epochs/0/next"; + let (status, bytes) = app.get_bytes(path).await; + + assert_eq!( + status, + StatusCode::OK, + "unexpected status {status} with body: {}", + String::from_utf8_lossy(&bytes) + ); + + let content: Vec = + serde_json::from_slice(&bytes).expect("failed to parse epoch content array"); + + // The result is in strict ascending order. Every epoch is greater than the + // requested epoch. + let mut prev = None; + for item in &content { + assert!(item.epoch > 0); + if let Some(prev) = prev { + assert!(item.epoch > prev); + } + prev = Some(item.epoch); + } + } + + #[tokio::test] + async fn epochs_by_number_next_bad_request() { + let app = TestApp::new(); + let path = "/epochs/0/next?count=0"; + assert_status(&app, path, StatusCode::BAD_REQUEST).await; + } + + #[tokio::test] + async fn epochs_by_number_next_not_found() { + let app = TestApp::new(); + let path = "/epochs/999999/next"; + assert_status(&app, path, StatusCode::NOT_FOUND).await; + } + + #[tokio::test] + async fn epochs_by_number_previous_happy_path() { + let app = TestApp::new(); + let path = "/epochs/2/previous"; + let (status, bytes) = app.get_bytes(path).await; + + assert_eq!( + status, + StatusCode::OK, + "unexpected status {status} with body: {}", + String::from_utf8_lossy(&bytes) + ); + + let content: Vec = + serde_json::from_slice(&bytes).expect("failed to parse epoch content array"); + + // Every epoch in the result is before the requested epoch, in ascending + // order. + let mut prev = None; + for item in &content { + assert!(item.epoch < 2); + if let Some(prev) = prev { + assert!(item.epoch > prev); + } + prev = Some(item.epoch); + } + } + + #[tokio::test] + async fn epochs_by_number_previous_of_zero_is_empty() { + let app = TestApp::new(); + let path = "/epochs/0/previous"; + let (status, bytes) = app.get_bytes(path).await; + + assert_eq!( + status, + StatusCode::OK, + "unexpected status {status} with body: {}", + String::from_utf8_lossy(&bytes) + ); + + let content: Vec = + serde_json::from_slice(&bytes).expect("failed to parse epoch content array"); + assert!(content.is_empty()); + } + + #[tokio::test] + async fn epochs_by_number_previous_bad_request() { + let app = TestApp::new(); + let path = "/epochs/2/previous?page=0"; + assert_status(&app, path, StatusCode::BAD_REQUEST).await; + } }