Skip to content
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
103 changes: 103 additions & 0 deletions crates/cardano/src/model/dreps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ pub struct DRepState {
// `None`. Index 7 must not be reused for anything else.
#[n(7)]
pub anchor: Option<Anchor>,

/// 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 {
Expand All @@ -61,6 +66,7 @@ impl DRepState {
deposit: 0,
identifier,
anchor: None,
first_seen_at: None,
}
}

Expand Down Expand Up @@ -98,6 +104,7 @@ pub(crate) mod testing {
expired in any::<bool>(),
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,
Expand All @@ -108,6 +115,7 @@ pub(crate) mod testing {
expired,
deposit,
anchor,
first_seen_at,
}
}
}
Expand All @@ -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<Anchor>,
}

impl DRepRegistration {
Expand All @@ -148,6 +157,7 @@ impl DRepRegistration {
prev_registered_at: None,
prev_voting_power: 0,
prev_deposit: 0,
prev_anchor: None,
}
}
}
Expand All @@ -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<DRepState>) {
Expand All @@ -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();
}
}

Expand Down Expand Up @@ -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<DRepState>) {
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<DRepState>) {
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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)));
}
}
5 changes: 5 additions & 0 deletions crates/cardano/src/model/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ pub enum CardanoDelta {
DRepAnchorUpdate(Box<DRepAnchorUpdate>),
NewProposalV2(Box<NewProposalV2>),
VoteCast(Box<VoteCast>),
DRepSeen(Box<DRepSeen>),
}

impl CardanoDelta {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down
11 changes: 9 additions & 2 deletions crates/cardano/src/roll/dreps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<DRep> {
Expand Down Expand Up @@ -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));
}
Comment thread
vladimirvolek marked this conversation as resolved.

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) => {
Expand Down
1 change: 1 addition & 0 deletions crates/minibf/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,7 @@ where
.route("/pools/extended", get(routes::pools::all_extended::<D>))
.route("/pools/retiring", get(routes::pools::all_retiring::<D>))
.route("/pools/{id}", get(routes::pools::by_id::<D>))
.route("/governance/dreps", get(routes::governance::all_dreps::<D>))
.route(
"/governance/dreps/{drep_id}",
get(routes::governance::drep_by_id::<D>),
Expand Down
2 changes: 1 addition & 1 deletion crates/minibf/src/mapping.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ pub fn rational_to_f64<const DECIMALS: u8>(val: &alonzo::RationalNumber) -> f64
round_f64::<DECIMALS>(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");
Expand Down
Loading
Loading