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 3fafb3e55..ff25d6a35 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 29b5533c6..9762833ac 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 { @@ -228,10 +228,19 @@ impl BlockVisitor for DRepStateVisitor { )); } + // Sightings mirror db-sync's `drep_hash` rows, which db-sync only + // writes for certs of valid txs; the crawl above already gates the + // certificate fan-out on `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(()); }; + 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/core/src/config.rs b/crates/core/src/config.rs index 907ee4553..299854403 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -785,6 +785,8 @@ pub struct MinibfConfig { pub url: Option, #[serde(default, skip_serializing_if = "Option::is_none")] max_scan_items: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + max_offchain_cache_mb: Option, } impl MinibfConfig { @@ -795,6 +797,7 @@ impl MinibfConfig { token_registry_url: None, url: None, max_scan_items: None, + max_offchain_cache_mb: None, } } @@ -815,6 +818,19 @@ impl MinibfConfig { pub fn max_scan_items(&self) -> u64 { self.max_scan_items.unwrap_or(default_max_scan_items()) } + + pub fn with_max_offchain_cache_mb(mut self, max_offchain_cache_mb: u64) -> Self { + self.max_offchain_cache_mb = Some(max_offchain_cache_mb); + self + } + + /// Disk the off-chain metadata cache may occupy, in MB. Zero turns the + /// on-disk cache off, leaving the in-process one. + pub fn max_offchain_cache_bytes(&self) -> u64 { + self.max_offchain_cache_mb + .unwrap_or(default_max_offchain_cache_mb()) + .saturating_mul(1024 * 1024) + } } #[derive(Deserialize, Serialize, Clone)] @@ -889,6 +905,10 @@ fn default_max_optimize_rounds() -> u8 { 10 } +fn default_max_offchain_cache_mb() -> u64 { + 256 +} + fn default_max_scan_items() -> u64 { 3000 } diff --git a/crates/minibf/src/lib.rs b/crates/minibf/src/lib.rs index 68c21c159..fff94a30b 100644 --- a/crates/minibf/src/lib.rs +++ b/crates/minibf/src/lib.rs @@ -622,6 +622,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 af3212780..09e1caf42 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/dreps.rs b/crates/minibf/src/routes/governance/dreps.rs new file mode 100644 index 000000000..06928c227 --- /dev/null +++ b/crates/minibf/src/routes/governance/dreps.rs @@ -0,0 +1,379 @@ +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. +/// +/// Blockfrost derives this in SQL from the epoch of the DRep's latest +/// registration or vote: `registered AND current_epoch - last_active_epoch > +/// drep_activity`. A DRep that has neither registered nor voted has no such +/// epoch, and SQL's `NULL > n` sends that row to the `ELSE FALSE` arm, so +/// Blockfrost never reports one as expired — which is why the `None` case +/// here answers `false` rather than falling back to anything. +/// +/// Deliberately *not* read off the ledger expiry dolos keeps in +/// [`DRepState::expiry`] and `expired`. That value is the Haskell ledger's +/// own, dormancy credit and all, and it is what the epoch boundary rules on; +/// Blockfrost's flag is a db-sync heuristic that knows nothing of either. +/// The two part company exactly on the DReps that were never registered: +/// the ledger expires them, Blockfrost does not. Serving the ledger value +/// here disagreed with live Blockfrost on 13 of the first 3000 preview +/// DReps, all of them never-registered vote-delegation targets, so the +/// endpoint keeps Blockfrost's rule and the ledger keeps its own. +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..fa0f21ef2 --- /dev/null +++ b/crates/minibf/src/routes/governance/metadata.rs @@ -0,0 +1,950 @@ +use axum::http::StatusCode; +use blockfrost_openapi::models::{ + dreps_inner_metadata_error::Code as MetadataError, DrepsInnerMetadata, DrepsInnerMetadataError, +}; +use pallas::{ + crypto::hash::{Hash, Hasher}, + ledger::primitives::conway::Anchor, +}; +use reqwest::dns::{Addrs, Name, Resolve, Resolving}; +use std::{ + collections::{HashMap, VecDeque}, + net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, ToSocketAddrs as _}, + path::{Path, PathBuf}, + sync::{ + atomic::{AtomicU64, Ordering}, + Arc, Mutex, MutexGuard, OnceLock, + }, + time::{Duration, Instant, SystemTime}, +}; + +const MAX_METADATA_BYTES: usize = 1024 * 1024; +const FETCH_TIMEOUT: Duration = Duration::from_secs(5); +const MAX_REDIRECTS: usize = 3; + +/// An anchor pins its content by hash, so a verified fetch never has to be +/// repeated: its body goes to the [`OffchainStore`] on disk and the rendered +/// metadata stays in memory within these caps. A failed fetch is kept in +/// memory for `FAILURE_TTL`, so a dead host costs one timeout per window +/// instead of one per page render. +const FAILURE_TTL: Duration = Duration::from_secs(10 * 60); +const MAX_CACHE_ENTRIES: usize = 4096; +const MAX_CACHE_BYTES: usize = 64 * 1024 * 1024; + +// Blockfrost hands back db-sync's own fetch-error text and backend-ryo picks +// the `code` out of that text by keyword (`transformOffChainFetchError`), so +// the messages below keep db-sync's wording, prefix included. +const ERROR_PREFIX: &str = "Error Offchain Voting Anchor"; + +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_PREFIX}: 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_PREFIX}: Connection failure error when fetching metadata from {url}."), + ) +} + +fn size_exceeded_error(url: &str) -> DrepsInnerMetadataError { + DrepsInnerMetadataError::new( + MetadataError::SizeExceeded, + format!( + "{ERROR_PREFIX}: Size error when fetching metadata from {url}, the payload exceeds {MAX_METADATA_BYTES} bytes." + ), + ) +} + +/// db-sync refuses localhost anchors with a URL parse error, which ryo files +/// under `CONNECTION_ERROR`. +fn blocked_url_error(url: &str) -> DrepsInnerMetadataError { + DrepsInnerMetadataError::new( + MetadataError::ConnectionError, + format!( + "{ERROR_PREFIX}: URL parse error for {url} resulted in : \"Access to non-public addresses is not allowed\"" + ), + ) +} + +/// db-sync decides whether a payload is JSON from the response's content +/// type and files a mismatch as an HTTP response error quoting the type it +/// got, before it ever looks at the body or its hash. +fn content_type_error(url: &str, content_type: &str) -> DrepsInnerMetadataError { + DrepsInnerMetadataError::new( + MetadataError::HttpResponseError, + format!("{ERROR_PREFIX}: HTTP Response error from {url}: expected JSON, but got : \"{content_type}\""), + ) +} + +fn decode_error(url: &str, reason: &str) -> DrepsInnerMetadataError { + DrepsInnerMetadataError::new( + MetadataError::DecodeError, + format!( + "{ERROR_PREFIX}: JSON decode error when fetching metadata from {url} resulted in : \"{reason}\"" + ), + ) +} + +/// Whether a response *claims* to carry JSON. This only picks which error a +/// body that would not parse is reported under, never whether the body is +/// read: anchors are served by whatever the DRep pointed at, and valid +/// metadata arrives under `text/plain` from a raw GitHub file or +/// `binary/octet-stream` from an S3 bucket often enough that the type alone +/// cannot turn a payload away. +fn content_type_claims_json(content_type: &str) -> bool { + content_type + .split(';') + .next() + .unwrap_or_default() + .trim() + .to_ascii_lowercase() + .contains("json") +} + +/// Address blocks a public host never sits in, written the way IANA's +/// special-purpose registries list them: the private ranges and +/// carrier-grade NAT, loopback, link-local (where the cloud metadata +/// services answer), multicast, documentation and the reserved space. +/// +/// Spelled out rather than assembled from the `std` predicates because +/// those miss carrier-grade NAT and leave every IPv6 classifier unstable. +const RESERVED_V4: &[(Ipv4Addr, u32)] = &[ + (Ipv4Addr::new(0, 0, 0, 0), 8), + (Ipv4Addr::new(10, 0, 0, 0), 8), + (Ipv4Addr::new(100, 64, 0, 0), 10), + (Ipv4Addr::new(127, 0, 0, 0), 8), + (Ipv4Addr::new(169, 254, 0, 0), 16), + (Ipv4Addr::new(172, 16, 0, 0), 12), + (Ipv4Addr::new(192, 0, 0, 0), 24), + (Ipv4Addr::new(192, 0, 2, 0), 24), + (Ipv4Addr::new(192, 168, 0, 0), 16), + (Ipv4Addr::new(198, 18, 0, 0), 15), + (Ipv4Addr::new(198, 51, 100, 0), 24), + (Ipv4Addr::new(203, 0, 113, 0), 24), + (Ipv4Addr::new(224, 0, 0, 0), 4), + (Ipv4Addr::new(240, 0, 0, 0), 4), +]; + +/// The same for IPv6. `::/96` covers the unspecified address, the loopback +/// and the deprecated IPv4-compatible form in one entry; the IPv4-mapped +/// block needs no entry because those addresses are unwrapped and judged as +/// the IPv4 addresses they dial. +const RESERVED_V6: &[(Ipv6Addr, u32)] = &[ + (Ipv6Addr::UNSPECIFIED, 96), + (Ipv6Addr::new(0x100, 0, 0, 0, 0, 0, 0, 0), 64), + (Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 0), 32), + (Ipv6Addr::new(0xfc00, 0, 0, 0, 0, 0, 0, 0), 7), + (Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 0), 10), + (Ipv6Addr::new(0xff00, 0, 0, 0, 0, 0, 0, 0), 8), +]; + +/// Whether `addr` sits inside the `bits`-long prefix of `block`. +fn in_prefix(addr: &[u8], block: &[u8], bits: u32) -> bool { + let whole = bits as usize / 8; + let spare = bits % 8; + + addr[..whole] == block[..whole] + && (spare == 0 || (addr[whole] ^ block[whole]) >> (8 - spare) == 0) +} + +/// The anchor URL is attacker-controlled on-chain data, so an address in one +/// of the reserved blocks would let a DRep aim the node at its own network +/// position rather than at metadata. +fn ip_is_public(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(ip) => !RESERVED_V4 + .iter() + .any(|(block, bits)| in_prefix(&ip.octets(), &block.octets(), *bits)), + + // an IPv4 address wearing an IPv6 coat still dials the IPv4 host, so + // it faces the IPv4 rules + IpAddr::V6(ip) => match ip.to_ipv4_mapped() { + Some(ip) => ip_is_public(IpAddr::V4(ip)), + None => !RESERVED_V6 + .iter() + .any(|(block, bits)| in_prefix(&ip.octets(), &block.octets(), *bits)), + }, + } +} + +/// The gate on the URL itself: scheme, and a host that is a literal address +/// or `localhost`. A hostname passes here and gets vetted by +/// [`PublicOnlyResolver`] once it resolves. +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"), + } +} + +type BoxError = Box; + +/// The addresses of `host` the client may dial: whatever it resolves to, +/// minus the non-public ranges. +fn public_addresses(host: &str) -> std::io::Result> { + // the port is a placeholder; the connector replaces it with the URL's + let public: Vec<_> = (host, 0u16) + .to_socket_addrs()? + .filter(|addr| ip_is_public(addr.ip())) + .collect(); + + if public.is_empty() { + return Err(std::io::Error::other(format!( + "{host} resolves to no public address" + ))); + } + + Ok(public) +} + +/// A DRep controls its anchor's DNS records as much as its URL, so a name +/// resolving into the node's own network would slip past the literal-host +/// check in `is_fetchable`. This resolver runs for every connection the +/// client opens, redirect hops included, and hands the connector only the +/// vetted addresses: nothing can change between the check and the dial. +struct PublicOnlyResolver; + +impl Resolve for PublicOnlyResolver { + fn resolve(&self, name: Name) -> Resolving { + let host = name.as_str().to_string(); + + Box::pin(async move { + let addrs = tokio::task::spawn_blocking(move || public_addresses(&host)).await??; + + Ok::(Box::new(addrs.into_iter())) + }) + } +} + +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(FETCH_TIMEOUT) + .dns_resolver(Arc::new(PublicOnlyResolver)) + // every redirect hop gets the same URL gate as the anchor, and its + // hostname goes through the resolver like the anchor's did + .redirect(reqwest::redirect::Policy::custom(|attempt| { + if attempt.previous().len() > MAX_REDIRECTS { + 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)) +} + +type CacheKey = (String, Hash<32>); + +struct CacheEntry { + metadata: DrepsInnerMetadata, + stored_at: Instant, + size: usize, +} + +impl CacheEntry { + fn is_stale(&self, now: Instant) -> bool { + // a verified fetch never goes stale: the hash pins the content + self.metadata.error.is_some() && now.duration_since(self.stored_at) > FAILURE_TTL + } +} + +struct MetadataCache { + entries: HashMap, + // insertion order, oldest first, for eviction + order: VecDeque, + bytes: usize, + max_entries: usize, + max_bytes: usize, +} + +impl MetadataCache { + fn new(max_entries: usize, max_bytes: usize) -> Self { + Self { + entries: HashMap::new(), + order: VecDeque::new(), + bytes: 0, + max_entries, + max_bytes, + } + } + + fn get(&self, key: &CacheKey, now: Instant) -> Option { + let entry = self.entries.get(key)?; + + (!entry.is_stale(now)).then(|| entry.metadata.clone()) + } + + fn insert(&mut self, key: CacheKey, metadata: DrepsInnerMetadata, now: Instant) { + // the hex `bytes` dominate an entry, so the budget is a bound on + // the order of the real footprint rather than an exact figure + let size = key.0.len() + metadata.bytes.as_ref().map_or(0, |x| x.len()); + + let entry = CacheEntry { + metadata, + stored_at: now, + size, + }; + + match self.entries.insert(key.clone(), entry) { + Some(old) => self.bytes -= old.size, + None => self.order.push_back(key), + } + + self.bytes += size; + + while self.entries.len() > self.max_entries || self.bytes > self.max_bytes { + let Some(oldest) = self.order.pop_front() else { + break; + }; + + if let Some(old) = self.entries.remove(&oldest) { + self.bytes -= old.size; + } + } + } +} + +fn cache() -> MutexGuard<'static, MetadataCache> { + static CACHE: OnceLock> = OnceLock::new(); + + CACHE + .get_or_init(|| Mutex::new(MetadataCache::new(MAX_CACHE_ENTRIES, MAX_CACHE_BYTES))) + .lock() + // plain data under the lock: a panic elsewhere leaves nothing half-done + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +fn errored(mut out: DrepsInnerMetadata, error: DrepsInnerMetadataError) -> DrepsInnerMetadata { + out.error = Some(Box::new(error)); + out +} + +/// Verified bodies, content-addressed by their hash: one file per hash under +/// `/offchain`, so a fetch outlives the process. The hash check +/// on the way back in guards the file the way it guarded the download, and a +/// failure never reaches the disk. +pub struct OffchainStore { + dir: PathBuf, + budget: u64, +} + +impl OffchainStore { + pub const DIR: &'static str = "offchain"; + + /// `budget` is the disk the store may occupy, in bytes; zero turns it + /// off and leaves only the in-process cache. + pub fn new(storage_path: &Path, budget: u64) -> Self { + Self { + dir: storage_path.join(Self::DIR), + budget, + } + } + + fn path(&self, hash: &Hash<32>) -> PathBuf { + self.dir.join(hex::encode(hash)) + } + + async fn read(&self, hash: Hash<32>) -> Option> { + let path = self.path(&hash); + + let body = tokio::task::spawn_blocking(move || std::fs::read(path)) + .await + .ok()? + .ok()?; + + (Hasher::<256>::hash(&body) == hash).then_some(body) + } + + /// Weigh the directory and drop the oldest files until it fits the + /// budget again, taking it a fifth below so the next walk is not + /// immediate. Age is the order the files were written, not the order + /// they were last read: the anchor hash pins each body for good, so no + /// entry is worth more than another and only the bound matters. + async fn enforce_budget(&self) { + let dir = self.dir.clone(); + let budget = self.budget; + + let _ = tokio::task::spawn_blocking(move || { + let mut files: Vec<(SystemTime, u64, PathBuf)> = std::fs::read_dir(&dir)? + .filter_map(Result::ok) + .filter_map(|entry| { + let meta = entry.metadata().ok()?; + + if !meta.is_file() { + return None; + } + + Some((meta.modified().ok()?, meta.len(), entry.path())) + }) + .collect(); + + let total: u64 = files.iter().map(|(_, size, _)| size).sum(); + + let Some(mut excess) = total.checked_sub(budget - budget / 5) else { + return Ok(()); + }; + + files.sort_by_key(|(written, _, _)| *written); + + for (_, size, path) in files { + if excess == 0 { + break; + } + + if std::fs::remove_file(&path).is_ok() { + excess = excess.saturating_sub(size); + } + } + + std::io::Result::Ok(()) + }) + .await; + } + + async fn write(&self, hash: Hash<32>, body: Vec) { + static SEQ: AtomicU64 = AtomicU64::new(0); + + // Weighing the directory costs a walk, so it happens once per + // fifth-of-a-budget written rather than per file, which caps the + // overshoot at that same fifth. + static UNWEIGHED: AtomicU64 = AtomicU64::new(0); + + if self.budget == 0 { + return; + } + + let dir = self.dir.clone(); + let path = self.path(&hash); + let size = body.len() as u64; + + // written beside its final name and renamed into place, so a + // concurrent reader never sees a partial body + let written = tokio::task::spawn_blocking(move || { + std::fs::create_dir_all(&dir)?; + + let tmp = dir.join(format!( + "{}.{}.{}.tmp", + hex::encode(hash), + std::process::id(), + SEQ.fetch_add(1, Ordering::Relaxed) + )); + + std::fs::write(&tmp, &body)?; + std::fs::rename(tmp, path) + }) + .await; + + // the store is a cache: a body that could not be kept is fetched + // again next time + if let Err(err) = written.map_err(std::io::Error::other).and_then(|x| x) { + tracing::warn!(%err, "failed to store off-chain metadata"); + return; + } + + let unweighed = UNWEIGHED.fetch_add(size, Ordering::Relaxed) + size; + + if unweighed >= self.budget / 5 { + UNWEIGHED.store(0, Ordering::Relaxed); + self.enforce_budget().await; + } + } +} + +/// The verified body of `anchor`, or the error Blockfrost would report. +async fn fetch_anchor(anchor: &Anchor) -> Result, DrepsInnerMetadataError> { + let url = &anchor.url; + + let client = http_client().ok_or_else(|| connection_error(url))?; + + if !is_fetchable(url) { + return Err(blocked_url_error(url)); + } + + let mut response = client + .get(url) + .send() + .await + .map_err(|_| connection_error(url))?; + + if !response.status().is_success() { + return Err(http_response_error(url, response.status())); + } + + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_string(); + + if response + .content_length() + .is_some_and(|len| len > MAX_METADATA_BYTES as u64) + { + return Err(size_exceeded_error(url)); + } + + let mut body = Vec::new(); + + while let Some(chunk) = response.chunk().await.map_err(|_| connection_error(url))? { + if body.len() + chunk.len() > MAX_METADATA_BYTES { + return Err(size_exceeded_error(url)); + } + + body.extend_from_slice(&chunk); + } + + // db-sync decides a payload is not metadata at all before it weighs the + // body against the anchor's hash, which is why Blockfrost answers a + // shortener's HTML page with this error rather than a hash mismatch. It + // reads the content type to say so, but the type alone is too unreliable + // to reject on, so it only names the failure of a body that would not + // have parsed anyway. + if serde_json::from_slice::(&body).is_err() + && !content_type_claims_json(&content_type) + { + return Err(content_type_error(url, &content_type)); + } + + let actual_hash = Hasher::<256>::hash(&body); + + if actual_hash != anchor.content_hash { + return Err(hash_mismatch_error( + url, + anchor.content_hash.as_ref(), + actual_hash.as_ref(), + )); + } + + Ok(body) +} + +/// The metadata of a verified body. +fn verified(out: DrepsInnerMetadata, body: &[u8]) -> DrepsInnerMetadata { + match serde_json::from_slice(body) { + Ok(json) => DrepsInnerMetadata { + json_metadata: Some(json), + bytes: Some(format!("\\x{}", hex::encode(body))), + ..out + }, + // the spec keeps `json_metadata` and `bytes` null on failed + // validation and reports the failure through `error` + Err(err) => { + let error = decode_error(&out.url, &err.to_string()); + errored(out, error) + } + } +} + +pub async fn fetch_drep_metadata( + store: &OffchainStore, + anchor: Option, +) -> Option { + let anchor = anchor?; + let key = (anchor.url.clone(), anchor.content_hash); + + let cached = cache().get(&key, Instant::now()); + + if cached.is_some() { + return cached; + } + + let out = DrepsInnerMetadata { + url: anchor.url.clone(), + hash: hex::encode(anchor.content_hash), + json_metadata: None, + bytes: None, + error: None, + }; + + let metadata = match store.read(anchor.content_hash).await { + Some(body) => verified(out, &body), + None => match fetch_anchor(&anchor).await { + Ok(body) => { + store.write(anchor.content_hash, body.clone()).await; + verified(out, &body) + } + Err(error) => errored(out, error), + }, + }; + + cache().insert(key, metadata.clone(), Instant::now()); + + Some(metadata) +} + +#[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")); + } + + /// An IPv4 address written as IPv6 dials the IPv4 host, so the IPv4 + /// rules have to reach it: `::ffff:127.0.0.1` is not + /// `Ipv6Addr::is_loopback`, yet it lands on the loopback. + #[test] + fn rejects_ipv4_addresses_written_as_ipv6() { + for host in [ + "[::ffff:127.0.0.1]", + "[::ffff:10.1.2.3]", + "[::ffff:169.254.169.254]", + "[::ffff:192.168.1.1]", + "[::ffff:100.64.0.1]", + // the deprecated IPv4-compatible spelling sits in `::/96` + "[::127.0.0.1]", + ] { + let url = format!("http://{host}/meta.json"); + assert!(!is_fetchable(&url), "{url}"); + } + + // the coat itself is not the problem: a public IPv4 stays fetchable + assert!(is_fetchable("http://[::ffff:93.184.216.34]/meta.json")); + } + + #[test] + fn prefixes_match_on_the_bits_that_matter() { + let inside = |a: &str| !ip_is_public(a.parse().unwrap()); + + // 100.64.0.0/10 ends mid-octet: .64 through .127 are carrier-grade + // NAT, .63 and .128 are not + assert!(inside("100.64.0.1")); + assert!(inside("100.127.255.254")); + assert!(!inside("100.63.255.255")); + assert!(!inside("100.128.0.1")); + + // fc00::/7 covers fc.. and fd.. + assert!(inside("fc00::1")); + assert!(inside("fdff::1")); + assert!(!inside("fb00::1")); + assert!(!inside("fe00::1")); + } + + #[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")); + } + + /// `localhost` passes the URL gate as a name only to resolve into the + /// loopback range, which is where the resolver has to refuse it. + #[tokio::test] + async fn resolver_refuses_names_on_non_public_addresses() { + assert!(public_addresses("localhost").is_err()); + + let name: Name = "localhost".parse().expect("failed to parse name"); + assert!(PublicOnlyResolver.resolve(name).await.is_err()); + } + + /// backend-ryo derives the error code from db-sync's message text; the + /// keyword each message carries has to land on the code it is filed + /// under. + #[test] + fn messages_carry_the_keywords_blockfrost_files_them_under() { + let cases = [ + (hash_mismatch_error("u", &[1], &[2]), "hash mismatch"), + (size_exceeded_error("u"), "size error"), + (decode_error("u", "why"), "decode error"), + ( + http_response_error("u", StatusCode::NOT_FOUND), + "http response error", + ), + (content_type_error("u", "text/html"), "http response error"), + (connection_error("u"), "connection failure error"), + (blocked_url_error("u"), "url parse error"), + ]; + + for (error, keyword) in cases { + assert!( + error.message.to_lowercase().contains(keyword), + "{:?} lacks {keyword:?}: {}", + error.code, + error.message + ); + } + + assert_eq!( + http_response_error("https://x.io/d.json", StatusCode::NOT_FOUND).message, + "Error Offchain Voting Anchor: HTTP Response error from https://x.io/d.json resulted in HTTP status code : 404 \"Not Found\"" + ); + + assert_eq!( + content_type_error("https://x.io/d.json", "text/html; charset=utf-8").message, + "Error Offchain Voting Anchor: HTTP Response error from https://x.io/d.json: expected JSON, but got : \"text/html; charset=utf-8\"" + ); + } + + #[test] + fn content_types_that_claim_json() { + for claimed in [ + "application/json", + "application/json; charset=utf-8", + "application/ld+json", + ] { + assert!(content_type_claims_json(claimed), "{claimed}"); + } + + // metadata does arrive under all of these, so none of them may be + // the reason a body is turned away + for unclaimed in [ + "text/plain; charset=utf-8", + "binary/octet-stream", + "application/octet-stream", + "text/html; charset=utf-8", + "", + ] { + assert!(!content_type_claims_json(unclaimed), "{unclaimed}"); + } + } + + fn temp_root(name: &str) -> PathBuf { + static SEQ: AtomicU64 = AtomicU64::new(0); + + std::env::temp_dir().join(format!( + "dolos-offchain-{name}-{}-{}", + std::process::id(), + SEQ.fetch_add(1, Ordering::Relaxed) + )) + } + + #[tokio::test] + async fn store_keeps_verified_bodies_only() { + let root = temp_root("verified"); + let store = OffchainStore::new(&root, 1024 * 1024); + + let body = br#"{"body":"hello"}"#.to_vec(); + let hash = Hasher::<256>::hash(&body); + + assert_eq!(store.read(hash).await, None); + + store.write(hash, body.clone()).await; + assert_eq!(store.read(hash).await, Some(body.clone())); + + // a body that no longer matches its name is not served + std::fs::write(store.path(&hash), b"tampered").unwrap(); + assert_eq!(store.read(hash).await, None); + + std::fs::remove_dir_all(root).unwrap(); + } + + /// The store is a cache beside the state and archive data, so it has to + /// stay inside its budget however many anchors the chain carries. + #[tokio::test] + async fn store_evicts_the_oldest_bodies_to_stay_inside_its_budget() { + let root = temp_root("budget"); + // room for two bodies, and the walk takes it a fifth under + let store = OffchainStore::new(&root, 2048); + + let mut written = vec![]; + + for (age, byte) in [(4u64, b'a'), (3, b'b'), (2, b'c'), (1, b'd')] { + let body = vec![byte; 700]; + let hash = Hasher::<256>::hash(&body); + store.write(hash, body).await; + + // one file per second apart, oldest first, since a filesystem + // may not separate four writes in the same instant + let file = std::fs::File::options() + .write(true) + .open(store.path(&hash)) + .unwrap(); + file.set_modified(SystemTime::now() - Duration::from_secs(age)) + .unwrap(); + + written.push(hash); + } + + store.enforce_budget().await; + + let kept: Vec = + futures::future::join_all(written.iter().map(|hash| store.read(*hash))) + .await + .into_iter() + .map(|body| body.is_some()) + .collect(); + + assert_eq!(kept, vec![false, false, true, true], "oldest go first"); + + let total: u64 = std::fs::read_dir(&root) + .unwrap() + .filter_map(Result::ok) + .map(|e| e.metadata().unwrap().len()) + .sum(); + + assert!(total <= 2048, "{total} bytes left behind"); + + std::fs::remove_dir_all(root).unwrap(); + } + + /// A zero budget is the way to keep metadata out of the data directory + /// entirely; the in-process cache still answers. + #[tokio::test] + async fn a_zero_budget_writes_nothing_to_disk() { + let root = temp_root("nodisk"); + let store = OffchainStore::new(&root, 0); + + let body = b"{}".to_vec(); + let hash = Hasher::<256>::hash(&body); + + store.write(hash, body).await; + + assert!(store.read(hash).await.is_none()); + assert!(!root.join(OffchainStore::DIR).exists()); + } + + #[test] + fn verified_bodies_render_json_or_a_decode_error() { + let out = DrepsInnerMetadata { + url: "https://x.io/d.json".to_string(), + hash: String::new(), + json_metadata: None, + bytes: None, + error: None, + }; + + let json = verified(out.clone(), br#"{"a":1}"#); + assert_eq!(json.json_metadata, Some(serde_json::json!({"a": 1}))); + assert_eq!(json.bytes.as_deref(), Some("\\x7b2261223a317d")); + assert!(json.error.is_none()); + + let broken = verified(out, b"not json"); + assert_eq!(broken.json_metadata, None); + assert_eq!(broken.bytes, None); + assert_eq!(broken.error.unwrap().code, MetadataError::DecodeError); + } + + fn metadata(url: &str, error: Option) -> DrepsInnerMetadata { + DrepsInnerMetadata { + url: url.to_string(), + hash: String::new(), + json_metadata: None, + bytes: Some("\\x00".repeat(4)), + error: error.map(Box::new), + } + } + + fn key(url: &str) -> CacheKey { + (url.to_string(), Hash::<32>::from([0u8; 32])) + } + + #[test] + fn cache_keeps_verified_fetches_and_expires_failed_ones() { + let mut cache = MetadataCache::new(16, usize::MAX); + let now = Instant::now(); + let later = now + FAILURE_TTL + Duration::from_secs(1); + + cache.insert(key("ok"), metadata("ok", None), now); + cache.insert( + key("bad"), + metadata("bad", Some(connection_error("bad"))), + now, + ); + + assert!(cache.get(&key("ok"), now).is_some()); + assert!(cache.get(&key("bad"), now).is_some()); + assert!(cache.get(&key("ok"), later).is_some()); + assert!(cache.get(&key("bad"), later).is_none()); + assert!(cache.get(&key("missing"), now).is_none()); + } + + #[test] + fn cache_evicts_oldest_past_its_caps() { + let now = Instant::now(); + + let mut by_count = MetadataCache::new(2, usize::MAX); + for url in ["a", "b", "c"] { + by_count.insert(key(url), metadata(url, None), now); + } + assert!(by_count.get(&key("a"), now).is_none()); + assert!(by_count.get(&key("b"), now).is_some()); + assert!(by_count.get(&key("c"), now).is_some()); + + // each entry weighs its url plus 16 bytes of hex + let mut by_bytes = MetadataCache::new(usize::MAX, 40); + for url in ["a", "b", "c"] { + by_bytes.insert(key(url), metadata(url, None), now); + } + assert!(by_bytes.get(&key("a"), now).is_none()); + assert!(by_bytes.get(&key("b"), now).is_some()); + assert!(by_bytes.get(&key("c"), now).is_some()); + assert_eq!(by_bytes.bytes, 34); + + // replacing an entry swaps its weight instead of adding to it + by_bytes.insert(key("c"), metadata("c", None), now); + assert_eq!(by_bytes.bytes, 34); + assert_eq!(by_bytes.entries.len(), by_bytes.order.len()); + } +} diff --git a/crates/minibf/src/routes/governance/mod.rs b/crates/minibf/src/routes/governance/mod.rs index 8ad8027d5..56afefa22 100644 --- a/crates/minibf/src/routes/governance/mod.rs +++ b/crates/minibf/src/routes/governance/mod.rs @@ -1,4 +1,6 @@ +mod dreps; mod mapping; +mod metadata; use std::collections::HashMap; @@ -13,12 +15,16 @@ use blockfrost_openapi::models::{ proposal::{self, Proposal}, proposal_withdrawals_inner::ProposalWithdrawalsInner, proposals_inner::{GovernanceType, ProposalsInner}, + DrepsInner, }; use dolos_cardano::{ model::{DRepState, FixedNamespace as _, ProposalAction, ProposalState}, - pallas_extras, ChainSummary, PParamsSet, + ChainSummary, PParamsSet, }; use dolos_core::{ArchiveStore as _, BlockSlot, Domain, StateStore as _}; +use dreps::{drep_is_expired, drep_is_retired, drep_list_item, parse_drep_id, DrepModelBuilder}; +use futures::{stream, StreamExt as _, TryStreamExt as _}; +use metadata::{fetch_drep_metadata, OffchainStore}; use pallas::{ crypto::hash::Hash, ledger::{ @@ -27,177 +33,155 @@ use pallas::{ traverse::{MultiEraBlock, MultiEraTx}, }, }; +use serde::Deserialize; use crate::{ error::Error, - mapping::{bech32, bech32_gov_action, parse_gov_action_id, stake_cred_to_address, IntoModel}, + mapping::{bech32_gov_action, parse_gov_action_id, stake_cred_to_address, IntoModel}, pagination::{Order, Pagination, PaginationParameters}, 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), - } - } - } -} +/// How many anchor fetches one page runs at a time. A page asks for up to +/// 100 rows and every miss costs a round trip capped at the fetch timeout, +/// so the bound keeps a slow page from opening 100 outbound connections at +/// once while a cached row still costs nothing. +const MAX_CONCURRENT_METADATA_FETCHES: usize = 8; + +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()?; -pub struct DrepModelBuilder<'a> { - drep_id: String, - drep_id_encoded: Vec, - is_legacy: bool, - state: Option, - pparams: PParamsSet, - chain: &'a ChainSummary, - tip: BlockSlot, + Ok((chain, tip, pparams)) } -impl<'a> DrepModelBuilder<'a> { - fn is_special_case(&self) -> bool { - ["drep_always_abstain", "drep_always_no_confidence"].contains(&self.drep_id.as_str()) - } +/// 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, +} - fn first_active_epoch(&self) -> Option { - if self.is_special_case() { - return None; +impl DrepsListParameters { + fn pagination(&self) -> PaginationParameters { + PaginationParameters { + count: self.count.clone(), + page: self.page.clone(), + order: self.order.clone(), + from: None, + to: None, } + } - if self - .state - .as_ref() - .map(|x| x.is_unregistered()) - .unwrap_or(true) - { - return 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()), } + } +} - self.state - .as_ref()? - .registered_at - .map(|x| self.chain.slot_epoch(x.0).0) +/// 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()), } +} - fn last_active_epoch(&self) -> Option { - if self.is_special_case() { - return None; - } +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())?; - self.state - .as_ref()? - .last_active_slot - .map(|x| self.chain.slot_epoch(x).0) - } + let pagination = Pagination::try_from(params.pagination())?; + pagination.enforce_max_scan_limit(domain.config.max_scan_items())?; - fn is_drep_expired(&self) -> bool { - if self.is_special_case() { - return false; - } + let (chain, tip, pparams) = chain_context(&domain)?; - if self.is_drep_retired() { - return false; - } + let mut dreps = vec![]; - let last_active_epoch = self.last_active_epoch(); + for item in domain.iter_cardano_entities::(None)? { + let (key, state) = item.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - let inactivity_period = self.pparams.drep_inactivity_period().unwrap_or_default(); + // 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; + } - let expiring_epoch = last_active_epoch.map(|x| x + inactivity_period); + if expired.is_some_and(|wanted| drep_is_expired(&state, &chain, tip, &pparams) != wanted) { + continue; + } - let (current_epoch, _) = self.chain.slot_epoch(self.tip); + let appeared_at = state.first_seen_at.unwrap_or((u64::MAX, usize::MAX)); - expiring_epoch - .map(|expiration| expiration <= current_epoch) - .unwrap_or(false) + dreps.push((appeared_at, key, state)); } - fn is_drep_retired(&self) -> bool { - if self.is_special_case() { - return false; - } + 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), + }; - let Some(state) = self.state.as_ref() else { - return false; - }; + 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)) + }); - match (state.registered_at, state.unregistered_at) { - (Some(registered), Some(unregistered)) => unregistered > registered, - (Some(_), None) => false, - _ => false, + if matches!(pagination.order, Order::Desc) { + dreps.reverse(); } } - fn is_drep_active(&self) -> bool { - !self.is_drep_retired() - } -} + let store = OffchainStore::new( + &domain.storage_config().path, + domain.config.max_offchain_cache_bytes(), + ); -impl<'a> IntoModel for DrepModelBuilder<'a> { - type SortKey = (); + let items = dreps + .into_iter() + .skip(pagination.from()) + .take(pagination.count) + .map(|(_, _, state)| async { + let metadata = fetch_drep_metadata(&store, state.anchor.clone()).await; + let mut model = drep_list_item(state, &pparams, &chain, tip)?; + model.metadata = metadata.map(Box::new); + Ok::<_, StatusCode>(model) + }); - 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), - }; + // buffered keeps the page order while bounding the outbound fan-out + let page = stream::iter(items) + .buffered(MAX_CONCURRENT_METADATA_FETCHES) + .try_collect::>() + .await?; - Ok(out) - } + Ok(Json(page)) } pub async fn drep_by_id( @@ -207,37 +191,27 @@ pub async fn drep_by_id( where Option: From, { - let (drep, drep_bytes, is_legacy, is_special_case) = - parse_drep_id(&drep).map_err(|_| StatusCode::BAD_REQUEST)?; + let parsed = parse_drep_id(&drep)?; - let drep_state = if is_special_case { - None + let drep_state = if parsed.is_special { + domain.read_cardano_entity::(parsed.encoded.clone())? } else { Some( domain - .read_cardano_entity::(drep_bytes.clone())? + .read_cardano_entity::(parsed.encoded.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 (chain, tip, pparams) = chain_context(&domain)?; let model = DrepModelBuilder { - drep_id: drep, - drep_id_encoded: drep_bytes, - is_legacy, + 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: &pparams, chain: &chain, tip, }; @@ -817,12 +791,19 @@ where #[cfg(test)] mod tests { use super::*; + use crate::mapping::bech32; + use crate::mapping::bech32_drep; use crate::test_support::{TestApp, TestFault}; use bech32::{Bech32, Hrp}; - use dolos_cardano::model::GovPurpose; + use blockfrost_openapi::models::drep::Drep as DrepModel; + use dolos_cardano::{ + model::{drep_to_entity_key, DRepExpiry, GovPurpose}, + pallas_extras, + }; use dolos_core::StateWriter as _; use dolos_testing::{synthetic::SyntheticBlockConfig, toy_domain::ToyDomain}; use itertools::Itertools; + use pallas::ledger::primitives::conway::DRep; use pallas::{ codec::utils::Bytes, ledger::primitives::conway::{GovAction, GovActionId}, @@ -833,12 +814,20 @@ mod tests { "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 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") + 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) { @@ -846,21 +835,25 @@ mod tests { 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}"); + 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); - let _model: blockfrost_openapi::models::drep::Drep = - serde_json::from_slice(&body).expect("failed to parse drep model"); + + 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; } @@ -869,6 +862,7 @@ mod tests { let app = TestApp::new(); let missing = missing_drep(); let path = format!("/governance/dreps/{missing}"); + assert_status(&app, &path, StatusCode::NOT_FOUND).await; } @@ -877,9 +871,358 @@ mod tests { 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()); + } + + /// Three DReps whose voting powers and first sightings disagree, so + /// ordering by amount cannot be mistaken for the default ordering and + /// each direction names a different row first. + fn amount_app() -> TestApp { + TestApp::new_with_cfg_and_setup(SyntheticBlockConfig::default(), |domain, _| { + let writer = domain + .state() + .start_writer() + .expect("failed to start writer"); + + // seen in this order, so appearance order is a, b, c while the + // amounts run the other way + for (byte, power, seen) in [(0xa1u8, 300u64, 10u64), (0xb2, 100, 20), (0xc3, 200, 30)] { + let identifier = DRep::Key([byte; 28].into()); + + let mut state = DRepState::new(identifier.clone()); + state.registered_at = Some((seen, 0)); + state.first_seen_at = Some((seen, 0)); + state.voting_power = power; + state.expiry = Some(DRepExpiry::new(u64::MAX, 0)); + + writer + .write_entity_typed(&drep_to_entity_key(&identifier), &state) + .expect("failed to write drep"); + } + + writer.commit().expect("failed to commit dreps"); + }) + } + + /// The seeded DReps in the order they first appeared, which is the + /// listing's default order. + fn seeded_ids() -> Vec { + [0xa1u8, 0xb2, 0xc3] + .iter() + .map(|byte| bech32_drep(&DRep::Key([*byte; 28].into())).expect("failed to encode")) + .collect() + } + + fn listed_ids(models: &[DrepsInner], seeded: &[String]) -> Vec { + models + .iter() + .map(|x| x.drep_id.clone()) + .filter(|id| seeded.contains(id)) + .collect() + } + + #[tokio::test] + async fn governance_dreps_list_order_by_amount() { + let app = amount_app(); + let seeded = seeded_ids(); + let (a, b, c) = (seeded[0].clone(), seeded[1].clone(), seeded[2].clone()); + + // default: the order they were first seen, amounts ignored + let models = get_dreps_list(&app, "/governance/dreps?count=100").await; + assert_eq!( + listed_ids(&models, &seeded), + vec![a.clone(), b.clone(), c.clone()] + ); + + // by amount ascending: 100, 200, 300 + let models = get_dreps_list(&app, "/governance/dreps?count=100&order_by=amount").await; + assert_eq!( + listed_ids(&models, &seeded), + vec![b.clone(), c.clone(), a.clone()] + ); + + // and descending is that read backwards + let models = get_dreps_list( + &app, + "/governance/dreps?count=100&order_by=amount&order=desc", + ) + .await; + assert_eq!(listed_ids(&models, &seeded), vec![a, c, b]); + } + + /// A DRep that never registered and never voted — a vote-delegation + /// target the chain only ever mentioned — has no last-active epoch, and + /// Blockfrost's SQL sends that row to the `ELSE FALSE` arm however old + /// it is. Verified against live Blockfrost on preview, where 13 such + /// DReps sit in the first 3000 rows. + #[tokio::test] + async fn governance_dreps_list_never_active_dreps_never_expire() { + let identifier = DRep::Key([0xd4u8; 28].into()); + + let app = { + let identifier = identifier.clone(); + + TestApp::new_with_cfg_and_setup(SyntheticBlockConfig::default(), move |domain, _| { + let writer = domain + .state() + .start_writer() + .expect("failed to start writer"); + + // seen at the very first slot and silent ever since, with the + // ledger expiry the boundary would have long since tripped + let mut state = DRepState::new(identifier.clone()); + state.first_seen_at = Some((0, 0)); + state.expiry = Some(DRepExpiry::new(0, 0)); + state.expired = true; + + writer + .write_entity_typed(&drep_to_entity_key(&identifier), &state) + .expect("failed to write drep"); + writer.commit().expect("failed to commit drep"); + }) + }; + + let drep_id = bech32_drep(&identifier).expect("failed to encode"); + let model = get_drep(&app, &drep_id).await; + + assert_eq!(model.last_active_epoch, None); + assert!(!model.retired); + assert!(!model.expired, "Blockfrost reports these as not expired"); + + // and the filter agrees with the field + let listed = get_dreps_list(&app, "/governance/dreps?count=100&expired=true").await; + assert!(listed.iter().all(|x| x.drep_id != drep_id)); + + let listed = get_dreps_list(&app, "/governance/dreps?count=100&expired=false").await; + assert!(listed.iter().any(|x| x.drep_id == drep_id)); + } + + /// Ordering by amount has to hold across a page boundary, not just + /// inside one page, which is what the Blockfrost suite checks too. + #[tokio::test] + async fn governance_dreps_list_order_by_amount_spans_pages() { + let app = amount_app(); + let seeded = seeded_ids(); + + let mut paged = vec![]; + + for page in 1..=4 { + let path = format!("/governance/dreps?count=1&order_by=amount&page={page}"); + paged.extend(listed_ids(&get_dreps_list(&app, &path).await, &seeded)); + } + + let whole = listed_ids( + &get_dreps_list(&app, "/governance/dreps?count=100&order_by=amount").await, + &seeded, + ); + + assert_eq!(paged, whole); + } + + #[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; + } + /// Three blocks: the first tx of block 1 proposes two actions, block 2 /// proposes none and block 3 proposes one. Enough to pin the listing /// order, the cert index within a tx and a few action types. diff --git a/crates/minibf/src/test_support.rs b/crates/minibf/src/test_support.rs index d3f5c8e26..c7de3bd3e 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, None) @@ -259,3 +283,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, 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 fc772c2c1..4e34d2e00 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 @@ -1211,7 +1211,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 493595c0b..f93f22fec 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 a80252729..23d3d2e58 100644 --- a/docs/content/apis/minibf.mdx +++ b/docs/content/apis/minibf.mdx @@ -73,12 +73,14 @@ The `serve.minibf` section controls the options for the MiniBF endpoint that can | token_registry_url | string | "https://token-registry.io" | | url | string | "https://minibf.local" | | max_scan_items | integer | 3000 | +| max_offchain_cache_mb | integer | 256 (default) | - `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. - `url`: optional public URL used in the `/` root response. - `max_scan_items`: caps page-based scans for heavy endpoints (defaults to 3000 if unset). +- `max_offchain_cache_mb`: disk the verified DRep metadata cache may use under `/offchain`, in MB (defaults to 256). `0` keeps the cache in memory only and writes nothing. This is an example of the `serve.minibf` fragment with a `dolos.toml` configuration file. @@ -89,6 +91,7 @@ permissive_cors = true token_registry_url = "https://token-registry.io" url = "https://minibf.local" max_scan_items = 3000 +max_offchain_cache_mb = 256 ``` Check the [Configuration Schema](../configuration/schema) for detailed info on how to set this up. @@ -146,6 +149,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 DReps | | `/governance/dreps/{drep_id}` | Get DRep information | | `/governance/proposals` | Get list of governance proposals | | `/governance/proposals/{tx_hash}/{cert_index}` | Get governance proposal information | diff --git a/docs/content/configuration/schema.mdx b/docs/content/configuration/schema.mdx index 773e51b54..87e9a94f5 100644 --- a/docs/content/configuration/schema.mdx +++ b/docs/content/configuration/schema.mdx @@ -125,6 +125,11 @@ here because this volume is already sized for the data; a mainnet transfer stages gigabytes. All but `verify` take `--scratch-dir` to point somewhere else. +It also holds `offchain/`: the DRep metadata MiniBF fetched and verified +against its on-chain hash, kept so a restart does not fetch it again. It stays +within `serve.minibf.max_offchain_cache_mb`, dropping the oldest bodies first, +and deleting it costs nothing but the next fetch. + `dolos snapshot backfill` adds `mithril/`, the immutable window it replays from, for the same reason — the download is data-volume sized, and the command deletes the files it has consumed as it goes. `--download-dir` moves it. @@ -318,12 +323,14 @@ The `serve.minibf` section controls the options for the HTTP endpoint that hosts | token_registry_url | string | "https://token-registry.io" | | url | string | "https://minibf.local" | | max_scan_items | integer | 3000 | +| max_offchain_cache_mb | integer | 256 (default) | - `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 (defaults to `true`). - `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). +- `max_offchain_cache_mb`: disk the verified DRep metadata cache may use under `/offchain`, in MB (defaults to 256). `0` keeps the cache in memory only and writes nothing. ## `serve.minikupo` section 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")), };