diff --git a/CHANGELOG.md b/CHANGELOG.md index fc49a9c76..e54bc28c8 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.5.0] - 2026-07-16 ### 🚀 Features diff --git a/crates/cardano/src/model/dreps.rs b/crates/cardano/src/model/dreps.rs index a7085c3e4..c680bb940 100644 --- a/crates/cardano/src/model/dreps.rs +++ b/crates/cardano/src/model/dreps.rs @@ -48,6 +48,11 @@ pub struct DRepState { // `None`. Index 7 must not be reused for anything else. #[n(7)] pub anchor: Option, + + /// First on-chain reference by any certificate, vote delegations included. + /// Mirrors db-sync's `drep_hash` insertion order. + #[n(8)] + pub first_seen_at: Option<(BlockSlot, TxOrder)>, } impl DRepState { @@ -61,6 +66,7 @@ impl DRepState { deposit: 0, identifier, anchor: None, + first_seen_at: None, } } @@ -98,6 +104,7 @@ pub(crate) mod testing { expired in any::(), deposit in root::any_lovelace(), anchor in prop::option::of(root::any_anchor()), + first_seen_at in prop::option::of((root::any_slot(), root::any_tx_order())), ) -> DRepState { DRepState { identifier, @@ -108,6 +115,7 @@ pub(crate) mod testing { expired, deposit, anchor, + first_seen_at, } } } @@ -128,6 +136,7 @@ pub struct DRepRegistration { pub(crate) prev_registered_at: Option<(BlockSlot, TxOrder)>, pub(crate) prev_voting_power: u64, pub(crate) prev_deposit: u64, + pub(crate) prev_anchor: Option, } impl DRepRegistration { @@ -148,6 +157,7 @@ impl DRepRegistration { prev_registered_at: None, prev_voting_power: 0, prev_deposit: 0, + prev_anchor: None, } } } @@ -168,11 +178,15 @@ impl dolos_core::EntityDelta for DRepRegistration { self.prev_registered_at = entity.registered_at; self.prev_voting_power = entity.voting_power; self.prev_deposit = entity.deposit; + self.prev_anchor = entity.anchor.clone(); // apply changes entity.registered_at = Some((self.slot, self.txorder)); entity.voting_power = self.deposit; entity.deposit = self.deposit; + + // Registration anchor replaces the previous one (including clearing it). + entity.anchor = self.anchor.clone(); } fn undo(&self, entity: &mut Option) { @@ -184,6 +198,7 @@ impl dolos_core::EntityDelta for DRepRegistration { entity.registered_at = self.prev_registered_at; entity.voting_power = self.prev_voting_power; entity.deposit = self.prev_deposit; + entity.anchor = self.prev_anchor.clone(); } } @@ -243,6 +258,61 @@ 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 + if entity.first_seen_at.is_none() { + entity.first_seen_at = Some((self.slot, self.txorder)); + } + } + + 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, @@ -439,6 +509,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( @@ -487,5 +567,28 @@ mod prop_tests { ) { assert_delta_roundtrip(Some(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_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))); } } diff --git a/crates/cardano/src/model/mod.rs b/crates/cardano/src/model/mod.rs index 57f22b0d5..e75262fc4 100644 --- a/crates/cardano/src/model/mod.rs +++ b/crates/cardano/src/model/mod.rs @@ -233,6 +233,7 @@ pub enum CardanoDelta { DRepAnchorUpdate(Box), NewProposalV2(Box), VoteCast(Box), + DRepSeen(Box), } impl CardanoDelta { @@ -284,6 +285,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); @@ -344,6 +346,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(), @@ -397,6 +400,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), @@ -450,6 +454,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 76af38968..f4c06d1ce 100644 --- a/crates/cardano/src/roll/dreps.rs +++ b/crates/cardano/src/roll/dreps.rs @@ -8,8 +8,9 @@ use pallas::ledger::{ use super::WorkDeltas; use crate::{ - owned::OwnedMultiEraOutput, pallas_extras::stake_cred_to_drep, roll::BlockVisitor, - DRepActivity, DRepAnchorUpdate, DRepRegistration, DRepUnRegistration, + owned::OwnedMultiEraOutput, pallas_extras, pallas_extras::stake_cred_to_drep, + roll::BlockVisitor, DRepActivity, DRepAnchorUpdate, DRepRegistration, DRepSeen, + DRepUnRegistration, }; fn cert_drep(cert: &MultiEraCert) -> Option { @@ -64,10 +65,16 @@ impl BlockVisitor for DRepStateVisitor { order: &TxOrder, cert: &MultiEraCert, ) -> Result<(), ChainError> { + 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/minibf/src/lib.rs b/crates/minibf/src/lib.rs index f67cc7039..006e7b7ab 100644 --- a/crates/minibf/src/lib.rs +++ b/crates/minibf/src/lib.rs @@ -487,6 +487,7 @@ where .route("/pools/extended", get(routes::pools::all_extended::)) .route("/pools/retiring", get(routes::pools::all_retiring::)) .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 af754fe4d..2a7330e77 100644 --- a/crates/minibf/src/mapping.rs +++ b/crates/minibf/src/mapping.rs @@ -79,7 +79,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..03fe9de9c --- /dev/null +++ b/crates/minibf/src/routes/governance/dreps.rs @@ -0,0 +1,329 @@ +use crate::mapping::{bech32, bech32_drep, IntoModel, DREP_HRP}; +use axum::http::StatusCode; +use blockfrost_openapi::models::{Drep, DrepsInner}; +use dolos_cardano::{model::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)?; + + // first 4 bits need to be equal to 0010 + if header_byte & 0b11110000 != 0b00100000 { + 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, + )), + ("drep_vkh", 28) => Ok(ParsedDRep::legacy( + bech32(DREP_HRP, &payload).map_err(|_| StatusCode::BAD_REQUEST)?, + payload, + pallas_extras::DREP_KEY_PREFIX, + )), + ("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), + } + } + } +} + +pub struct DrepModelBuilder<'a> { + pub drep_id: String, + pub drep_id_encoded: Vec, + pub is_legacy: bool, + pub state: Option, + pub delegated_stake: u64, + pub pparams: PParamsSet, + pub chain: &'a ChainSummary, + pub 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); + + // Blockfrost expires a DRep once `current - last_active > drep_activity`, + // so the epoch where the two are exactly equal is still active. + expiring_epoch + .map(|expiration| expiration < current_epoch) + .unwrap_or(false) + } + + fn is_drep_retired(&self) -> bool { + if self.is_special_case() { + return false; + } + + self.state + .as_ref() + .map(|x| x.is_unregistered()) + .unwrap_or(false) + } + + fn is_drep_active(&self) -> bool { + !self.is_drep_retired() + } + + fn hex_value(&self) -> String { + 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) + } + } + + fn amount(&self) -> String { + self.delegated_stake.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, + delegated_stake: u64, + pparams: PParamsSet, + chain: &ChainSummary, + tip: BlockSlot, +) -> Result { + let drep_id = bech32_drep(&state.identifier)?; + + let drep_id_encoded = match &state.identifier { + DRep::Key(hash) => [vec![pallas_extras::DREP_KEY_PREFIX], hash.to_vec()].concat(), + DRep::Script(hash) => [vec![pallas_extras::DREP_SCRIPT_PREFIX], hash.to_vec()].concat(), + DRep::Abstain => vec![0], + DRep::NoConfidence => vec![1], + }; + + let builder = DrepModelBuilder { + drep_id, + drep_id_encoded, + is_legacy: false, + state: Some(state), + delegated_stake, + 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_vkh_and_script() { + let hash = vec![7u8; 28]; + let cip105 = encode_id("drep", &hash); + + assert_eq!( + parse_drep_id(&encode_id("drep_vkh", &hash)), + Ok(ParsedDRep::legacy( + cip105.clone(), + hash.clone(), + pallas_extras::DREP_KEY_PREFIX, + )) + ); + + 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_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()); + // 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_vkh", &[7u8; 29])).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..c5f940645 --- /dev/null +++ b/crates/minibf/src/routes/governance/metadata.rs @@ -0,0 +1,173 @@ +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::{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 http and https URLs are allowed."), + ) +} + +fn is_fetchable(url: &str) -> bool { + reqwest::Url::parse(url).is_ok_and(|x| matches!(x.scheme(), "http" | "https")) +} + +fn http_client() -> Option<&'static reqwest::Client> { + static CLIENT: OnceLock> = OnceLock::new(); + + CLIENT + .get_or_init(|| { + reqwest::Client::builder() + .timeout(Duration::from_secs(5)) + .redirect(reqwest::redirect::Policy::limited(3)) + .user_agent("Dolos MiniBF") + .build() + .ok() + }) + .as_ref() +} + +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() != StatusCode::OK { + 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(), + ), + ); + } + + out.json_metadata = serde_json::from_slice(&body).ok(); + out.bytes = Some(format!("\\x{}", hex::encode(&body))); + + 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 accepts_http_urls() { + assert!(is_fetchable("https://example.com/meta.json")); + assert!(is_fetchable("http://example.com/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..63c565327 --- /dev/null +++ b/crates/minibf/src/routes/governance/mod.rs @@ -0,0 +1,408 @@ +mod dreps; +mod metadata; + +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + Json, +}; +use blockfrost_openapi::models::DrepsInner; +use dolos_cardano::{ + model::{drep_to_entity_key, AccountState, DRepDelegation, DRepState}, + ChainSummary, PParamsSet, +}; +use dolos_core::{ArchiveStore as _, BlockSlot, Domain, EntityKey}; +use dreps::{drep_list_item, parse_drep_id, DrepModelBuilder}; +use futures::future::join_all; +use metadata::fetch_drep_metadata; +use std::collections::HashMap; + +use crate::{ + error::Error, + mapping::IntoModel as _, + pagination::{Order, Pagination, PaginationParameters}, + Facade, +}; + +/// Stake delegated to each DRep, keyed the same way DRep entities are. +/// +/// `only` narrows the fold to a single DRep so the by-id route doesn't +/// materialize the whole map just to read one entry. +fn drep_stake_map( + domain: &Facade, + only: Option<&EntityKey>, +) -> Result, StatusCode> +where + Option: From, +{ + let mut out: HashMap = HashMap::new(); + + for item in domain + .iter_cardano_entities::(None) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + { + let (_, account) = item.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let Some(DRepDelegation::Delegated(drep)) = account.drep.set() else { + continue; + }; + + let key = drep_to_entity_key(drep); + + if only.is_some_and(|target| target != &key) { + continue; + } + + let stake = account.stake.set().map(|x| x.total()).unwrap_or_default(); + + *out.entry(key).or_default() += stake; + } + + Ok(out) +} + +fn chain_context( + domain: &Facade, +) -> Result<(ChainSummary, BlockSlot, PParamsSet), StatusCode> { + let chain = domain.get_chain_summary()?; + + 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()?; + + Ok((chain, tip, pparams)) +} + +pub async fn all_dreps( + Query(params): Query, + State(domain): State>, +) -> Result>, Error> +where + Option: From, + Option: From, +{ + let pagination = Pagination::try_from(params)?; + + let mut dreps = vec![]; + + for item in domain.iter_cardano_entities::(None)? { + let (key, state) = item.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let appeared_at = state.first_seen_at.unwrap_or((u64::MAX, usize::MAX)); + + dreps.push((appeared_at, key, state)); + } + + 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 (chain, tip, pparams) = chain_context(&domain)?; + let stake = drep_stake_map(&domain, None)?; + + let states: Vec<_> = dreps + .into_iter() + .skip(pagination.from()) + .take(pagination.count) + .map(|(_, key, state)| (key, state)) + .collect(); + + let metadata_futures: Vec<_> = states + .iter() + .map(|(_, state)| fetch_drep_metadata(state.anchor.clone())) + .collect(); + + let metadatas = join_all(metadata_futures).await; + + let page = states + .into_iter() + .zip(metadatas) + .map(|((key, state), metadata)| { + let delegated = stake.get(&key).copied().unwrap_or_default(); + let mut model = drep_list_item(state, delegated, pparams.clone(), &chain, tip)?; + model.metadata = metadata.map(Box::new); + Ok(model) + }) + .collect::, StatusCode>>()?; + + Ok(Json(page)) +} + +pub async fn drep_by_id( + Path(drep): Path, + State(domain): State>, +) -> Result, StatusCode> +where + Option: From, + 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 drep_key = EntityKey::from(parsed.encoded.clone()); + let delegated_stake = drep_stake_map(&domain, Some(&drep_key))? + .get(&drep_key) + .copied() + .unwrap_or_default(); + + let model = DrepModelBuilder { + drep_id: parsed.drep_id, + drep_id_encoded: parsed.encoded, + is_legacy: parsed.is_legacy, + state: drep_state, + delegated_stake, + 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), + amount: "0".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); + assert_eq!( + get_drep(&app, &encode_id("drep_vkh", &hash)).await, + expected + ); + } + + #[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), + amount: "0".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; + } + + #[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 b579e6935..b2908216d 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(), @@ -107,6 +118,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(); @@ -190,3 +214,44 @@ impl TestApp { &self.vectors } } + +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(); + + let domain = dolos_testing::faults::FaultyToyDomain::new(domain, TestFault::None); + + let cfg = MinibfConfig::new("[::]:0".parse().expect("invalid listen address")); + + let facade = Facade { + inner: domain.clone(), + config: cfg, + cache: crate::cache::CacheService::default(), + }; + + let router = build_router_with_facade(facade); + + TestApp { + router, + _domain: domain, + vectors, + } + } +} diff --git a/docs/content/apis/minibf.mdx b/docs/content/apis/minibf.mdx index 2fbdfa4ef..6c3d743fa 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). @@ -130,6 +130,7 @@ Dolos provides many, but not all of the Blockfrost endpoints. The following list | `/epochs/{epoch}/blocks` | Get blocks in a specific epoch | | `/epochs/{epoch}/parameters` | Get epoch parameters | | `/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")), };