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: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Dolos uses three distinct storage backends, each serving a specific purpose:

### ArchiveStore
- **Purpose**: Historical block storage with temporal indexing
- **Contents**: Block bodies indexed by slot (one zstd frame per body in flat segment files, compressed with the dictionary bundled in `dolos-flatfiles`), entity logs keyed by `LogKey` (slot + entity key), and the lookups over them: archive tags (by address, payment, stake, policy, asset, datum, …) and exact lookups (by block hash, block number, tx hash), written in the same batch as the blocks they project
- **Contents**: Block bodies indexed by slot (one zstd frame per body in flat segment files, compressed with the dictionary bundled in `dolos-flatfiles`), entity logs keyed by `LogKey` (slot + entity key), and the lookups over them: archive tags (by address, payment, stake, policy, asset, datum, …), exact lookups (by block hash, block number, tx hash) and the stake address log (each account's addresses by first appearance), written in the same batch as the blocks they project
- **Traits**: `ArchiveStore` (reads) + `ArchiveWriter` (batched writes)
- **Database**: `<storage.path>/archive` (index plus flat block segment files)

Expand All @@ -31,6 +31,7 @@ There is no standalone index store. Every index is a projection and lives in
the store that holds what it projects:
- the live-UTxO tags (by address, payment, stake, policy, asset, script ref) project the UTxO set and live in the `StateStore` (`StateStore::utxos_by_tag`, written through `StateWriter::apply_utxo_tags` in the same batch as the set)
- the archive tags and the exact lookups (by block hash, block number, tx hash) project the block history and live in the `ArchiveStore` (`ArchiveStore::slots_by_tag` / `slot_by_*`, written through `ArchiveWriter::apply_index` in the same batch as the blocks)
- the stake address log (each `(stake, address)` pair once, at its first on-chain appearance) projects the block history too and lives in the `ArchiveStore` (`ArchiveStore::addresses_by_stake_log`, written through the same `ArchiveWriter::apply_index`)

### Database File Organization

Expand Down Expand Up @@ -105,11 +106,12 @@ The project follows a modular workspace architecture with clear separation of co
- **`state-utxos`**: UTxO set storage with `[tx_hash:32][index:4]` keys
- **`state-entities`**: All entity types with `[ns_hash:8][entity_key:32]` keys
- **`state-tags`**: Live-UTxO tags with `[dim_hash:8][lookup_key:var][txo_ref:36]` keys
- `archive`: `ArchiveStore` implementation with four-keyspace design:
- `archive`: `ArchiveStore` implementation with five-keyspace design:
- **`archive-blocks`**: Slot -> packed physical frame locations in the flat segment files
- **`archive-logs`**: All log namespaces with `[ns_hash:8][log_key:40]` keys
- **`archive-tags`**: Tag-based prefix scans for block tags with `[dim_hash:8][key_hash:8][slot:8]` keys
- **`index-exact`**: Exact-match lookups with `[dim_hash:8][key_data:var]` -> `[slot:8]`
- **`archive-stake-log`**: Stake address log, `(stake, address)` pairs at first appearance, page reads by stake prefix from either end
- `keys`: Shared key encoding utilities
- **Key Advantages**:
- Reduced segment files compared to per-entity keyspaces
Expand Down
89 changes: 86 additions & 3 deletions crates/cardano/src/indexes/delta.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
//! store).

use dolos_core::{
ArchiveIndexDelta, BlockSlot, EraCbor, Tag, TxoRef, UtxoIndexDelta, UtxoMap, UtxoSetDelta,
ArchiveIndexDelta, BlockSlot, EraCbor, StakeAddressAppearance, Tag, TxoRef, UtxoIndexDelta,
UtxoMap, UtxoSetDelta,
};
use pallas::{
codec::minicbor,
Expand Down Expand Up @@ -133,6 +134,7 @@ impl CardanoIndexDeltaBuilder {
block_number: number,
tx_hashes: Vec::new(),
tags: Vec::new(),
stake_addresses: Vec::new(),
});
}

Expand Down Expand Up @@ -171,6 +173,34 @@ impl CardanoIndexDeltaBuilder {
}
}

