Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.7.0-alpha.1] - 2026-08-24

### 🚀 Features
Expand Down
2 changes: 2 additions & 0 deletions crates/cardano/src/ewrap/loading.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1806,6 +1806,7 @@ mod tests {
identifier,
anchor: None,
expiry: None,
first_seen_at: None,
}
}

Expand Down Expand Up @@ -2859,6 +2860,7 @@ mod ratification_tests {
identifier: drep(),
anchor: None,
expiry: None,
first_seen_at: Some((0, 0)),
};

writer
Expand Down
156 changes: 148 additions & 8 deletions crates/cardano/src/model/dreps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8> {
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
Expand Down Expand Up @@ -116,6 +120,13 @@ pub struct DRepState {
// anything else.
#[n(8)]
pub expiry: Option<DRepExpiry>,

// 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 {
Expand All @@ -130,6 +141,7 @@ impl DRepState {
identifier,
anchor: None,
expiry: None,
first_seen_at: None,
}
}

Expand Down Expand Up @@ -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,
Expand All @@ -189,6 +202,7 @@ pub(crate) mod testing {
deposit,
anchor,
expiry,
first_seen_at,
}
}
}
Expand Down Expand Up @@ -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<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; 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<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 @@ -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(
Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -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,
Expand All @@ -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]
Expand All @@ -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();
Expand Down
7 changes: 7 additions & 0 deletions crates/cardano/src/model/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,9 @@ pub enum CardanoDelta {
GovDistrRotate(Box<GovDistrRotate>),
ProposalResolved(Box<ProposalResolved>),
GovDistrBoundaryCredit(Box<GovDistrBoundaryCredit>),
// The WAL stores this enum positionally: append new variants at the end,
// never insert them mid-enum.
DRepSeen(Box<DRepSeen>),
}

impl CardanoDelta {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down
11 changes: 10 additions & 1 deletion crates/cardano/src/roll/dreps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<DRep> {
Expand Down Expand Up @@ -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) => {
Expand Down
20 changes: 20 additions & 0 deletions crates/core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -785,6 +785,8 @@ pub struct MinibfConfig {
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
max_scan_items: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
max_offchain_cache_mb: Option<u64>,
}

impl MinibfConfig {
Expand All @@ -795,6 +797,7 @@ impl MinibfConfig {
token_registry_url: None,
url: None,
max_scan_items: None,
max_offchain_cache_mb: None,
}
}

Expand All @@ -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)]
Expand Down Expand Up @@ -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
}
Expand Down
Loading
Loading