diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f8d1f8b9..8fdc5ebdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to this project will be documented in this file. +## [Unreleased] + +### 🚀 Features + +- *(minibf)* Add `/governance/dreps` endpoint + ## [1.7.0-alpha.1] - 2026-08-24 ### 🚀 Features diff --git a/crates/cardano/src/ewrap/loading.rs b/crates/cardano/src/ewrap/loading.rs index 6e68f8614..829b511f3 100644 --- a/crates/cardano/src/ewrap/loading.rs +++ b/crates/cardano/src/ewrap/loading.rs @@ -1806,6 +1806,7 @@ mod tests { identifier, anchor: None, expiry: None, + first_seen_at: None, } } @@ -2859,6 +2860,7 @@ mod ratification_tests { identifier: drep(), anchor: None, expiry: None, + first_seen_at: Some((0, 0)), }; writer diff --git a/crates/cardano/src/model/dreps.rs b/crates/cardano/src/model/dreps.rs index d91af359c..a5861ac6b 100644 --- a/crates/cardano/src/model/dreps.rs +++ b/crates/cardano/src/model/dreps.rs @@ -12,16 +12,20 @@ use tracing::{debug, warn}; use super::FixedNamespace as _; use crate::pallas_extras; -pub fn drep_to_entity_key(value: &DRep) -> EntityKey { - let bytes = match value { +/// Raw single-prefix-byte encoding of a DRep identity — the bytes the entity +/// key pads and the hex the API renders. +pub fn drep_encoded_bytes(value: &DRep) -> Vec { + match value { DRep::Key(key) => [vec![pallas_extras::DREP_KEY_PREFIX], key.to_vec()].concat(), DRep::Script(key) => [vec![pallas_extras::DREP_SCRIPT_PREFIX], key.to_vec()].concat(), // Invented keys for convenience DRep::Abstain => vec![0], DRep::NoConfidence => vec![1], - }; + } +} - EntityKey::from(bytes) +pub fn drep_to_entity_key(value: &DRep) -> EntityKey { + EntityKey::from(drep_encoded_bytes(value)) } /// Epoch-based DRep expiry, stored exactly as the Haskell ledger stores @@ -116,6 +120,13 @@ pub struct DRepState { // anything else. #[n(8)] pub expiry: Option, + + // Backward-compatible addition: absent in pre-existing rows, decodes as + // `None`. First on-chain reference by any certificate, vote delegations + // included; mirrors db-sync's `drep_hash` insertion order. Index 9 must + // not be reused for anything else. + #[n(9)] + pub first_seen_at: Option<(BlockSlot, TxOrder)>, } impl DRepState { @@ -130,6 +141,7 @@ impl DRepState { identifier, anchor: None, expiry: None, + first_seen_at: None, } } @@ -178,6 +190,7 @@ pub(crate) mod testing { deposit in root::any_lovelace(), anchor in prop::option::of(root::any_anchor()), expiry in prop::option::of(any_drep_expiry()), + first_seen_at in prop::option::of((root::any_slot(), root::any_tx_order())), ) -> DRepState { DRepState { identifier, @@ -189,6 +202,7 @@ pub(crate) mod testing { deposit, anchor, expiry, + first_seen_at, } } } @@ -324,6 +338,70 @@ impl dolos_core::EntityDelta for DRepUnRegistration { } } +/// Records the first on-chain appearance of a DRep, creating the entity if it +/// doesn't exist yet. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DRepSeen { + pub(crate) drep: DRep, + pub(crate) slot: BlockSlot, + pub(crate) txorder: TxOrder, + + // undo + pub(crate) prev_first_seen_at: Option<(BlockSlot, TxOrder)>, + pub(crate) was_new: bool, +} + +impl DRepSeen { + pub fn new(drep: DRep, slot: BlockSlot, txorder: TxOrder) -> Self { + Self { + drep, + slot, + txorder, + prev_first_seen_at: None, + was_new: false, + } + } +} + +impl dolos_core::EntityDelta for DRepSeen { + type Entity = DRepState; + + fn key(&self) -> NsKey { + NsKey::from((DRepState::NS, drep_to_entity_key(&self.drep))) + } + + fn apply(&mut self, entity: &mut Option) { + self.was_new = entity.is_none(); + + let entity = entity.get_or_insert_with(|| DRepState::new(self.drep.clone())); + + // save undo info + self.prev_first_seen_at = entity.first_seen_at; + + // only the earliest sighting counts; a legacy row can predate this + // field, so its lifecycle stamps are earlier on-chain references than + // any new sighting + if entity.first_seen_at.is_none() { + entity.first_seen_at = [ + entity.registered_at, + entity.unregistered_at, + Some((self.slot, self.txorder)), + ] + .into_iter() + .flatten() + .min(); + } + } + + fn undo(&self, entity: &mut Option) { + if self.was_new { + *entity = None; + } else if let Some(state) = entity { + state.first_seen_at = self.prev_first_seen_at; + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DRepActivity { pub(crate) drep: DRep, @@ -771,6 +849,16 @@ mod prop_tests { } } + prop_compose! { + fn any_drep_seen()( + drep in root::any_drep(), + slot in root::any_slot(), + txorder in root::any_tx_order(), + ) -> DRepSeen { + DRepSeen::new(drep, slot, txorder) + } + } + proptest! { #[test] fn drep_registration_roundtrip( @@ -867,6 +955,56 @@ mod prop_tests { ) { root::assert_delta_serde_roundtrip(entity, delta); } + + #[test] + fn drep_seen_roundtrip( + entity in prop::option::of(any_drep_state()), + delta in any_drep_seen(), + ) { + assert_delta_roundtrip(entity, delta); + } + + #[test] + fn drep_seen_serde_roundtrip( + entity in prop::option::of(any_drep_state()), + delta in any_drep_seen(), + ) { + root::assert_delta_serde_roundtrip(entity, delta); + } + } + + #[test] + fn drep_seen_keeps_earliest_sighting() { + use dolos_core::EntityDelta as _; + + let drep = DRep::Key([1u8; 28].into()); + let mut entity = None; + + DRepSeen::new(drep.clone(), 100, 3).apply(&mut entity); + assert_eq!(entity.as_ref().unwrap().first_seen_at, Some((100, 3))); + + // a later sighting must not move the first appearance + DRepSeen::new(drep, 200, 1).apply(&mut entity); + assert_eq!(entity.unwrap().first_seen_at, Some((100, 3))); + } + + #[test] + fn drep_seen_backfills_legacy_rows_from_lifecycle_stamps() { + use dolos_core::EntityDelta as _; + + // a row written before `first_seen_at` existed: the registration is + // an earlier on-chain reference than the sighting that backfills it + let drep = DRep::Key([1u8; 28].into()); + let mut legacy = DRepState::new(drep.clone()); + legacy.registered_at = Some((100, 0)); + let mut entity = Some(legacy); + + let mut seen = DRepSeen::new(drep, 200, 0); + seen.apply(&mut entity); + assert_eq!(entity.as_ref().unwrap().first_seen_at, Some((100, 0))); + + seen.undo(&mut entity); + assert_eq!(entity.unwrap().first_seen_at, None); } } @@ -1002,9 +1140,9 @@ mod compat_tests { use super::*; /// Replica of the on-disk `DRepState` shape before the phase-3 expiry - /// addition (indexes 0..=7). Encoding this and decoding it as the - /// current `DRepState` proves that pre-existing rows keep decoding, - /// with the new field empty. + /// and first-seen additions (indexes 0..=7). Encoding this and decoding + /// it as the current `DRepState` proves that pre-existing rows keep + /// decoding, with the new fields empty. #[derive(Debug, Encode, Decode, Clone, PartialEq, Eq)] struct LegacyDRepState { #[n(0)] @@ -1033,7 +1171,7 @@ mod compat_tests { } #[test] - fn legacy_rows_decode_with_expiry_empty() { + fn legacy_rows_decode_with_new_fields_empty() { let legacy = LegacyDRepState { registered_at: Some((1234, 2)), voting_power: 500_000_000, @@ -1060,6 +1198,7 @@ mod compat_tests { assert_eq!(decoded.identifier, legacy.identifier); assert_eq!(decoded.anchor, legacy.anchor); assert_eq!(decoded.expiry, None); + assert_eq!(decoded.first_seen_at, None); } #[test] @@ -1071,6 +1210,7 @@ mod compat_tests { updated_in: 500, prev: Some(510), }); + state.first_seen_at = Some((100, 1)); let bytes = minicbor::to_vec(&state).unwrap(); let decoded: DRepState = minicbor::decode(&bytes).unwrap(); diff --git a/crates/cardano/src/model/mod.rs b/crates/cardano/src/model/mod.rs index eb274f246..9212dbca9 100644 --- a/crates/cardano/src/model/mod.rs +++ b/crates/cardano/src/model/mod.rs @@ -266,6 +266,9 @@ pub enum CardanoDelta { GovDistrRotate(Box), ProposalResolved(Box), GovDistrBoundaryCredit(Box), + // The WAL stores this enum positionally: append new variants at the end, + // never insert them mid-enum. + DRepSeen(Box), } impl CardanoDelta { @@ -317,6 +320,7 @@ delta_from!(DRepRegistration); delta_from!(DRepUnRegistration); delta_from!(DRepActivity); delta_from!(DRepExpiration); +delta_from!(DRepSeen); delta_from!(WithdrawalInc); delta_from!(VoteDelegation); delta_from!(PParamsUpdate); @@ -393,6 +397,7 @@ impl dolos_core::EntityDelta for CardanoDelta { Self::DRepUnRegistration(x) => x.key(), Self::DRepExpiration(x) => x.key(), Self::DRepAnchorUpdate(x) => x.key(), + Self::DRepSeen(x) => x.key(), Self::WithdrawalInc(x) => x.key(), Self::VoteDelegation(x) => x.key(), Self::PParamsUpdate(x) => x.key(), @@ -462,6 +467,7 @@ impl dolos_core::EntityDelta for CardanoDelta { Self::DRepActivity(x) => Self::downcast_apply(x.as_mut(), entity), Self::DRepExpiration(x) => Self::downcast_apply(x.as_mut(), entity), Self::DRepAnchorUpdate(x) => Self::downcast_apply(x.as_mut(), entity), + Self::DRepSeen(x) => Self::downcast_apply(x.as_mut(), entity), Self::WithdrawalInc(x) => Self::downcast_apply(x.as_mut(), entity), Self::VoteDelegation(x) => Self::downcast_apply(x.as_mut(), entity), Self::PParamsUpdate(x) => Self::downcast_apply(x.as_mut(), entity), @@ -531,6 +537,7 @@ impl dolos_core::EntityDelta for CardanoDelta { Self::DRepActivity(x) => Self::downcast_undo(x.as_ref(), entity), Self::DRepExpiration(x) => Self::downcast_undo(x.as_ref(), entity), Self::DRepAnchorUpdate(x) => Self::downcast_undo(x.as_ref(), entity), + Self::DRepSeen(x) => Self::downcast_undo(x.as_ref(), entity), Self::WithdrawalInc(x) => Self::downcast_undo(x.as_ref(), entity), Self::VoteDelegation(x) => Self::downcast_undo(x.as_ref(), entity), Self::PParamsUpdate(x) => Self::downcast_undo(x.as_ref(), entity), diff --git a/crates/cardano/src/roll/dreps.rs b/crates/cardano/src/roll/dreps.rs index 187fede1b..cad964724 100644 --- a/crates/cardano/src/roll/dreps.rs +++ b/crates/cardano/src/roll/dreps.rs @@ -16,7 +16,7 @@ use crate::{ pallas_extras::{self, stake_cred_to_drep}, roll::BlockVisitor, DRepActivity, DRepAnchorUpdate, DRepDormancyRelease, DRepExpiryUpdate, DRepRegistration, - DRepUnRegistration, GovDormancyReset, PParamsSet, + DRepSeen, DRepUnRegistration, GovDormancyReset, PParamsSet, }; fn cert_drep(cert: &MultiEraCert) -> Option { @@ -224,10 +224,22 @@ impl BlockVisitor for DRepStateVisitor { } } + // Sightings mirror db-sync's `drep_hash` rows, and db-sync does not + // apply certs from phase-2-invalid txs. + if tx.is_valid() { + if let Some(cert) = pallas_extras::cert_as_vote_delegation(cert) { + deltas.add_for_entity(DRepSeen::new(cert.drep, block.slot(), *order)); + } + } + let Some(drep) = cert_drep(cert) else { return Ok(()); }; + if tx.is_valid() { + deltas.add_for_entity(DRepSeen::new(drep.clone(), block.slot(), *order)); + } + if let MultiEraCert::Conway(conway) = &cert { match conway.deref().deref() { conway::Certificate::RegDRepCert(_, deposit, anchor) => { diff --git a/crates/minibf/src/lib.rs b/crates/minibf/src/lib.rs index c14e85ba9..c6413f2ab 100644 --- a/crates/minibf/src/lib.rs +++ b/crates/minibf/src/lib.rs @@ -605,6 +605,7 @@ where .route("/pools/retired", get(routes::pools::all_retired::)) .route("/pools", get(routes::pools::all::)) .route("/pools/{id}", get(routes::pools::by_id::)) + .route("/governance/dreps", get(routes::governance::all_dreps::)) .route( "/governance/dreps/{drep_id}", get(routes::governance::drep_by_id::), diff --git a/crates/minibf/src/mapping.rs b/crates/minibf/src/mapping.rs index f45ca7692..b91ab348f 100644 --- a/crates/minibf/src/mapping.rs +++ b/crates/minibf/src/mapping.rs @@ -83,7 +83,7 @@ pub fn rational_to_f64(val: &alonzo::RationalNumber) -> f64 round_f64::(res) } -const DREP_HRP: bech32::Hrp = bech32::Hrp::parse_unchecked("drep"); +pub const DREP_HRP: bech32::Hrp = bech32::Hrp::parse_unchecked("drep"); const POOL_HRP: bech32::Hrp = bech32::Hrp::parse_unchecked("pool"); const ASSET_HRP: bech32::Hrp = bech32::Hrp::parse_unchecked("asset"); const CALIDUS_HRP: bech32::Hrp = bech32::Hrp::parse_unchecked("calidus"); diff --git a/crates/minibf/src/routes/governance.rs b/crates/minibf/src/routes/governance.rs deleted file mode 100644 index 9c61e2ed1..000000000 --- a/crates/minibf/src/routes/governance.rs +++ /dev/null @@ -1,281 +0,0 @@ -use axum::{ - extract::{Path, State}, - http::StatusCode, - Json, -}; -use dolos_cardano::{model::DRepState, pallas_extras, ChainSummary, PParamsSet}; -use dolos_core::{ArchiveStore as _, BlockSlot, Domain}; -use pallas::ledger::primitives::Epoch; - -use crate::{ - mapping::{bech32, IntoModel}, - Facade, -}; - -fn parse_drep_id(drep_id: &str) -> Result<(String, Vec, bool, bool), StatusCode> { - match drep_id { - "drep_always_abstain" => Ok((drep_id.to_string(), vec![0], false, true)), - "drep_always_no_confidence" => Ok((drep_id.to_string(), vec![1], false, true)), - drep_id => { - let (hrp, payload) = bech32::decode(drep_id).map_err(|_| StatusCode::BAD_REQUEST)?; - - match (hrp.as_str(), payload.len()) { - ("drep", 29) => { - let header_byte = payload.first().ok_or(StatusCode::BAD_REQUEST)?; - - // first 4 bits need to be equal to 0010 - if header_byte & 0b11110000 != 0b00100000 { - return Err(StatusCode::BAD_REQUEST); - } - - Ok((drep_id.to_string(), payload, false, false)) - } - ("drep", 28) => Ok(( - drep_id.to_string(), - [vec![pallas_extras::DREP_KEY_PREFIX], payload].concat(), - true, - false, - )), - ("drep_vkh", 28) => Ok(( - bech32(bech32::Hrp::parse("drep").unwrap(), &payload) - .map_err(|_| StatusCode::BAD_REQUEST)?, - [vec![pallas_extras::DREP_KEY_PREFIX], payload].concat(), - true, - false, - )), - ("drep_script", 28) => Ok(( - bech32(bech32::Hrp::parse("drep").unwrap(), &payload) - .map_err(|_| StatusCode::BAD_REQUEST)?, - [vec![pallas_extras::DREP_SCRIPT_PREFIX], payload].concat(), - true, - false, - )), - _ => Err(StatusCode::BAD_REQUEST), - } - } - } -} - -pub struct DrepModelBuilder<'a> { - drep_id: String, - drep_id_encoded: Vec, - is_legacy: bool, - state: Option, - pparams: PParamsSet, - chain: &'a ChainSummary, - tip: BlockSlot, -} - -impl<'a> DrepModelBuilder<'a> { - fn is_special_case(&self) -> bool { - ["drep_always_abstain", "drep_always_no_confidence"].contains(&self.drep_id.as_str()) - } - - fn first_active_epoch(&self) -> Option { - if self.is_special_case() { - return None; - } - - if self - .state - .as_ref() - .map(|x| x.is_unregistered()) - .unwrap_or(true) - { - return None; - } - - self.state - .as_ref()? - .registered_at - .map(|x| self.chain.slot_epoch(x.0).0) - } - - fn last_active_epoch(&self) -> Option { - if self.is_special_case() { - return None; - } - - self.state - .as_ref()? - .last_active_slot - .map(|x| self.chain.slot_epoch(x).0) - } - - fn is_drep_expired(&self) -> bool { - if self.is_special_case() { - return false; - } - - if self.is_drep_retired() { - return false; - } - - let last_active_epoch = self.last_active_epoch(); - - let inactivity_period = self.pparams.drep_inactivity_period().unwrap_or_default(); - - let expiring_epoch = last_active_epoch.map(|x| x + inactivity_period); - - let (current_epoch, _) = self.chain.slot_epoch(self.tip); - - expiring_epoch - .map(|expiration| expiration <= current_epoch) - .unwrap_or(false) - } - - fn is_drep_retired(&self) -> bool { - if self.is_special_case() { - return false; - } - - let Some(state) = self.state.as_ref() else { - return false; - }; - - match (state.registered_at, state.unregistered_at) { - (Some(registered), Some(unregistered)) => unregistered > registered, - (Some(_), None) => false, - _ => false, - } - } - - fn is_drep_active(&self) -> bool { - !self.is_drep_retired() - } -} - -impl<'a> IntoModel for DrepModelBuilder<'a> { - type SortKey = (); - - fn into_model(self) -> Result { - let expired = self.is_drep_expired(); - - let out = blockfrost_openapi::models::drep::Drep { - drep_id: self.drep_id.clone(), - hex: if self.is_special_case() { - "".to_string() - } else if self.is_legacy { - hex::encode(&self.drep_id_encoded[1..]) - } else { - hex::encode(&self.drep_id_encoded) - }, - amount: self - .state - .as_ref() - .map(|x| x.voting_power.to_string()) - .unwrap_or_default(), - active: self.is_drep_active(), - active_epoch: self.first_active_epoch().map(|x| x as i32), - has_script: pallas_extras::drep_id_is_script(&self.drep_id_encoded), - retired: self.is_drep_retired(), - expired, - last_active_epoch: self.last_active_epoch().map(|x| x as i32), - }; - - Ok(out) - } -} - -pub async fn drep_by_id( - Path(drep): Path, - State(domain): State>, -) -> Result, StatusCode> -where - Option: From, -{ - let (drep, drep_bytes, is_legacy, is_special_case) = - parse_drep_id(&drep).map_err(|_| StatusCode::BAD_REQUEST)?; - - let drep_state = if is_special_case { - None - } else { - Some( - domain - .read_cardano_entity::(drep_bytes.clone())? - .ok_or(StatusCode::NOT_FOUND)?, - ) - }; - - let chain = domain - .get_chain_summary() - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - let (tip, _) = domain - .archive() - .get_tip() - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - .ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; - - let pparams = domain.get_current_effective_pparams()?; - - let model = DrepModelBuilder { - drep_id: drep, - drep_id_encoded: drep_bytes, - is_legacy, - state: drep_state, - pparams, - chain: &chain, - tip, - }; - - model.into_response() -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::test_support::{TestApp, TestFault}; - use bech32::{Bech32, Hrp}; - - fn invalid_drep() -> &'static str { - "not-a-drep" - } - - fn missing_drep() -> String { - let mut payload = Vec::with_capacity(29); - payload.push(0b00100010); - payload.extend_from_slice(&[8u8; 28]); - let hrp = Hrp::parse_unchecked("drep"); - bech32::encode::(hrp, &payload).expect("failed to encode missing drep") - } - - async fn assert_status(app: &TestApp, path: &str, expected: StatusCode) { - let (status, _body) = app.get_bytes(path).await; - assert_eq!(status, expected); - } - - #[tokio::test] - async fn governance_drep_happy_path() { - let app = TestApp::new(); - let drep = &app.vectors().drep_id; - let path = format!("/governance/dreps/{drep}"); - let (status, body) = app.get_bytes(&path).await; - assert_eq!(status, StatusCode::OK); - let _model: blockfrost_openapi::models::drep::Drep = - serde_json::from_slice(&body).expect("failed to parse drep model"); - } - - #[tokio::test] - async fn governance_drep_bad_request() { - let app = TestApp::new(); - let path = format!("/governance/dreps/{}", invalid_drep()); - assert_status(&app, &path, StatusCode::BAD_REQUEST).await; - } - - #[tokio::test] - async fn governance_drep_not_found() { - let app = TestApp::new(); - let missing = missing_drep(); - let path = format!("/governance/dreps/{missing}"); - assert_status(&app, &path, StatusCode::NOT_FOUND).await; - } - - #[tokio::test] - async fn governance_drep_internal_error() { - let app = TestApp::new_with_fault(Some(TestFault::StateStoreError)); - let drep = &app.vectors().drep_id; - let path = format!("/governance/dreps/{drep}"); - assert_status(&app, &path, StatusCode::INTERNAL_SERVER_ERROR).await; - } -} diff --git a/crates/minibf/src/routes/governance/dreps.rs b/crates/minibf/src/routes/governance/dreps.rs new file mode 100644 index 000000000..e0afb8b53 --- /dev/null +++ b/crates/minibf/src/routes/governance/dreps.rs @@ -0,0 +1,364 @@ +use crate::mapping::{bech32, bech32_drep, IntoModel, DREP_HRP}; +use axum::http::StatusCode; +use blockfrost_openapi::models::{Drep, DrepsInner}; +use dolos_cardano::{ + model::{drep_encoded_bytes, DRepState}, + pallas_extras, ChainSummary, PParamsSet, +}; +use dolos_core::BlockSlot; +use pallas::ledger::primitives::{conway::DRep, Epoch}; + +pub const DREP_ALWAYS_ABSTAIN: &str = "drep_always_abstain"; +pub const DREP_ALWAYS_NO_CONFIDENCE: &str = "drep_always_no_confidence"; + +#[derive(Debug, PartialEq, Eq)] +pub struct ParsedDRep { + pub drep_id: String, + pub encoded: Vec, + pub is_legacy: bool, + pub is_special: bool, +} + +impl ParsedDRep { + fn special(drep_id: &str, key: u8) -> Self { + Self { + drep_id: drep_id.to_string(), + encoded: vec![key], + is_legacy: false, + is_special: true, + } + } + + fn cip129(drep_id: &str, encoded: Vec) -> Self { + Self { + drep_id: drep_id.to_string(), + encoded, + is_legacy: false, + is_special: false, + } + } + + fn legacy(drep_id: String, hash: Vec, prefix: u8) -> Self { + Self { + drep_id, + encoded: [vec![prefix], hash].concat(), + is_legacy: true, + is_special: false, + } + } +} + +pub fn parse_drep_id(drep_id: &str) -> Result { + match drep_id { + DREP_ALWAYS_ABSTAIN => Ok(ParsedDRep::special(drep_id, 0)), + DREP_ALWAYS_NO_CONFIDENCE => Ok(ParsedDRep::special(drep_id, 1)), + drep_id => { + let (hrp, payload) = bech32::decode(drep_id).map_err(|_| StatusCode::BAD_REQUEST)?; + + match (hrp.as_str(), payload.len()) { + ("drep", 29) => { + let header_byte = payload.first().ok_or(StatusCode::BAD_REQUEST)?; + + // CIP-129 defines exactly two DRep headers: 0x22 (key + // hash) and 0x23 (script hash); credential values 0 and 1 + // are reserved. Blockfrost rejects everything else. + let valid_headers = [ + pallas_extras::DREP_KEY_PREFIX, + pallas_extras::DREP_SCRIPT_PREFIX, + ]; + + if !valid_headers.contains(header_byte) { + return Err(StatusCode::BAD_REQUEST); + } + + Ok(ParsedDRep::cip129(drep_id, payload)) + } + ("drep", 28) => Ok(ParsedDRep::legacy( + drep_id.to_string(), + payload, + pallas_extras::DREP_KEY_PREFIX, + )), + // Blockfrost accepts only the `drep` and `drep_script` + // prefixes; `drep_vkh` gets 400 there, so it gets 400 here. + ("drep_script", 28) => Ok(ParsedDRep::legacy( + bech32(DREP_HRP, &payload).map_err(|_| StatusCode::BAD_REQUEST)?, + payload, + pallas_extras::DREP_SCRIPT_PREFIX, + )), + _ => Err(StatusCode::BAD_REQUEST), + } + } + } +} + +/// Blockfrost's `retired` flag: the latest lifecycle event is an +/// unregistration. Special DReps never hold a `registered_at`, so they never +/// read as retired. +pub fn drep_is_retired(state: &DRepState) -> bool { + state.is_unregistered() +} + +/// Blockfrost's `expired` flag: a still-registered DRep whose last activity +/// lies more than `drep_activity` epochs behind the tip. The epoch where the +/// two are exactly equal is still active. +pub fn drep_is_expired( + state: &DRepState, + chain: &ChainSummary, + tip: BlockSlot, + pparams: &PParamsSet, +) -> bool { + if drep_is_retired(state) { + return false; + } + + let last_active_epoch = state.last_active_slot.map(|x| chain.slot_epoch(x).0); + let inactivity_period = pparams.drep_inactivity_period().unwrap_or_default(); + let expiring_epoch = last_active_epoch.map(|x| x + inactivity_period); + let (current_epoch, _) = chain.slot_epoch(tip); + + expiring_epoch + .map(|expiration| expiration < current_epoch) + .unwrap_or(false) +} + +pub struct DrepModelBuilder<'a> { + pub drep_id: String, + pub drep_id_encoded: Vec, + pub is_legacy: bool, + pub is_special: bool, + pub state: Option, + pub pparams: &'a PParamsSet, + pub chain: &'a ChainSummary, + pub tip: BlockSlot, +} + +impl<'a> DrepModelBuilder<'a> { + fn first_active_epoch(&self) -> Option { + if self.is_special { + return None; + } + + if self + .state + .as_ref() + .map(|x| x.is_unregistered()) + .unwrap_or(true) + { + return None; + } + + self.state + .as_ref()? + .registered_at + .map(|x| self.chain.slot_epoch(x.0).0) + } + + fn last_active_epoch(&self) -> Option { + if self.is_special { + return None; + } + + self.state + .as_ref()? + .last_active_slot + .map(|x| self.chain.slot_epoch(x).0) + } + + fn is_drep_expired(&self) -> bool { + if self.is_special { + return false; + } + + self.state + .as_ref() + .map(|state| drep_is_expired(state, self.chain, self.tip, self.pparams)) + .unwrap_or(false) + } + + fn is_drep_retired(&self) -> bool { + if self.is_special { + return false; + } + + self.state.as_ref().map(drep_is_retired).unwrap_or(false) + } + + fn is_drep_active(&self) -> bool { + !self.is_drep_retired() + } + + fn hex_value(&self) -> String { + if self.is_special { + "".to_string() + } else if self.is_legacy { + hex::encode(&self.drep_id_encoded[1..]) + } else { + hex::encode(&self.drep_id_encoded) + } + } + + /// The boundary pass stores the ledger-exact `drep_distr` snapshot in + /// `DRepState.voting_power`; serving it keeps the API on the corrected + /// value instead of a live re-aggregation. + fn amount(&self) -> String { + self.state + .as_ref() + .map(|x| x.voting_power) + .unwrap_or_default() + .to_string() + } +} + +impl<'a> IntoModel for DrepModelBuilder<'a> { + type SortKey = (); + + fn into_model(self) -> Result { + let out = Drep { + drep_id: self.drep_id.clone(), + hex: self.hex_value(), + amount: self.amount(), + active: self.is_drep_active(), + active_epoch: self.first_active_epoch().map(|x| x as i32), + has_script: pallas_extras::drep_id_is_script(&self.drep_id_encoded), + retired: self.is_drep_retired(), + expired: self.is_drep_expired(), + last_active_epoch: self.last_active_epoch().map(|x| x as i32), + }; + + Ok(out) + } +} + +impl<'a> IntoModel for DrepModelBuilder<'a> { + type SortKey = (); + + fn into_model(self) -> Result { + let out = DrepsInner { + drep_id: self.drep_id.clone(), + hex: self.hex_value(), + amount: self.amount(), + has_script: pallas_extras::drep_id_is_script(&self.drep_id_encoded), + retired: self.is_drep_retired(), + expired: self.is_drep_expired(), + last_active_epoch: self.last_active_epoch().map(|x| x as i32), + // off-chain metadata is fetched and attached by the caller + metadata: None, + }; + + Ok(out) + } +} + +pub fn drep_list_item( + state: DRepState, + pparams: &PParamsSet, + chain: &ChainSummary, + tip: BlockSlot, +) -> Result { + let drep_id = bech32_drep(&state.identifier)?; + let drep_id_encoded = drep_encoded_bytes(&state.identifier); + let is_special = matches!(state.identifier, DRep::Abstain | DRep::NoConfidence); + + let builder = DrepModelBuilder { + drep_id, + drep_id_encoded, + is_legacy: false, + is_special, + state: Some(state), + pparams, + chain, + tip, + }; + + builder.into_model() +} + +#[cfg(test)] +mod tests { + use super::*; + use bech32::{Bech32, Hrp}; + + fn encode_id(hrp: &str, payload: &[u8]) -> String { + let hrp = Hrp::parse_unchecked(hrp); + bech32::encode::(hrp, payload).expect("failed to encode bech32 id") + } + + #[test] + fn parse_drep_id_special_cases() { + assert_eq!( + parse_drep_id(DREP_ALWAYS_ABSTAIN), + Ok(ParsedDRep::special(DREP_ALWAYS_ABSTAIN, 0)) + ); + + assert_eq!( + parse_drep_id(DREP_ALWAYS_NO_CONFIDENCE), + Ok(ParsedDRep::special(DREP_ALWAYS_NO_CONFIDENCE, 1)) + ); + } + + #[test] + fn parse_drep_id_cip105_key() { + let hash = vec![7u8; 28]; + let drep_id = encode_id("drep", &hash); + + assert_eq!( + parse_drep_id(&drep_id), + Ok(ParsedDRep::legacy( + drep_id.clone(), + hash, + pallas_extras::DREP_KEY_PREFIX, + )) + ); + } + + #[test] + fn parse_drep_id_normalizes_script() { + let hash = vec![7u8; 28]; + let cip105 = encode_id("drep", &hash); + + assert_eq!( + parse_drep_id(&encode_id("drep_script", &hash)), + Ok(ParsedDRep::legacy( + cip105, + hash, + pallas_extras::DREP_SCRIPT_PREFIX, + )) + ); + } + + #[test] + fn parse_drep_id_cip129_accepts_only_key_and_script_headers() { + let hash = vec![7u8; 28]; + + for header in [ + pallas_extras::DREP_KEY_PREFIX, + pallas_extras::DREP_SCRIPT_PREFIX, + ] { + let payload = [vec![header], hash.clone()].concat(); + assert!(parse_drep_id(&encode_id("drep", &payload)).is_ok()); + } + + // upper nibble matches, credential nibble is reserved or invalid + for header in [0x20u8, 0x21, 0x24, 0x2f] { + let payload = [vec![header], hash.clone()].concat(); + assert_eq!( + parse_drep_id(&encode_id("drep", &payload)), + Err(StatusCode::BAD_REQUEST) + ); + } + } + + #[test] + fn parse_drep_id_rejects_malformed_ids() { + // not bech32 + assert!(parse_drep_id("not-a-drep").is_err()); + // wrong hrp + assert!(parse_drep_id(&encode_id("pool", &[7u8; 28])).is_err()); + // Blockfrost does not accept the drep_vkh prefix + assert!(parse_drep_id(&encode_id("drep_vkh", &[7u8; 28])).is_err()); + // wrong payload + assert!(parse_drep_id(&encode_id("drep", &[7u8; 27])).is_err()); + assert!(parse_drep_id(&encode_id("drep", &[7u8; 30])).is_err()); + assert!(parse_drep_id(&encode_id("drep_script", &[7u8; 29])).is_err()); + } +} diff --git a/crates/minibf/src/routes/governance/metadata.rs b/crates/minibf/src/routes/governance/metadata.rs new file mode 100644 index 000000000..f2e580ec4 --- /dev/null +++ b/crates/minibf/src/routes/governance/metadata.rs @@ -0,0 +1,264 @@ +use axum::http::StatusCode; +use blockfrost_openapi::models::{ + dreps_inner_metadata_error::Code as MetadataError, DrepsInnerMetadata, DrepsInnerMetadataError, +}; +use pallas::{crypto::hash::Hasher, ledger::primitives::conway::Anchor}; +use std::{net::IpAddr, sync::OnceLock, time::Duration}; + +const MAX_METADATA_BYTES: usize = 1024 * 1024; + +fn hash_mismatch_error( + url: &str, + expected_hash: &[u8], + actual_hash: &[u8], +) -> DrepsInnerMetadataError { + DrepsInnerMetadataError::new( + MetadataError::HashMismatch, + format!( + "Hash mismatch when fetching metadata from {url}. Expected \"{}\" but got \"{}\".", + hex::encode(expected_hash), + hex::encode(actual_hash), + ), + ) +} + +fn http_response_error(url: &str, status: StatusCode) -> DrepsInnerMetadataError { + let reason = status.canonical_reason().unwrap_or("Unknown"); + + DrepsInnerMetadataError::new( + MetadataError::HttpResponseError, + format!( + "Error Offchain DRep: HTTP response error from {url} resulted in HTTP status code: {} \"{reason}\"", + status.as_u16(), + ), + ) +} + +fn connection_error(url: &str) -> DrepsInnerMetadataError { + DrepsInnerMetadataError::new( + MetadataError::ConnectionError, + format!("Error Offchain Drep: Connection failure error when fetching metadata from {url}."), + ) +} + +fn size_exceeded_error(url: &str) -> DrepsInnerMetadataError { + DrepsInnerMetadataError::new( + MetadataError::SizeExceeded, + format!( + "Error Offchain Drep: Metadata from {url} exceeds the maximum allowed size of {MAX_METADATA_BYTES} bytes." + ), + ) +} + +fn blocked_url_error(url: &str) -> DrepsInnerMetadataError { + DrepsInnerMetadataError::new( + MetadataError::ConnectionError, + format!("Error Offchain Drep: Refused to fetch metadata from {url}, only public http and https URLs are allowed."), + ) +} + +fn decode_error(url: &str) -> DrepsInnerMetadataError { + DrepsInnerMetadataError::new( + MetadataError::DecodeError, + format!( + "Error Offchain Drep: Failed to decode metadata from {url}, payload is not valid JSON." + ), + ) +} + +/// The anchor URL is attacker-controlled on-chain data; an address in one of +/// these ranges would let a DRep aim the node's own network position +/// (cloud metadata services, localhost daemons, LAN hosts). +fn ip_is_public(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(ip) => { + let cgnat = ip.octets()[0] == 100 && (ip.octets()[1] & 0b1100_0000) == 0b0100_0000; + + !(ip.is_loopback() + || ip.is_private() + || ip.is_link_local() + || ip.is_unspecified() + || ip.is_broadcast() + || ip.is_documentation() + || cgnat) + } + IpAddr::V6(ip) => { + let unique_local = (ip.segments()[0] & 0xfe00) == 0xfc00; + let link_local = (ip.segments()[0] & 0xffc0) == 0xfe80; + + !(ip.is_loopback() || ip.is_unspecified() || unique_local || link_local) + } + } +} + +fn is_fetchable(url: &str) -> bool { + let Ok(parsed) = reqwest::Url::parse(url) else { + return false; + }; + + if !matches!(parsed.scheme(), "http" | "https") { + return false; + } + + let Some(host) = parsed.host_str() else { + return false; + }; + + // IPv6 hosts keep their brackets in `host_str` + let host = host.trim_start_matches('[').trim_end_matches(']'); + + match host.parse::() { + Ok(ip) => ip_is_public(ip), + Err(_) => !host.eq_ignore_ascii_case("localhost"), + } +} + +fn http_client() -> Option<&'static reqwest::Client> { + static CLIENT: OnceLock = OnceLock::new(); + + if let Some(client) = CLIENT.get() { + return Some(client); + } + + // built outside `get_or_init` so a failed build is retried on the next + // call instead of pinning every future fetch to a connection error + let built = reqwest::Client::builder() + .timeout(Duration::from_secs(5)) + // every redirect hop gets the same public-URL gate as the anchor + .redirect(reqwest::redirect::Policy::custom(|attempt| { + if attempt.previous().len() > 3 { + attempt.error("too many redirects") + } else if !is_fetchable(attempt.url().as_str()) { + attempt.error("redirect to a non-public URL") + } else { + attempt.follow() + } + })) + .user_agent("Dolos MiniBF") + .build() + .ok()?; + + Some(CLIENT.get_or_init(|| built)) +} + +fn errored( + mut out: DrepsInnerMetadata, + error: DrepsInnerMetadataError, +) -> Option { + out.error = Some(Box::new(error)); + Some(out) +} + +pub async fn fetch_drep_metadata(anchor: Option) -> Option { + let anchor = anchor?; + + let mut out = DrepsInnerMetadata { + url: anchor.url.clone(), + hash: hex::encode(anchor.content_hash), + json_metadata: None, + bytes: None, + error: None, + }; + + let Some(client) = http_client() else { + return errored(out, connection_error(&anchor.url)); + }; + + if !is_fetchable(&anchor.url) { + return errored(out, blocked_url_error(&anchor.url)); + } + + let mut response = match client.get(&anchor.url).send().await { + Ok(response) => response, + Err(_) => return errored(out, connection_error(&anchor.url)), + }; + + if !response.status().is_success() { + return errored(out, http_response_error(&anchor.url, response.status())); + } + + if response + .content_length() + .is_some_and(|len| len > MAX_METADATA_BYTES as u64) + { + return errored(out, size_exceeded_error(&anchor.url)); + } + + let mut body = Vec::new(); + + loop { + match response.chunk().await { + Ok(Some(chunk)) => { + if body.len() + chunk.len() > MAX_METADATA_BYTES { + return errored(out, size_exceeded_error(&anchor.url)); + } + + body.extend_from_slice(&chunk); + } + Ok(None) => break, + Err(_) => return errored(out, connection_error(&anchor.url)), + } + } + + let actual_hash = Hasher::<256>::hash(&body); + + if actual_hash.as_ref() != anchor.content_hash.as_slice() { + return errored( + out, + hash_mismatch_error( + &anchor.url, + anchor.content_hash.as_slice(), + actual_hash.as_ref(), + ), + ); + } + + match serde_json::from_slice(&body) { + Ok(json) => { + out.json_metadata = Some(json); + out.bytes = Some(format!("\\x{}", hex::encode(&body))); + } + Err(_) => { + // the spec keeps `json_metadata` and `bytes` null on failed + // validation and reports the failure through `error` + return errored(out, decode_error(&anchor.url)); + } + } + + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_non_http_schemes() { + assert!(!is_fetchable("file:///etc/passwd")); + assert!(!is_fetchable("ftp://example.com/x")); + assert!(!is_fetchable("not a url")); + } + + #[test] + fn rejects_non_public_hosts() { + assert!(!is_fetchable("http://127.0.0.1:8080/meta.json")); + assert!(!is_fetchable("http://localhost:3000/meta.json")); + assert!(!is_fetchable("http://169.254.169.254/latest/meta-data")); + assert!(!is_fetchable("http://10.1.2.3/meta.json")); + assert!(!is_fetchable("http://172.16.0.1/meta.json")); + assert!(!is_fetchable("http://192.168.1.1/meta.json")); + assert!(!is_fetchable("http://100.64.0.1/meta.json")); + assert!(!is_fetchable("http://0.0.0.0/meta.json")); + assert!(!is_fetchable("https://[::1]/meta.json")); + assert!(!is_fetchable("https://[fe80::1]/meta.json")); + assert!(!is_fetchable("https://[fd00::1]/meta.json")); + } + + #[test] + fn accepts_http_urls() { + assert!(is_fetchable("https://example.com/meta.json")); + assert!(is_fetchable("http://example.com/meta.json")); + assert!(is_fetchable("https://93.184.216.34/meta.json")); + assert!(is_fetchable("http://100.128.0.1/meta.json")); + } +} diff --git a/crates/minibf/src/routes/governance/mod.rs b/crates/minibf/src/routes/governance/mod.rs new file mode 100644 index 000000000..400b51202 --- /dev/null +++ b/crates/minibf/src/routes/governance/mod.rs @@ -0,0 +1,476 @@ +mod dreps; +mod metadata; + +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + Json, +}; +use blockfrost_openapi::models::DrepsInner; +use dolos_cardano::{model::DRepState, ChainSummary, PParamsSet}; +use dolos_core::{BlockSlot, Domain}; +use dreps::{drep_is_expired, drep_is_retired, drep_list_item, parse_drep_id, DrepModelBuilder}; +use futures::future::join_all; +use metadata::fetch_drep_metadata; +use serde::Deserialize; + +use crate::{ + error::Error, + mapping::IntoModel as _, + pagination::{Order, Pagination, PaginationParameters}, + Facade, +}; + +fn chain_context( + domain: &Facade, +) -> Result<(ChainSummary, BlockSlot, PParamsSet), StatusCode> { + let chain = domain.get_chain_summary()?; + let tip = domain.get_tip_slot()?; + let pparams = domain.get_current_effective_pparams()?; + + Ok((chain, tip, pparams)) +} + +/// Query parameters of `/governance/dreps`: the shared pagination set plus +/// the endpoint's own `order_by`, `retired` and `expired`. Blockfrost does +/// not define `from`/`to` here. +#[derive(Debug, Deserialize)] +pub struct DrepsListParameters { + pub count: Option, + pub page: Option, + pub order: Option, + pub order_by: Option, + pub retired: Option, + pub expired: Option, +} + +impl DrepsListParameters { + fn pagination(&self) -> PaginationParameters { + PaginationParameters { + count: self.count.clone(), + page: self.page.clone(), + order: self.order.clone(), + from: None, + to: None, + } + } + + /// `order_by` accepts only `amount`, mirroring the openapi enum. + fn order_by_amount(&self) -> Result { + match self.order_by.as_deref() { + None => Ok(false), + Some("amount") => Ok(true), + Some(_) => Err(StatusCode::BAD_REQUEST.into()), + } + } +} + +/// Blockfrost validates these as booleans and rejects anything else. +fn parse_bool_filter(value: Option<&str>) -> Result, Error> { + match value { + None => Ok(None), + Some("true") => Ok(Some(true)), + Some("false") => Ok(Some(false)), + Some(_) => Err(StatusCode::BAD_REQUEST.into()), + } +} + +pub async fn all_dreps( + Query(params): Query, + State(domain): State>, +) -> Result>, Error> +where + Option: From, +{ + let order_by_amount = params.order_by_amount()?; + let retired = parse_bool_filter(params.retired.as_deref())?; + let expired = parse_bool_filter(params.expired.as_deref())?; + + let pagination = Pagination::try_from(params.pagination())?; + pagination.enforce_max_scan_limit(domain.config.max_scan_items())?; + + let (chain, tip, pparams) = chain_context(&domain)?; + + let mut dreps = vec![]; + + for item in domain.iter_cardano_entities::(None)? { + let (key, state) = item.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // Blockfrost applies the filters before pagination, so every page + // holds up to `count` matching rows. + if retired.is_some_and(|wanted| drep_is_retired(&state) != wanted) { + continue; + } + + if expired.is_some_and(|wanted| drep_is_expired(&state, &chain, tip, &pparams) != wanted) { + continue; + } + + let appeared_at = state.first_seen_at.unwrap_or((u64::MAX, usize::MAX)); + + dreps.push((appeared_at, key, state)); + } + + if order_by_amount { + // `order` flips only the amount; the appearance order stays the + // ascending tie-breaker, like Blockfrost's `ORDER BY amount, id ASC`. + dreps.sort_by(|(a_order, a_key, a_state), (b_order, b_key, b_state)| { + let amounts = match pagination.order { + Order::Desc => b_state.voting_power.cmp(&a_state.voting_power), + Order::Asc => a_state.voting_power.cmp(&b_state.voting_power), + }; + + amounts.then_with(|| (a_order, a_key).cmp(&(b_order, b_key))) + }); + } else { + dreps.sort_by(|(a_order, a_key, _), (b_order, b_key, _)| { + (a_order, a_key).cmp(&(b_order, b_key)) + }); + + if matches!(pagination.order, Order::Desc) { + dreps.reverse(); + } + } + + let items = dreps + .into_iter() + .skip(pagination.from()) + .take(pagination.count) + .map(|(_, _, state)| async { + let metadata = fetch_drep_metadata(state.anchor.clone()).await; + let mut model = drep_list_item(state, &pparams, &chain, tip)?; + model.metadata = metadata.map(Box::new); + Ok::<_, StatusCode>(model) + }); + + let page = join_all(items) + .await + .into_iter() + .collect::, StatusCode>>()?; + + Ok(Json(page)) +} + +pub async fn drep_by_id( + Path(drep): Path, + State(domain): State>, +) -> Result, StatusCode> +where + Option: From, +{ + let parsed = parse_drep_id(&drep)?; + + let drep_state = if parsed.is_special { + domain.read_cardano_entity::(parsed.encoded.clone())? + } else { + Some( + domain + .read_cardano_entity::(parsed.encoded.clone())? + .ok_or(StatusCode::NOT_FOUND)?, + ) + }; + + let (chain, tip, pparams) = chain_context(&domain)?; + + let model = DrepModelBuilder { + drep_id: parsed.drep_id, + drep_id_encoded: parsed.encoded, + is_legacy: parsed.is_legacy, + is_special: parsed.is_special, + state: drep_state, + pparams: &pparams, + chain: &chain, + tip, + }; + + model.into_response() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::{TestApp, TestFault}; + use bech32::{Bech32, Hrp}; + use blockfrost_openapi::models::drep::Drep as DrepModel; + use dolos_cardano::pallas_extras; + use dolos_testing::synthetic::SyntheticBlockConfig; + + fn invalid_drep() -> &'static str { + "not-a-drep" + } + + fn encode_id(hrp: &str, payload: &[u8]) -> String { + let hrp = Hrp::parse_unchecked(hrp); + bech32::encode::(hrp, payload).expect("failed to encode bech32 id") + } + + fn missing_drep() -> String { + let payload = [vec![pallas_extras::DREP_KEY_PREFIX], vec![8u8; 28]].concat(); + encode_id("drep", &payload) + } + + fn vector_drep_hash(app: &TestApp) -> Vec { + let (_, payload) = bech32::decode(&app.vectors().drep_id).expect("invalid vector drep id"); + + payload[1..].to_vec() + } + + async fn assert_status(app: &TestApp, path: &str, expected: StatusCode) { + let (status, _body) = app.get_bytes(path).await; + assert_eq!(status, expected); + } + + async fn get_drep(app: &TestApp, drep_id: &str) -> DrepModel { + let path = format!("/governance/dreps/{drep_id}"); + let (status, body) = app.get_bytes(&path).await; + + assert_eq!( + status, + StatusCode::OK, + "unexpected status {status} with body: {}", + String::from_utf8_lossy(&body) + ); + + serde_json::from_slice(&body).expect("failed to parse drep model") + } + + #[tokio::test] + async fn governance_drep_bad_request() { + let app = TestApp::new(); + let path = format!("/governance/dreps/{}", invalid_drep()); + + assert_status(&app, &path, StatusCode::BAD_REQUEST).await; + } + + #[tokio::test] + async fn governance_drep_not_found() { + let app = TestApp::new(); + let missing = missing_drep(); + let path = format!("/governance/dreps/{missing}"); + + assert_status(&app, &path, StatusCode::NOT_FOUND).await; + } + + #[tokio::test] + async fn governance_drep_internal_error() { + let app = TestApp::new_with_fault(Some(TestFault::StateStoreError)); + let drep = &app.vectors().drep_id; + let path = format!("/governance/dreps/{drep}"); + + assert_status(&app, &path, StatusCode::INTERNAL_SERVER_ERROR).await; + } + + #[tokio::test] + async fn governance_drep_happy_path() { + let app = TestApp::builder() + .with_cfg(SyntheticBlockConfig { + drep_deposit: 7777, + ..Default::default() + }) + .with_protocol(9) + .build(); + + let drep_id = app.vectors().drep_id.clone(); + let model = get_drep(&app, &drep_id).await; + + let (_, payload) = bech32::decode(&drep_id).expect("invalid vector drep id"); + + let expected = DrepModel { + drep_id, + hex: hex::encode(&payload), + // the ledger's drep_distr counts the DRep's own deposit + amount: "7777".to_string(), + active: true, + active_epoch: Some(2), + has_script: false, + retired: false, + expired: false, + last_active_epoch: Some(2), + }; + + assert_eq!(model, expected); + } + + #[tokio::test] + async fn governance_drep_special_ids() { + let app = TestApp::new(); + + for id in ["drep_always_abstain", "drep_always_no_confidence"] { + let model = get_drep(&app, id).await; + + let expected = DrepModel { + drep_id: id.to_string(), + hex: "".to_string(), + amount: "0".to_string(), + active: true, + active_epoch: None, + has_script: false, + retired: false, + expired: false, + last_active_epoch: None, + }; + + assert_eq!(model, expected); + } + } + + #[tokio::test] + async fn governance_drep_by_id_accepts_legacy_encodings() { + let app = TestApp::new(); + let hash = vector_drep_hash(&app); + let cip105 = encode_id("drep", &hash); + let cip129 = get_drep(&app, &app.vectors().drep_id.clone()).await; + + let expected = DrepModel { + drep_id: cip105.clone(), + hex: hex::encode(&hash), + ..cip129 + }; + + assert_eq!(get_drep(&app, &cip105).await, expected); + + // Blockfrost rejects the drep_vkh prefix + let path = format!("/governance/dreps/{}", encode_id("drep_vkh", &hash)); + assert_status(&app, &path, StatusCode::BAD_REQUEST).await; + } + + #[tokio::test] + async fn governance_drep_by_id_script_variant_not_found() { + let app = TestApp::new(); + let hash = vector_drep_hash(&app); + + let path = format!("/governance/dreps/{}", encode_id("drep_script", &hash)); + assert_status(&app, &path, StatusCode::NOT_FOUND).await; + + let cip129_script = [vec![pallas_extras::DREP_SCRIPT_PREFIX], hash].concat(); + let path = format!("/governance/dreps/{}", encode_id("drep", &cip129_script)); + assert_status(&app, &path, StatusCode::NOT_FOUND).await; + } + + async fn get_dreps_list(app: &TestApp, path: &str) -> Vec { + let (status, body) = app.get_bytes(path).await; + assert_eq!(status, StatusCode::OK); + + serde_json::from_slice(&body).expect("failed to parse dreps list") + } + + #[tokio::test] + async fn governance_dreps_list_happy_path() { + let app = TestApp::builder() + .with_cfg(SyntheticBlockConfig { + drep_deposit: 7777, + ..Default::default() + }) + .with_protocol(9) + .build(); + + let models = get_dreps_list(&app, "/governance/dreps").await; + + let drep_id = app.vectors().drep_id.clone(); + let (_, payload) = bech32::decode(&drep_id).expect("invalid vector drep id"); + + assert_eq!( + models, + vec![DrepsInner { + drep_id, + hex: hex::encode(&payload), + // the ledger's drep_distr counts the DRep's own deposit + amount: "7777".to_string(), + has_script: false, + retired: false, + expired: false, + last_active_epoch: Some(2), + metadata: None, + }] + ); + } + + #[tokio::test] + async fn governance_dreps_list_pagination() { + let app = TestApp::new(); + + let models = get_dreps_list(&app, "/governance/dreps?page=2").await; + assert!(models.is_empty()); + + let models = get_dreps_list(&app, "/governance/dreps?order=desc&count=1").await; + assert_eq!(models.len(), 1); + } + + #[tokio::test] + async fn governance_dreps_list_bad_request() { + let app = TestApp::new(); + + assert_status(&app, "/governance/dreps?count=0", StatusCode::BAD_REQUEST).await; + assert_status( + &app, + "/governance/dreps?order=sideways", + StatusCode::BAD_REQUEST, + ) + .await; + assert_status( + &app, + "/governance/dreps?order_by=alphabet", + StatusCode::BAD_REQUEST, + ) + .await; + assert_status( + &app, + "/governance/dreps?retired=banana", + StatusCode::BAD_REQUEST, + ) + .await; + assert_status( + &app, + "/governance/dreps?expired=banana", + StatusCode::BAD_REQUEST, + ) + .await; + } + + #[tokio::test] + async fn governance_dreps_list_filters_apply_before_pagination() { + let app = TestApp::new(); + + // the synthetic drep is registered and active: it survives the + // negative filters and disappears behind the positive ones + let models = get_dreps_list(&app, "/governance/dreps?retired=false&expired=false").await; + assert_eq!(models.len(), 1); + + let models = get_dreps_list(&app, "/governance/dreps?retired=true").await; + assert!(models.is_empty()); + + let models = get_dreps_list(&app, "/governance/dreps?expired=true").await; + assert!(models.is_empty()); + } + + #[tokio::test] + async fn governance_dreps_list_order_by_amount() { + let app = TestApp::new(); + + let models = get_dreps_list(&app, "/governance/dreps?order_by=amount").await; + assert_eq!(models.len(), 1); + + let models = get_dreps_list(&app, "/governance/dreps?order_by=amount&order=desc").await; + assert_eq!(models.len(), 1); + } + + #[tokio::test] + async fn governance_dreps_list_scan_limit() { + let app = TestApp::new(); + + // page * count above `max_scan_items` (default 3000) + assert_status( + &app, + "/governance/dreps?page=1000&count=100", + StatusCode::BAD_REQUEST, + ) + .await; + } + + #[tokio::test] + async fn governance_dreps_list_internal_error() { + let app = TestApp::new_with_fault(Some(TestFault::StateStoreError)); + + assert_status(&app, "/governance/dreps", StatusCode::INTERNAL_SERVER_ERROR).await; + } +} diff --git a/crates/minibf/src/test_support.rs b/crates/minibf/src/test_support.rs index c2a81f3bb..6f3a5758f 100644 --- a/crates/minibf/src/test_support.rs +++ b/crates/minibf/src/test_support.rs @@ -29,8 +29,19 @@ pub struct TestDomainBuilder { } impl TestDomainBuilder { - pub fn new_with_synthetic(mut cfg: SyntheticBlockConfig) -> Self { - let genesis = Arc::new(dolos_cardano::include::preview::load()); + pub fn new_with_synthetic(cfg: SyntheticBlockConfig) -> Self { + Self::new_with_synthetic_and_protocol(cfg, None) + } + + pub fn new_with_synthetic_and_protocol( + mut cfg: SyntheticBlockConfig, + force_protocol: Option, + ) -> Self { + let mut genesis = dolos_cardano::include::preview::load(); + if let Some(protocol) = force_protocol { + genesis.force_protocol = Some(protocol); + } + let genesis = Arc::new(genesis); let min_slot = { let temp = ToyDomain::new_with_genesis_and_config( genesis.clone(), @@ -116,6 +127,19 @@ impl TestApp { Self::new_with_cfg_and_fault(cfg, None) } + /// Customize the app beyond what the `new_*` constructors cover (e.g. + /// forcing the bootstrap protocol version). Defaults match [`Self::new`]. + pub fn builder() -> TestAppBuilder { + TestAppBuilder { + cfg: SyntheticBlockConfig { + block_count: 5, + txs_per_block: 3, + ..Default::default() + }, + force_protocol: None, + } + } + pub fn new_with_cfg_and_fault(cfg: SyntheticBlockConfig, fault: Option) -> Self { let (domain, vectors) = TestDomainBuilder::new_with_synthetic(cfg).finish(); Self::from_domain(domain, vectors, fault) @@ -243,3 +267,28 @@ impl TestApp { summary.epoch_start(epoch) } } + +pub struct TestAppBuilder { + cfg: SyntheticBlockConfig, + force_protocol: Option, +} + +impl TestAppBuilder { + pub fn with_cfg(mut self, cfg: SyntheticBlockConfig) -> Self { + self.cfg = cfg; + self + } + + pub fn with_protocol(mut self, protocol: usize) -> Self { + self.force_protocol = Some(protocol); + self + } + + pub fn build(self) -> TestApp { + let (domain, vectors) = + TestDomainBuilder::new_with_synthetic_and_protocol(self.cfg, self.force_protocol) + .finish(); + + TestApp::from_domain(domain, vectors, None) + } +} diff --git a/crates/snapshot/src/namespaces.rs b/crates/snapshot/src/namespaces.rs index 925512a01..17afca2d8 100644 --- a/crates/snapshot/src/namespaces.rs +++ b/crates/snapshot/src/namespaces.rs @@ -71,7 +71,7 @@ pub const SCHEMA_REVS: [(Namespace, u64); 14] = [ (AccountState::NS, 1), (AssetState::NS, 1), (DatumState::NS, 1), - (DRepState::NS, 1), + (DRepState::NS, 2), (EpochState::NS, 2), (EraSummary::NS, 1), (GovState::NS, 1), diff --git a/crates/snapshot/tests/export.rs b/crates/snapshot/tests/export.rs index 7bf48d3ba..b58554f86 100644 --- a/crates/snapshot/tests/export.rs +++ b/crates/snapshot/tests/export.rs @@ -66,7 +66,7 @@ use watcher::Watcher; /// The identity of an export over an empty store set at [`SKELETON_POINT`]. const GOLDEN_SKELETON: &str = - "sha256:a4224fbb87099130c64f7a0ea85b52a05917cf88838f6d1674e36dd226bc0708"; + "sha256:aebae0339fde0ada1cfb21b0f5446398a599d64f4391f2fb7ac82b935f169dfe"; /// The chain point the skeleton fixture stands at: mid-epoch-2 under /// [`skeleton_summary`], so the export covers three epochs and the last window @@ -1079,7 +1079,7 @@ const CANONICAL_SKELETON: &str = concat!( r#"{"diffId":"sha256:e59d8b7ec7144216a9caab188b2de8d09d98c7c419e228a41131722796b81711","kind":"state-utxos","mediaType":"application/vnd.dolos.stele.state-utxos.v1+zstd","records":1,"scope":{"shard":15},"uncompressedSize":46}"#, r#"],"parameters":{"#, r#""indexKeyHash":"xxh3-64","#, - r#""schemas":{"account-epochs":1,"account-stakes":0,"accounts":1,"assets":1,"datums":1,"dreps":1,"epochs":2,"eras":1,"gov":1,"leader-rewards":0,"member-rewards":0,"pending_mirs":1,"pending_rewards":1,"pool-deposit-refunds":0,"pools":1,"proposals":1,"stakes":1,"utxos":1},"#, + r#""schemas":{"account-epochs":1,"account-stakes":0,"accounts":1,"assets":1,"datums":1,"dreps":2,"epochs":2,"eras":1,"gov":1,"leader-rewards":0,"member-rewards":0,"pending_mirs":1,"pending_rewards":1,"pool-deposit-refunds":0,"pools":1,"proposals":1,"stakes":1,"utxos":1},"#, r#""shards":{"account-epochs":1,"accounts":16,"assets":16,"datums":16,"dreps":1,"epochs":1,"eras":1,"gov":1,"pending_mirs":1,"pending_rewards":1,"pools":1,"proposals":1,"stakes":1,"utxos":16},"#, r#""stateEpochs":[]"#, r#"},"position":{"epoch":2,"network":{"magic":764824073,"name":"mainnet"},"point":{"hash":"0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b","slot":250}},"profile":{"name":"io.txpipe.dolos.cardano","version":1},"schema":1,"sequence":2}"#, diff --git a/crates/snapshot/tests/goldens.rs b/crates/snapshot/tests/goldens.rs index 36594c63c..e74225dad 100644 --- a/crates/snapshot/tests/goldens.rs +++ b/crates/snapshot/tests/goldens.rs @@ -297,7 +297,7 @@ const GOLDEN_LAYERS: [(&str, &str, u64, u64); 42] = [ /// The stele's identity: sha256 of the canonical inscription. const GOLDEN_INSCRIPTION: &str = - "sha256:3eb3c9373201208a315eecaad348f9bdba934e15b3f88890d82768df96ad1de7"; + "sha256:16867155365153d25d7e6cfefbedf31b0021f3c09f75ab282a29a0dad76175d6"; fn history() -> Vec { vec![ @@ -624,7 +624,7 @@ const CANONICAL_INSCRIPTION: &str = concat!( r#""scope":{"lastImmutable":3},"uncompressedSize":250}"#, r#"],"parameters":{"#, r#""indexKeyHash":"xxh3-64","#, - r#""schemas":{"account-epochs":1,"account-stakes":0,"accounts":1,"assets":1,"datums":1,"dreps":1,"epochs":2,"eras":1,"gov":1,"leader-rewards":0,"member-rewards":0,"pending_mirs":1,"pending_rewards":1,"pool-deposit-refunds":0,"pools":1,"proposals":1,"stakes":1,"utxos":1},"#, + r#""schemas":{"account-epochs":1,"account-stakes":0,"accounts":1,"assets":1,"datums":1,"dreps":2,"epochs":2,"eras":1,"gov":1,"leader-rewards":0,"member-rewards":0,"pending_mirs":1,"pending_rewards":1,"pool-deposit-refunds":0,"pools":1,"proposals":1,"stakes":1,"utxos":1},"#, r#""shards":{"account-epochs":1,"accounts":16,"assets":16,"datums":16,"dreps":1,"epochs":1,"eras":1,"gov":1,"pending_mirs":1,"pending_rewards":1,"pools":1,"proposals":1,"stakes":1,"utxos":16},"#, r#""stateEpochs":[4]"#, r#"},"position":{"#, diff --git a/crates/snapshot/tests/registry/canaries.rs b/crates/snapshot/tests/registry/canaries.rs index cad55eb50..4758f136a 100644 --- a/crates/snapshot/tests/registry/canaries.rs +++ b/crates/snapshot/tests/registry/canaries.rs @@ -212,6 +212,7 @@ pub fn drep_state() -> DRepState { updated_in: 409, prev: Some(400), }), + first_seen_at: Some((44_444, 2)), } } diff --git a/crates/snapshot/tests/registry/goldens/dreps.rev2.hex b/crates/snapshot/tests/registry/goldens/dreps.rev2.hex new file mode 100644 index 000000000..7dbfd5d9e --- /dev/null +++ b/crates/snapshot/tests/registry/goldens/dreps.rev2.hex @@ -0,0 +1,3 @@ +8a8219d903031a2e5014401a0001046a821a00012fd10bf51a1dcd65008200581ca0a1a2a3a4a5a6a7a8a9aaabacadae +afb0b1b2b3b4b5b6b7b8b9babb82781a68747470733a2f2f63616e6172792e696e76616c69642f3136315820a1a2a3a4 +a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc08319019c1901991901908219ad9c02 diff --git a/crates/snapshot/tests/registry/mod.rs b/crates/snapshot/tests/registry/mod.rs index 3b08cb999..c648c8436 100644 --- a/crates/snapshot/tests/registry/mod.rs +++ b/crates/snapshot/tests/registry/mod.rs @@ -177,10 +177,18 @@ pub fn registry() -> Vec { enc_dreps, DRepState, canaries::drep_state, - &[Pinned { - rev: 1, - hex: include_str!("goldens/dreps.rev1.hex"), - }] + // Revision 2 appends `first_seen_at` (index 9); revision 1 rows + // predate the field and must keep decoding. + &[ + Pinned { + rev: 1, + hex: include_str!("goldens/dreps.rev1.hex"), + }, + Pinned { + rev: 2, + hex: include_str!("goldens/dreps.rev2.hex"), + }, + ] ), entity_entry!( enc_epochs, diff --git a/docs/content/apis/minibf.mdx b/docs/content/apis/minibf.mdx index 057b6dcd2..62fcd88d7 100644 --- a/docs/content/apis/minibf.mdx +++ b/docs/content/apis/minibf.mdx @@ -60,7 +60,7 @@ print(json.dumps(api.block_latest().to_dict(), indent=2)) ### Using a Tx Builder The endpoints required for most tx builders to work are supported. Libraries like Lucid, Lucid-evolution, Blaze, MeshJS, etc can be used by pointing their corresponding provider configuration to Dolos Mini-BF endpoint. - + ## Configuration @@ -76,7 +76,7 @@ The `serve.minibf` section controls the options for the MiniBF endpoint that can - `listen_address`: the local address (`IP:PORT`) to listen for incoming connections (`[::]` represents any IP address). - `permissive_cors`: allow cross-origin requests from any origin. -- `token_registry_url`: optional token registry base URL used for off-chain asset metadata. +- `token_registry_url`: optional token registry base URL used for off-chain asset metadata. - `url`: optional public URL used in the `/` root response. - `max_scan_items`: caps page-based scans for heavy endpoints (defaults to 3000 if unset). @@ -137,6 +137,7 @@ Dolos provides many, but not all of the Blockfrost endpoints. The following list | `/epochs/{epoch}/stakes` | Get epoch stake distribution | | `/epochs/{epoch}/stakes/{pool_id}` | Get epoch stake distribution for a specific pool | | `/genesis` | Get genesis information | +| `/governance/dreps` | Get list of registered DReps | | `/governance/dreps/{drep_id}` | Get DRep information | | `/metadata/txs/labels/{label}` | Get metadata for transactions with a specific label | | `/metadata/txs/labels/{label}/cbor` | Get CBOR metadata for transactions with a specific label | diff --git a/src/bin/dolos/doctor/update_entity.rs b/src/bin/dolos/doctor/update_entity.rs index 357519abf..5c8f95e4e 100644 --- a/src/bin/dolos/doctor/update_entity.rs +++ b/src/bin/dolos/doctor/update_entity.rs @@ -1,4 +1,7 @@ -use dolos_cardano::{model::AccountState, EpochState, FixedNamespace as _, PoolState}; +use dolos_cardano::{ + model::{AccountState, DRepState}, + EpochState, FixedNamespace as _, PoolState, +}; use dolos_core::config::RootConfig; use miette::IntoDiagnostic; @@ -32,6 +35,7 @@ pub fn run(config: &RootConfig, args: &Args) -> miette::Result<()> { "epochs" => EpochState::NS, "accounts" => AccountState::NS, "pools" => PoolState::NS, + "dreps" => DRepState::NS, _ => return Err(miette::Error::msg("invalid namespace")), };