/// Record an address appearing under its stake credential in the current
/// block, for the stake address log. Addresses without a stake credential
/// (Byron, enterprise) are not logged.
///
/// `order` places the appearance inside the block; see
/// [`stake_appearance_order`].
pub fn add_stake_appearance(&mut self, order: u32, addr: &Address) {
let stake = match addr {
Address::Shelley(x) => {
pallas_extras::shelley_address_to_stake_address(x).map(|s| s.to_vec())
}
Address::Stake(x) => Some(x.to_vec()),
Address::Byron(_) => None,
};

let Some(stake) = stake else {
return;
};

self.current_block()
.stake_addresses
.push(StakeAddressAppearance {
order,
stake,
address: addr.to_vec(),
});
}

/// Add asset tags to the current block.
pub fn add_assets(&mut self, assets: &MultiEraValue) {
let block = self.current_block();
Expand Down Expand Up @@ -291,7 +321,7 @@ impl CardanoIndexDeltaBuilder {

self.start_block(block.slot(), block.hash().to_vec(), Some(block.number()));

for tx in block.txs() {
for (tx_order, tx) in block.txs().iter().enumerate() {
self.add_tx_hash(tx.hash().to_vec());

for (label, _) in tx.metadata().collect::<Vec<_>>() {
Expand All @@ -315,9 +345,13 @@ impl CardanoIndexDeltaBuilder {
}
}

for (_, output) in tx.produces() {
for (output_order, output) in tx.produces() {
if let Ok(addr) = output.address() {
self.add_address(&addr);
self.add_stake_appearance(
stake_appearance_order(tx_order, output_order),
&addr,
);
}
self.add_assets(&output.value());
if let Some(datum) = output.datum() {
Expand Down Expand Up @@ -437,6 +471,13 @@ impl CardanoIndexDeltaBuilder {
}
}

/// The position of a produced output inside its block, as the stake address
/// log orders appearances within one slot: transaction index in the high 16
/// bits, output index in the low 16.
pub fn stake_appearance_order(tx_order: usize, output_order: usize) -> u32 {
((tx_order as u32) << 16) | (output_order as u32 & 0xffff)
}

/// The live-UTxO tag changes a `UtxoSetDelta` implies.
///
/// Pairs with `StateWriter::apply_utxoset`: every ref that delta puts into the
Expand Down Expand Up @@ -485,6 +526,48 @@ mod tests {
assert_eq!(archive[0].tags.len(), 3);
}

/// Only addresses with a stake credential feed the stake address log, and
/// the appearance carries the credential the endpoint queries by.
#[test]
fn stake_appearances_carry_the_stake_credential() {
use pallas::ledger::addresses::ByronAddress;

let mut builder = CardanoIndexDeltaBuilder::new();
builder.start_block(100, vec![0; 32], Some(50));

let shelley = test_shelley_address();
builder.add_stake_appearance(stake_appearance_order(1, 2), &shelley);

let enterprise = Address::Shelley(ShelleyAddress::new(
Network::Testnet,
ShelleyPaymentPart::Key([1; 28].as_slice().into()),
ShelleyDelegationPart::Null,
));
builder.add_stake_appearance(stake_appearance_order(1, 3), &enterprise);

let byron = Address::Byron(ByronAddress::new(&[0x82; 20], 0));
builder.add_stake_appearance(stake_appearance_order(2, 0), &byron);

let archive = builder.build();
let appearances = &archive[0].stake_addresses;

let Address::Shelley(shelley_inner) = &shelley else {
unreachable!()
};
let stake = pallas_extras::shelley_address_to_stake_address(shelley_inner)
.expect("test address delegates to a key")
.to_vec();

assert_eq!(
appearances,
&[StakeAddressAppearance {
order: (1 << 16) | 2,
stake,
address: shelley.to_vec(),
}]
);
}

/// An output that carries a reference script gets a `script_ref` tag whose
/// key is the script's on-chain hash.
#[test]
Expand Down
4 changes: 3 additions & 1 deletion crates/cardano/src/indexes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ mod dimensions;
mod ext;
mod query;

pub use delta::{utxo_index_delta_from_utxo_delta, CardanoIndexDeltaBuilder};
pub use delta::{
stake_appearance_order, utxo_index_delta_from_utxo_delta, CardanoIndexDeltaBuilder,
};
pub use dimensions::{archive as archive_dimensions, utxo as utxo_dimensions};
pub use ext::{CardanoArchiveIndexExt, CardanoStateIndexExt};
pub use query::{AsyncCardanoQueryExt, ScriptData, ScriptLanguage, SlotOrder};
19 changes: 19 additions & 0 deletions crates/core/src/archive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,25 @@ pub trait ArchiveStore: Clone + Send + Sync + 'static {
end: BlockSlot,
) -> Result<Self::SlotIter, ArchiveError>;

/// Read one page of the stake address log: the addresses seen under the
/// stake credential, ordered by first on-chain appearance.
///
/// `offset` and `limit` window the ordered list; `reverse` reads the
/// exact reverse of it. An unknown credential is an empty page.
///
/// Entries come from `ArchiveIndexDelta::stake_addresses` through
/// [`ArchiveWriter::apply_index`], in the same batch as the blocks they
/// project. [`ArchiveWriter::undo_index`] removes a pair only when the
/// undone block is its stored first appearance. The log is as complete
/// as the history that was applied through that path.
fn addresses_by_stake_log(
&self,
stake: &[u8],
offset: usize,
limit: usize,
reverse: bool,
) -> Result<Vec<Vec<u8>>, ArchiveError>;

/// Iterate every archive tag record whose slot falls in `slots`.
///
/// `slots` is **half-open** (`start..end`), unlike
Expand Down
107 changes: 104 additions & 3 deletions crates/core/src/builtin/memory/archive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@
//! keyed `(kind, key)`. The disk backend encodes `[dim_hash][key][slot]`
//! triples into a flat keyspace and can only reach the contract's order by
//! walking a caller-supplied dimension list in name order; keyed on the
//! triple, that order is the map's own and holds by construction.
//! triple, that order is the map's own and holds by construction;
//! - the stake address log lives in two maps: the ordered entries per stake
//! credential, keyed `(slot, order, address)` so a page read is a walk from
//! either end, and a membership map from `(stake, address)` to the pair's
//! first appearance, which is the write-path probe and the undo check.
//!
//! ## Stored key form
//!
Expand Down Expand Up @@ -46,8 +50,8 @@ use pallas::ledger::traverse::MultiEraBlock;

use crate::archive::{ArchiveError, ArchiveStore, ArchiveWriter, LogKey, Skippable};
use crate::indexes::{
key_hash, ArchiveIndexDelta, ExactKind, ExactRecord, IndexRecord, KeyHash, TagDimension,
TagRecord, MAX_EXACT_KEY_LEN,
key_hash, ArchiveIndexDelta, ExactKind, ExactRecord, IndexRecord, KeyHash,
StakeAddressAppearance, TagDimension, TagRecord, MAX_EXACT_KEY_LEN,
};
use crate::{
BlockBody, BlockSlot, ChainPoint, EntityValue, Namespace, RawBlock, StateSchema, TemporalKey,
Expand All @@ -61,6 +65,12 @@ type ArchiveTag = (Cow<'static, str>, KeyHash, BlockSlot);
/// heap allocation on either side.
type ExactKey = [u8; MAX_EXACT_KEY_LEN];

/// One ordered entry of the stake address log: `(slot, order, address)`.
type StakeLogEntry = (BlockSlot, u32, Vec<u8>);

/// A `(stake, address)` pair, the membership key of the stake address log.
type StakeLogPair = (Vec<u8>, Vec<u8>);

/// A key's stored form, or `None` unless it is exactly the width its kind
/// requires.
///
Expand Down Expand Up @@ -91,6 +101,12 @@ struct Tables {
/// Keyed on the record's own inline key rather than a `Vec`, so
/// `iter_exact_records` copies rather than allocates per record.
exact: BTreeMap<(ExactKind, ExactKey), BlockSlot>,
/// Stake address log: first appearances ordered `(slot, order, address)`
/// per stake credential.
stake_log: BTreeMap<Vec<u8>, BTreeSet<StakeLogEntry>>,
/// Membership map for the log: each pair's first appearance, which is
/// also what an undo has to match before it may remove the pair.
stake_log_pairs: BTreeMap<StakeLogPair, (BlockSlot, u32)>,
}

/// A single mutation, recorded by a writer and replayed at commit.
Expand All @@ -106,6 +122,8 @@ enum Op {
RemoveArchiveTag(ArchiveTag),
InsertExact(ExactKind, ExactKey, BlockSlot),
RemoveExact(ExactKind, ExactKey),
InsertStakeAddress(BlockSlot, StakeAddressAppearance),
RemoveStakeAddress(BlockSlot, StakeAddressAppearance),
}

fn poisoned() -> ArchiveError {
Expand Down Expand Up @@ -244,6 +262,10 @@ impl ArchiveWriter for MemoryArchiveWriter {
for tag in Self::archive_tags_of(block) {
ops.push(Op::InsertArchiveTag(tag));
}

for appearance in &block.stake_addresses {
ops.push(Op::InsertStakeAddress(block.slot, appearance.clone()));
}
}

Ok(())
Expand All @@ -263,6 +285,10 @@ impl ArchiveWriter for MemoryArchiveWriter {
for tag in Self::archive_tags_of(block) {
ops.push(Op::RemoveArchiveTag(tag));
}

for appearance in block.stake_addresses.iter().rev() {
ops.push(Op::RemoveStakeAddress(block.slot, appearance.clone()));
}
}

Ok(())
Expand Down Expand Up @@ -316,6 +342,9 @@ impl ArchiveWriter for MemoryArchiveWriter {

let mut tables = self.store.tables.write().map_err(|_| poisoned())?;

// Reborrow so the match arms can hold disjoint field borrows.
let tables = &mut *tables;

for op in ops {
match op {
// An identical body means this block is being written again
Expand Down Expand Up @@ -359,6 +388,45 @@ impl ArchiveWriter for MemoryArchiveWriter {
Op::RemoveExact(kind, key) => {
tables.exact.remove(&(kind, key));
}
// Only the first appearance of a pair is kept; the membership
// map is the probe, and it sees this batch's earlier inserts.
Op::InsertStakeAddress(slot, app) => {
let pair = (app.stake.clone(), app.address.clone());

if let std::collections::btree_map::Entry::Vacant(entry) =
tables.stake_log_pairs.entry(pair)
{
entry.insert((slot, app.order));
tables.stake_log.entry(app.stake).or_default().insert((
slot,
app.order,
app.address,
));
}
}
// Removed only when the undone block is the pair's stored
// first appearance; a pair seen earlier stays untouched.
Op::RemoveStakeAddress(slot, app) => {
let pair = (app.stake.clone(), app.address.clone());

let Some(&(first_slot, order)) = tables.stake_log_pairs.get(&pair) else {
continue;
};

if first_slot != slot {
continue;
}

tables.stake_log_pairs.remove(&pair);

if let Some(set) = tables.stake_log.get_mut(&app.stake) {
set.remove(&(slot, order, app.address));

if set.is_empty() {
tables.stake_log.remove(&app.stake);
}
}
}
}
}

Expand Down Expand Up @@ -633,6 +701,10 @@ impl ArchiveStore for MemoryArchiveStore {
.retain(|(_, _, slot)| *slot >= prune_before);
tables.exact.retain(|_, slot| *slot >= prune_before);

// The stake address log is left alone, as on the disk backend: its
// entries are first appearances, so removing one below the cutoff
// would drop an address the account may still use.

Ok(done)
}

Expand Down Expand Up @@ -717,6 +789,35 @@ impl ArchiveStore for MemoryArchiveStore {
Ok(MemorySlotIter(slots.into_iter()))
}

fn addresses_by_stake_log(
&self,
stake: &[u8],
offset: usize,
limit: usize,
reverse: bool,
) -> Result<Vec<Vec<u8>>, ArchiveError> {
let tables = self.tables.read().map_err(|_| poisoned())?;

let Some(set) = tables.stake_log.get(stake) else {
return Ok(Vec::new());
};

let pick = |entry: &StakeLogEntry| entry.2.clone();

let page = if reverse {
set.iter()
.rev()
.skip(offset)
.take(limit)
.map(pick)
.collect()
} else {
set.iter().skip(offset).take(limit).map(pick).collect()
};

Ok(page)
}

fn iter_archive_tags(
&self,
dimensions: &[TagDimension],
Expand Down
Loading
Loading