From e651cbd1005363d2d9a1d9f5798d911b900b4b1c Mon Sep 17 00:00:00 2001 From: William Hankins Date: Mon, 22 Sep 2025 22:47:44 +0000 Subject: [PATCH 01/18] feat: address state module (WIP) Signed-off-by: William Hankins --- Cargo.lock | 16 + common/src/address.rs | 16 +- common/src/queries/addresses.rs | 43 +-- common/src/types.rs | 13 + modules/address_state/Cargo.toml | 23 ++ modules/address_state/src/address_registry.rs | 120 +++++++ modules/address_state/src/address_state.rs | 300 ++++++++++++++++++ modules/address_state/src/state.rs | 168 ++++++++++ modules/utxo_state/src/utxo_state.rs | 7 - processes/omnibus/Cargo.toml | 1 + processes/omnibus/omnibus.toml | 13 + processes/omnibus/src/main.rs | 1 + 12 files changed, 676 insertions(+), 45 deletions(-) create mode 100644 modules/address_state/Cargo.toml create mode 100644 modules/address_state/src/address_registry.rs create mode 100644 modules/address_state/src/address_state.rs create mode 100644 modules/address_state/src/state.rs diff --git a/Cargo.lock b/Cargo.lock index 17a31c79..b69f797d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -54,6 +54,21 @@ dependencies = [ "tracing", ] +[[package]] +name = "acropolis_module_address_state" +version = "0.1.0" +dependencies = [ + "acropolis_common", + "anyhow", + "caryatid_sdk", + "config", + "hex", + "imbl 5.0.0", + "serde_cbor", + "tokio", + "tracing", +] + [[package]] name = "acropolis_module_assets_state" version = "0.1.0" @@ -359,6 +374,7 @@ version = "0.1.0" dependencies = [ "acropolis_common", "acropolis_module_accounts_state", + "acropolis_module_address_state", "acropolis_module_assets_state", "acropolis_module_block_unpacker", "acropolis_module_drdd_state", diff --git a/common/src/address.rs b/common/src/address.rs index 3570ac64..6aaa1e29 100644 --- a/common/src/address.rs +++ b/common/src/address.rs @@ -7,14 +7,14 @@ use anyhow::{anyhow, bail, Result}; use serde_with::{hex::Hex, serde_as}; /// a Byron-era address -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub struct ByronAddress { /// Raw payload pub payload: Vec, } /// Address network identifier -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub enum AddressNetwork { /// Mainnet Main, @@ -30,7 +30,7 @@ impl Default for AddressNetwork { } /// A Shelley-era address - payment part -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub enum ShelleyAddressPaymentPart { /// Payment to a key PaymentKeyHash(KeyHash), @@ -59,7 +59,7 @@ pub struct ShelleyAddressPointer { } /// A Shelley-era address - delegation part -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub enum ShelleyAddressDelegationPart { /// No delegation (enterprise addresses) None, @@ -81,7 +81,7 @@ impl Default for ShelleyAddressDelegationPart { } /// A Shelley-era address -#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub struct ShelleyAddress { /// Network id pub network: AddressNetwork, @@ -174,7 +174,7 @@ impl ShelleyAddress { /// Payload of a stake address #[serde_as] -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub enum StakeAddressPayload { /// Stake key StakeKeyHash(#[serde_as(as = "Hex")] Vec), @@ -196,7 +196,7 @@ impl StakeAddressPayload { } /// A stake address -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub struct StakeAddress { /// Network id pub network: AddressNetwork, @@ -274,7 +274,7 @@ impl StakeAddress { } /// A Cardano address -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub enum Address { None, Byron(ByronAddress), diff --git a/common/src/queries/addresses.rs b/common/src/queries/addresses.rs index 86a3e550..24dd945b 100644 --- a/common/src/queries/addresses.rs +++ b/common/src/queries/addresses.rs @@ -1,39 +1,22 @@ +use crate::{Address, AddressTotalsEntry, TxHash, UTxOIdentifier}; + +pub const DEFAULT_ADDRESS_QUERY_TOPIC: (&str, &str) = + ("address-state-query-topic", "cardano.query.address"); + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub enum AddressStateQuery { - GetAddressInfo { address_key: Vec }, - GetAddressInfoExtended { address_key: Vec }, - GetAddressAssetTotals { address_key: Vec }, - GetAddressUTxOs { address_key: Vec }, - GetAddressAssetUTxOs { address_key: Vec }, - GetAddressTransactions { address_key: Vec }, + GetAddressTotals { address_key: Address }, + GetAddressUTxOs { address_key: Address }, + GetAddressAssetUTxOs { address_key: Address, asset_id: u64 }, + GetAddressTransactions { address_key: Address }, } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub enum AddressStateQueryResponse { - AddressInfo(AddressInfo), - AddressInfoExtended(AddressInfoExtended), - AddressAssetTotals(AddressAssetTotals), - AddressUTxOs(AddressUTxOs), - AddressAssetUTxOs(AddressAssetUTxOs), - AddressTransactions(AddressTransactions), + AddressTotals(AddressTotalsEntry), + AddressUTxOs(Vec), + AddressAssetUTxOs(Vec), + AddressTransactions(Vec), NotFound, Error(String), } - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct AddressInfo {} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct AddressInfoExtended {} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct AddressAssetTotals {} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct AddressUTxOs {} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct AddressAssetUTxOs {} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct AddressTransactions {} diff --git a/common/src/types.rs b/common/src/types.rs index 127773be..d92a3a1d 100644 --- a/common/src/types.rs +++ b/common/src/types.rs @@ -1642,6 +1642,19 @@ pub struct PolicyAsset { pub quantity: u64, } +#[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct AddressTotalsEntry { + pub sent: NativeAssets, + pub received: NativeAssets, + pub tx_count: u64, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct UTxOIdentifier { + pub tx_identifier: u64, + pub tx_index: u64, +} + #[cfg(test)] mod tests { use super::*; diff --git a/modules/address_state/Cargo.toml b/modules/address_state/Cargo.toml new file mode 100644 index 00000000..0b2aeb97 --- /dev/null +++ b/modules/address_state/Cargo.toml @@ -0,0 +1,23 @@ +# Acropolis reward state module + +[package] +name = "acropolis_module_address_state" +version = "0.1.0" +edition = "2021" +authors = ["William Hankins "] +description = "Address State Tracker" +license = "Apache-2.0" + +[dependencies] +caryatid_sdk = "0.12" +acropolis_common = { path = "../../common" } +config = "0.15.11" +tokio = { version = "1", features = ["full"] } +tracing = "0.1.40" +anyhow = "1.0" +imbl = { version = "5.0.0", features = ["serde"] } +hex = "0.4.3" +serde_cbor = "0.11" + +[lib] +path = "src/address_state.rs" diff --git a/modules/address_state/src/address_registry.rs b/modules/address_state/src/address_registry.rs new file mode 100644 index 00000000..6d7ca9d9 --- /dev/null +++ b/modules/address_state/src/address_registry.rs @@ -0,0 +1,120 @@ +use acropolis_common::Address; +use std::collections::HashMap; +use std::sync::Arc; // whatever your Address type is + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct AddressId(pub usize); + +impl AddressId { + pub fn new(index: usize) -> Self { + AddressId(index) + } + + pub fn index(self) -> usize { + self.0 as usize + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct AddressKey(pub Arc
); + +pub struct AddressRegistry { + key_to_id: HashMap, + id_to_key: Vec, +} + +impl AddressRegistry { + pub fn new() -> Self { + Self { + key_to_id: HashMap::new(), + id_to_key: Vec::new(), + } + } + + pub fn get_or_insert(&mut self, address: Address) -> AddressId { + let key = AddressKey(Arc::new(address)); + + if let Some(&id) = self.key_to_id.get(&key) { + id + } else { + let id = AddressId::new(self.id_to_key.len()); + self.id_to_key.push(key.clone()); + self.key_to_id.insert(key, id); + id + } + } + + pub fn lookup_id(&self, address: &Address) -> Option { + self.key_to_id.get(&AddressKey(Arc::new(address.clone()))).copied() + } + + pub fn lookup(&self, id: AddressId) -> Option<&AddressKey> { + self.id_to_key.get(id.index()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use acropolis_common::Address; + + fn dummy_address(index: u8) -> Address { + match index { + 0 => Address::from_string("addr1qx8ypzkt3m80umslkjnc8qdwcevnclgwkmd46hy5apefmp90qrvwae5c50xvdujen203xg8gmnflrlfqhr29x396ekjqx77clq").unwrap(), + 1 => Address::from_string("addr1q8spsl7vakwfnte0jsztd33rsfw89zvsnu78ejpr6dtnqm4xwfz4f7dmvxc4758jrhr3xp436kmums8py8s2djpks9ss85s7a0").unwrap(), + _ => Address::from_string("addr1q8l7hny7x96fadvq8cukyqkcfca5xmkrvfrrkt7hp76v3qvssm7fz9ajmtd58ksljgkyvqu6gl23hlcfgv7um5v0rn8qtnzlfk").unwrap(), + } + } + + #[test] + fn only_insert_once() { + let mut registry = AddressRegistry::new(); + let addr = dummy_address(1); + + let id1 = registry.get_or_insert(addr.clone()); + let id2 = registry.get_or_insert(addr.clone()); + + assert_eq!(id1, id2); + } + + #[test] + fn different_addresses_get_different_ids() { + let mut registry = AddressRegistry::new(); + let id1 = registry.get_or_insert(dummy_address(1)); + let id2 = registry.get_or_insert(dummy_address(2)); + + assert_ne!(id1, id2); + assert_eq!(id1.index(), 0); + assert_eq!(id2.index(), 1); + } + + #[test] + fn lookup_id_returns_correct_id() { + let mut registry = AddressRegistry::new(); + let addr = dummy_address(1); + + let id1 = registry.get_or_insert(addr.clone()); + let id2 = registry.lookup_id(&addr).unwrap(); + + assert_eq!(id1, id2); + } + + #[test] + fn lookup_id_returns_none_for_missing_key() { + let registry = AddressRegistry::new(); + let addr = dummy_address(1); + + assert!(registry.lookup_id(&addr).is_none()); + } + + #[test] + fn lookup_returns_correct_address() { + let mut registry = AddressRegistry::new(); + let addr = dummy_address(1); + + let id = registry.get_or_insert(addr.clone()); + let key = registry.lookup(id).unwrap(); + + assert_eq!(addr, *key.0); + } +} diff --git a/modules/address_state/src/address_state.rs b/modules/address_state/src/address_state.rs new file mode 100644 index 00000000..655a42e9 --- /dev/null +++ b/modules/address_state/src/address_state.rs @@ -0,0 +1,300 @@ +//! Acropolis Address State module for Caryatid. +//! Consumes UTxO delta messages and indexes per-address +//! balances, transactions, and total sent/received amounts. + +use std::sync::Arc; + +use acropolis_common::{ + messages::{CardanoMessage, Message, StateQuery, StateQueryResponse}, + queries::addresses::{ + AddressStateQuery, AddressStateQueryResponse, DEFAULT_ADDRESS_QUERY_TOPIC, + }, + state_history::{StateHistory, StateHistoryStore}, + BlockStatus, +}; +use anyhow::Result; +use caryatid_sdk::{module, Context, Module, Subscription}; +use config::Config; +use tokio::sync::Mutex; +use tracing::{error, info, info_span, Instrument}; + +use crate::{ + address_registry::AddressRegistry, + state::{AddressStorageConfig, State}, +}; +mod address_registry; +mod state; + +// Subscription topics +const DEFAULT_UTXO_DELTAS_SUBSCRIBE_TOPIC: (&str, &str) = + ("utxo-deltas-subscribe-topic", "cardano.utxo.deltas"); +const DEFAULT_ADDRESS_DELTAS_SUBSCRIBE_TOPIC: (&str, &str) = + ("address-deltas-subscribe-topic", "cardano.address.delta"); + +// Configuration defaults +const DEFAULT_ENABLE_REGISTRY: (&str, bool) = ("enable-registry", false); +const DEFAULT_STORE_INFO: (&str, bool) = ("store-info", false); +const DEFAULT_STORE_TOTALS: (&str, bool) = ("store-totals", false); +const DEFAULT_STORE_TRANSACTIONS: (&str, bool) = ("store-transactions", false); +const DEFAULT_INDEX_UTXOS_BY_ASSET: (&str, bool) = ("index-utxos-by-asset", false); + +/// Address State module +#[module( + message_type(Message), + name = "address-state", + description = "In-memory Address State from utxo delta events" +)] +pub struct AddressState; + +impl AddressState { + async fn run( + history: Arc>>, + mut utxo_deltas_subscription: Option>>, + storage_config: AddressStorageConfig, + registry: Arc>, + ) -> Result<()> { + if let Some(sub) = utxo_deltas_subscription.as_mut() { + let _ = sub.read().await?; + info!("Consumed initial message from utxo_deltas_subscription"); + } + // Main loop of synchronised messages + loop { + // Get current state snapshot + let mut state = { + let mut h = history.lock().await; + h.get_or_init_with(|| State::new(storage_config)) + }; + + // Handle UTxO deltas if subscription is registered (store-info or store-transactions enabled) + if let Some(sub) = utxo_deltas_subscription.as_mut() { + let (_, utxo_msg) = sub.read().await?; + match utxo_msg.as_ref() { + Message::Cardano(( + ref block_info, + CardanoMessage::AddressDeltas(address_deltas_msg), + )) => { + if block_info.status == BlockStatus::RolledBack { + state = history.lock().await.get_rolled_back_state(block_info.number); + } + + let mut reg = registry.lock().await; + state = match state + .handle_address_deltas(&address_deltas_msg.deltas, &mut *reg) + { + Ok(new_state) => new_state, + Err(e) => { + error!("CIP-68 metadata handling error: {e:#}"); + state + } + }; + + // Commit state + { + let mut h = history.lock().await; + h.commit(block_info.number, state); + } + } + other => error!("Unexpected message on utxo-deltas subscription: {other:?}"), + } + } + } + } + + pub async fn init(&self, context: Arc>, config: Arc) -> Result<()> { + fn get_bool_flag(config: &Config, key: (&str, bool)) -> bool { + config.get_bool(key.0).unwrap_or(key.1) + } + + fn get_string_flag(config: &Config, key: (&str, &str)) -> String { + config.get_string(key.0).unwrap_or_else(|_| key.1.to_string()) + } + + // Get configuration flags and topis + let storage_config = AddressStorageConfig { + enable_registry: get_bool_flag(&config, DEFAULT_ENABLE_REGISTRY), + store_info: get_bool_flag(&config, DEFAULT_STORE_INFO), + store_totals: get_bool_flag(&config, DEFAULT_STORE_TOTALS), + store_transactions: get_bool_flag(&config, DEFAULT_STORE_TRANSACTIONS), + index_utxos_by_asset: get_bool_flag(&config, DEFAULT_INDEX_UTXOS_BY_ASSET), + }; + + let address_deltas_subscribe_topic: Option = if storage_config.any_enabled() { + let topic = get_string_flag(&config, DEFAULT_ADDRESS_DELTAS_SUBSCRIBE_TOPIC); + info!("Creating subscriber on '{topic}'"); + Some(topic) + } else { + None + }; + + let address_query_topic = get_string_flag(&config, DEFAULT_ADDRESS_QUERY_TOPIC); + info!("Creating asset query handler on '{address_query_topic}'"); + + // Initalize state history + let history = Arc::new(Mutex::new(StateHistory::::new( + "AddressState", + StateHistoryStore::default_block_store(), + ))); + let history_run = history.clone(); + let query_history = history.clone(); + let tick_history = history.clone(); + + // Initialize asset registry + let registry = Arc::new(Mutex::new(address_registry::AddressRegistry::new())); + let registry_run = registry.clone(); + let query_registry = registry.clone(); + + // Query handler + context.handle(&address_query_topic, move |message| { + let history = query_history.clone(); + let registry = query_registry.clone(); + async move { + let Message::StateQuery(StateQuery::Addresses(query)) = message.as_ref() else { + return Arc::new(Message::StateQueryResponse(StateQueryResponse::Addresses( + AddressStateQueryResponse::Error("Invalid message for assets-state".into()), + ))); + }; + + let state = history.lock().await.get_current_state(); + + let response = match query { + AddressStateQuery::GetAddressUTxOs { address_key } => { + let reg = registry.lock().await; + match reg.lookup_id(&address_key) { + Some(address_id) => match state.get_address_utxos(&address_id) { + Ok(Some(utxos)) => AddressStateQueryResponse::AddressUTxOs(utxos), + Ok(None) => AddressStateQueryResponse::NotFound, + Err(e) => AddressStateQueryResponse::Error(e.to_string()), + }, + None => { + if state.config.store_info { + AddressStateQueryResponse::NotFound + } else { + AddressStateQueryResponse::Error( + "address info storage disabled in config".to_string(), + ) + } + } + } + } + AddressStateQuery::GetAddressTotals { address_key } => { + let reg = registry.lock().await; + match reg.lookup_id(&address_key) { + Some(address_id) => match state.get_address_totals(&address_id) { + Ok(totals) => AddressStateQueryResponse::AddressTotals(totals), + Err(e) => AddressStateQueryResponse::Error(e.to_string()), + }, + None => { + if state.config.store_totals { + AddressStateQueryResponse::NotFound + } else { + AddressStateQueryResponse::Error( + "address totals storage disabled in config".to_string(), + ) + } + } + } + } + AddressStateQuery::GetAddressAssetUTxOs { + address_key, + asset_id, + } => { + let reg = registry.lock().await; + match reg.lookup_id(&address_key) { + Some(address_id) => { + match state.get_address_asset_utxos(&address_id, *asset_id) { + Ok(Some(utxos)) => { + AddressStateQueryResponse::AddressAssetUTxOs(utxos) + } + Ok(None) => AddressStateQueryResponse::NotFound, + Err(e) => AddressStateQueryResponse::Error(e.to_string()), + } + } + None => { + if state.config.index_utxos_by_asset { + AddressStateQueryResponse::NotFound + } else { + AddressStateQueryResponse::Error( + "indexing utxos by asset disabled in config".to_string(), + ) + } + } + } + } + AddressStateQuery::GetAddressTransactions { address_key } => { + let reg = registry.lock().await; + match reg.lookup_id(&address_key) { + Some(address_id) => match state.get_address_transactions(&address_id) { + Ok(Some(txs)) => { + AddressStateQueryResponse::AddressTransactions(txs) + } + Ok(None) => AddressStateQueryResponse::NotFound, + Err(e) => AddressStateQueryResponse::Error(e.to_string()), + }, + None => { + if state.config.store_transactions { + AddressStateQueryResponse::NotFound + } else { + AddressStateQueryResponse::Error( + "address transactions storage disabled in config" + .to_string(), + ) + } + } + } + } + }; + Arc::new(Message::StateQueryResponse(StateQueryResponse::Addresses( + response, + ))) + } + }); + + // Ticker to log stats + let mut subscription = context.subscribe("clock.tick").await?; + context.run(async move { + loop { + let Ok((_, message)) = subscription.read().await else { + return; + }; + if let Message::Clock(message) = message.as_ref() { + if message.number % 60 == 0 { + let span = info_span!("address_state.tick", number = message.number); + async { + let guard = tick_history.lock().await; + if let Some(state) = guard.current() { + if let Err(e) = state.tick() { + error!("Tick error: {e}"); + } + } else { + info!("no state yet"); + } + } + .instrument(span) + .await; + } + } + } + }); + + // Subscribe to enabled topics + let address_deltas_sub = if let Some(topic) = &address_deltas_subscribe_topic { + Some(context.subscribe(topic).await?) + } else { + None + }; + + // Start run task + context.run(async move { + Self::run( + history_run, + address_deltas_sub, + storage_config, + registry_run, + ) + .await + .unwrap_or_else(|e| error!("Failed: {e}")); + }); + + Ok(()) + } +} diff --git a/modules/address_state/src/state.rs b/modules/address_state/src/state.rs new file mode 100644 index 00000000..6b9f88e0 --- /dev/null +++ b/modules/address_state/src/state.rs @@ -0,0 +1,168 @@ +use acropolis_common::{AddressDelta, AddressTotalsEntry, TxHash, UTXODelta, UTxOIdentifier}; +use anyhow::Result; +use imbl::{HashMap, Vector}; + +use crate::address_registry::{AddressId, AddressRegistry}; + +#[derive(Debug, Default, Clone, Copy)] +pub struct AddressStorageConfig { + pub enable_registry: bool, + pub store_info: bool, + pub store_totals: bool, + pub store_transactions: bool, + pub index_utxos_by_asset: bool, +} + +impl AddressStorageConfig { + pub fn any_enabled(&self) -> bool { + self.enable_registry + || self.store_info + || self.store_totals + || self.store_transactions + || self.index_utxos_by_asset + } +} + +#[derive(Debug, Default, Clone)] +pub struct State { + pub config: AddressStorageConfig, + + /// Addresses mapped to utxos + pub utxos: Option>>, + + /// Addresses mapped to sent / receieved totals + pub totals: Option>, + + /// Index of UTxOs by (address, asset) + pub asset_index: Option>>, + + /// Addresses mapped to transactions + pub transactions: Option>>, +} + +impl State { + pub fn new(config: AddressStorageConfig) -> Self { + let store_info = config.store_info; + let store_totals = config.store_totals; + let store_transactions = config.store_transactions; + let index_utxos_by_asset = config.index_utxos_by_asset; + + Self { + config, + utxos: if store_info { + Some(HashMap::new()) + } else { + None + }, + totals: if store_totals { + Some(HashMap::new()) + } else { + None + }, + asset_index: if index_utxos_by_asset { + Some(HashMap::new()) + } else { + None + }, + transactions: if store_transactions { + Some(HashMap::new()) + } else { + None + }, + } + } + + pub fn get_address_utxos(&self, address_id: &AddressId) -> Result>> { + if !self.config.store_info { + return Err(anyhow::anyhow!("address info storage disabled in config")); + } + Ok( + self.utxos + .as_ref() + .and_then(|m| m.get(address_id)) + .map(|v| v.iter().cloned().collect()), + ) + } + + pub fn get_address_totals(&self, id: &AddressId) -> Result { + if !self.config.store_totals { + return Err(anyhow::anyhow!("address totals storage disabled in config")); + } + + self.totals + .as_ref() + .and_then(|m| m.get(id).cloned()) + .ok_or_else(|| anyhow::anyhow!("address not initialized in totals map")) + } + + pub fn get_address_asset_utxos( + &self, + address_id: &AddressId, + asset_id: u64, + ) -> Result>> { + if !self.config.index_utxos_by_asset { + return Err(anyhow::anyhow!("asset index storage disabled in config")); + } + + Ok(self + .asset_index + .as_ref() + .and_then(|m| m.get(&(*address_id, asset_id))) + .map(|v| v.iter().cloned().collect())) + } + + pub fn get_address_transactions(&self, address_id: &AddressId) -> Result>> { + if !self.config.store_transactions { + return Err(anyhow::anyhow!( + "address transactions storage disabled in config" + )); + } + + Ok(self + .transactions + .as_ref() + .and_then(|m| m.get(address_id)) + .map(|v| v.iter().cloned().collect())) + } + + pub fn tick(&self) -> Result<()> { + let count = if let Some(m) = &self.utxos { + m.len() + } else if let Some(m) = &self.totals { + m.len() + } else if let Some(m) = &self.transactions { + m.len() + } else if let Some(m) = &self.asset_index { + let unique: std::collections::HashSet<_> = m.keys().map(|(addr, _)| *addr).collect(); + unique.len() + } else { + 0 + }; + + if count != 0 { + tracing::info!("Tracking {} addresses", count); + } else { + tracing::info!("address_state storage disabled in config"); + } + Ok(()) + } + + pub fn handle_address_deltas( + &self, + deltas: &[AddressDelta], + registry: &mut AddressRegistry, + ) -> Result { + let mut new_totals = self.totals.clone(); + for delta in deltas { + let address_id = registry.get_or_insert(delta.address); + + if let Some(ref mut totals_map) = new_totals { + totals_map + .entry(address_id) + .and_modify(|v| *v += delta.delta.clone()) + .or_insert(delta.delta.clone()); + } + } + Ok(self.clone()) + } +} diff --git a/modules/utxo_state/src/utxo_state.rs b/modules/utxo_state/src/utxo_state.rs index 2c76a7c3..193c2443 100644 --- a/modules/utxo_state/src/utxo_state.rs +++ b/modules/utxo_state/src/utxo_state.rs @@ -32,8 +32,6 @@ mod fake_immutable_utxo_store; use fake_immutable_utxo_store::FakeImmutableUTXOStore; const DEFAULT_SUBSCRIBE_TOPIC: &str = "cardano.utxo.deltas"; -const DEFAULT_SINGLE_UTXO_TOPIC: (&str, &str) = ("handle-topic-single-utxo", "rest.get.utxos.*"); - const DEFAULT_STORE: &str = "memory"; /// UTXO state module @@ -52,11 +50,6 @@ impl UTXOState { config.get_string("subscribe-topic").unwrap_or(DEFAULT_SUBSCRIBE_TOPIC.to_string()); info!("Creating subscriber on '{subscribe_topic}'"); - let single_utxo_topic = config - .get_string(DEFAULT_SINGLE_UTXO_TOPIC.0) - .unwrap_or(DEFAULT_SINGLE_UTXO_TOPIC.1.to_string()); - info!("Creating REST handler on '{single_utxo_topic}'"); - // Create store let store_type = config.get_string("store").unwrap_or(DEFAULT_STORE.to_string()); let store: Arc = match store_type.as_str() { diff --git a/processes/omnibus/Cargo.toml b/processes/omnibus/Cargo.toml index 3fb11193..492ba516 100644 --- a/processes/omnibus/Cargo.toml +++ b/processes/omnibus/Cargo.toml @@ -35,6 +35,7 @@ acropolis_module_rest_blockfrost = { path = "../../modules/rest_blockfrost" } acropolis_module_spdd_state = { path = "../../modules/spdd_state" } acropolis_module_drdd_state = { path = "../../modules/drdd_state" } acropolis_module_assets_state = { path = "../../modules/assets_state" } +acropolis_module_address_state = { path = "../../modules/address_state" } anyhow = "1.0" config = "0.15.11" diff --git a/processes/omnibus/omnibus.toml b/processes/omnibus/omnibus.toml index 67dd32d1..02716ebf 100644 --- a/processes/omnibus/omnibus.toml +++ b/processes/omnibus/omnibus.toml @@ -96,6 +96,19 @@ store-addresses = false # Enables /assets/{asset} endpoint (requires store-assets to be enabled) index-by-policy = false +[module.address-state] +# Enables address registry for query lookups +enable-registry = false +# Enables /addresses/{address}, /addresses/{address}/extended, +# and /addresses/{address}/utxos endpoints +store-info = false +# Enables /addresses/{address}/totals endpoint +store-totals = false +# Enables /addresses/{address}/transactions endpoint +store-transactions = false +# Enables /addresses/{address}/utxos/{asset} endpoint +index-utxos-by-asset = false + [module.clock] [module.rest-server] diff --git a/processes/omnibus/src/main.rs b/processes/omnibus/src/main.rs index ecbebffe..1dbda819 100644 --- a/processes/omnibus/src/main.rs +++ b/processes/omnibus/src/main.rs @@ -100,6 +100,7 @@ pub async fn main() -> Result<()> { EpochsState::register(&mut process); AccountsState::register(&mut process); AssetsState::register(&mut process); + TransactionState::register(&mut process); BlockfrostREST::register(&mut process); SPDDState::register(&mut process); DRDDState::register(&mut process); From 78748fd9954ad987cde561ca0ab7052f398b66f4 Mon Sep 17 00:00:00 2001 From: William Hankins Date: Mon, 22 Sep 2025 22:56:58 +0000 Subject: [PATCH 02/18] fix: prepare for asset deltas subscription Signed-off-by: William Hankins --- modules/address_state/Cargo.toml | 2 +- modules/address_state/src/address_registry.rs | 2 +- modules/address_state/src/address_state.rs | 23 +++++++------------ modules/address_state/src/state.rs | 16 +++---------- modules/assets_state/Cargo.toml | 2 +- 5 files changed, 14 insertions(+), 31 deletions(-) diff --git a/modules/address_state/Cargo.toml b/modules/address_state/Cargo.toml index 0b2aeb97..3b44f9dd 100644 --- a/modules/address_state/Cargo.toml +++ b/modules/address_state/Cargo.toml @@ -1,4 +1,4 @@ -# Acropolis reward state module +# Acropolis address state module [package] name = "acropolis_module_address_state" diff --git a/modules/address_state/src/address_registry.rs b/modules/address_state/src/address_registry.rs index 6d7ca9d9..2170ab14 100644 --- a/modules/address_state/src/address_registry.rs +++ b/modules/address_state/src/address_registry.rs @@ -1,6 +1,6 @@ use acropolis_common::Address; use std::collections::HashMap; -use std::sync::Arc; // whatever your Address type is +use std::sync::Arc; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct AddressId(pub usize); diff --git a/modules/address_state/src/address_state.rs b/modules/address_state/src/address_state.rs index 655a42e9..ef1a8251 100644 --- a/modules/address_state/src/address_state.rs +++ b/modules/address_state/src/address_state.rs @@ -26,10 +26,8 @@ mod address_registry; mod state; // Subscription topics -const DEFAULT_UTXO_DELTAS_SUBSCRIBE_TOPIC: (&str, &str) = - ("utxo-deltas-subscribe-topic", "cardano.utxo.deltas"); -const DEFAULT_ADDRESS_DELTAS_SUBSCRIBE_TOPIC: (&str, &str) = - ("address-deltas-subscribe-topic", "cardano.address.delta"); +const DEFAULT_ASSET_DELTAS_SUBSCRIBE_TOPIC: (&str, &str) = + ("address-deltas-subscribe-topic", "cardano.asset.deltas"); // Configuration defaults const DEFAULT_ENABLE_REGISTRY: (&str, bool) = ("enable-registry", false); @@ -118,8 +116,8 @@ impl AddressState { index_utxos_by_asset: get_bool_flag(&config, DEFAULT_INDEX_UTXOS_BY_ASSET), }; - let address_deltas_subscribe_topic: Option = if storage_config.any_enabled() { - let topic = get_string_flag(&config, DEFAULT_ADDRESS_DELTAS_SUBSCRIBE_TOPIC); + let asset_deltas_subscribe_topic: Option = if storage_config.any_enabled() { + let topic = get_string_flag(&config, DEFAULT_ASSET_DELTAS_SUBSCRIBE_TOPIC); info!("Creating subscriber on '{topic}'"); Some(topic) } else { @@ -277,7 +275,7 @@ impl AddressState { }); // Subscribe to enabled topics - let address_deltas_sub = if let Some(topic) = &address_deltas_subscribe_topic { + let asset_deltas_sub = if let Some(topic) = &asset_deltas_subscribe_topic { Some(context.subscribe(topic).await?) } else { None @@ -285,14 +283,9 @@ impl AddressState { // Start run task context.run(async move { - Self::run( - history_run, - address_deltas_sub, - storage_config, - registry_run, - ) - .await - .unwrap_or_else(|e| error!("Failed: {e}")); + Self::run(history_run, asset_deltas_sub, storage_config, registry_run) + .await + .unwrap_or_else(|e| error!("Failed: {e}")); }); Ok(()) diff --git a/modules/address_state/src/state.rs b/modules/address_state/src/state.rs index 6b9f88e0..7fbb98cb 100644 --- a/modules/address_state/src/state.rs +++ b/modules/address_state/src/state.rs @@ -1,4 +1,4 @@ -use acropolis_common::{AddressDelta, AddressTotalsEntry, TxHash, UTXODelta, UTxOIdentifier}; +use acropolis_common::{AddressDelta, AddressTotalsEntry, TxHash, UTxOIdentifier}; use anyhow::Result; use imbl::{HashMap, Vector}; @@ -150,19 +150,9 @@ impl State { pub fn handle_address_deltas( &self, deltas: &[AddressDelta], - registry: &mut AddressRegistry, + _registry: &mut AddressRegistry, ) -> Result { - let mut new_totals = self.totals.clone(); - for delta in deltas { - let address_id = registry.get_or_insert(delta.address); - - if let Some(ref mut totals_map) = new_totals { - totals_map - .entry(address_id) - .and_modify(|v| *v += delta.delta.clone()) - .or_insert(delta.delta.clone()); - } - } + for _delta in deltas {} Ok(self.clone()) } } diff --git a/modules/assets_state/Cargo.toml b/modules/assets_state/Cargo.toml index 5ea33910..f4656009 100644 --- a/modules/assets_state/Cargo.toml +++ b/modules/assets_state/Cargo.toml @@ -1,4 +1,4 @@ -# Acropolis reward state module +# Acropolis asset state module [package] name = "acropolis_module_assets_state" From e90a68cd808f609640c808b265b8d04930f85bf0 Mon Sep 17 00:00:00 2001 From: William Hankins Date: Fri, 26 Sep 2025 21:33:10 +0000 Subject: [PATCH 03/18] feat: add utxo and transaction indexing to address state Signed-off-by: William Hankins --- Cargo.lock | 3 +- common/Cargo.toml | 1 + common/src/address.rs | 60 ++++++- common/src/queries/addresses.rs | 12 +- common/src/types.rs | 5 +- modules/address_state/src/address_registry.rs | 120 ------------- modules/address_state/src/address_state.rs | 142 ++++------------ modules/address_state/src/state.rs | 157 +++++++----------- modules/assets_state/src/state.rs | 5 +- .../rest_blockfrost/src/handlers/addresses.rs | 130 +++++++++++++++ modules/rest_blockfrost/src/handlers/mod.rs | 1 + .../rest_blockfrost/src/handlers_config.rs | 7 + .../rest_blockfrost/src/rest_blockfrost.rs | 88 +++++++++- modules/stake_delta_filter/src/utils.rs | 11 +- .../utxo_state/src/address_delta_publisher.rs | 9 +- modules/utxo_state/src/state.rs | 44 +++-- processes/omnibus/omnibus.toml | 2 - processes/omnibus/src/main.rs | 3 +- 18 files changed, 419 insertions(+), 381 deletions(-) delete mode 100644 modules/address_state/src/address_registry.rs create mode 100644 modules/rest_blockfrost/src/handlers/addresses.rs diff --git a/Cargo.lock b/Cargo.lock index c4b66e5e..9bdb08e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,7 @@ dependencies = [ "caryatid_module_rest_server", "caryatid_sdk", "chrono", + "crc", "dashmap", "fraction", "futures", @@ -63,7 +64,7 @@ dependencies = [ "caryatid_sdk", "config", "hex", - "imbl 5.0.0", + "imbl", "serde_cbor", "tokio", "tracing", diff --git a/common/Cargo.toml b/common/Cargo.toml index 11227d17..a9b2e9b1 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -21,6 +21,7 @@ bitmask-enum = "2.2" blake2 = "0.10" bs58 = "0.5" chrono = { workspace = true } +crc = "3" gcd = "2.3" fraction = "0.15" hex = { workspace = true } diff --git a/common/src/address.rs b/common/src/address.rs index 6aaa1e29..9000161f 100644 --- a/common/src/address.rs +++ b/common/src/address.rs @@ -4,6 +4,8 @@ use crate::cip19::{VarIntDecoder, VarIntEncoder}; use crate::types::{KeyHash, ScriptHash}; use anyhow::{anyhow, bail, Result}; +use crc::{Crc, CRC_32_ISO_HDLC}; +use minicbor::data::IanaTag; use serde_with::{hex::Hex, serde_as}; /// a Byron-era address @@ -13,6 +15,55 @@ pub struct ByronAddress { pub payload: Vec, } +impl ByronAddress { + fn compute_crc32(&self) -> u32 { + const CRC32: Crc = Crc::::new(&CRC_32_ISO_HDLC); + CRC32.checksum(&self.payload) + } + + pub fn to_string(&self) -> Result { + let crc = self.compute_crc32(); + + let mut buf = Vec::new(); + { + let mut enc = minicbor::Encoder::new(&mut buf); + enc.array(2)?; + enc.tag(IanaTag::Cbor)?; + enc.bytes(&self.payload)?; + enc.u32(crc)?; + } + + Ok(bs58::encode(buf).into_string()) + } + + pub fn from_string(s: &str) -> Result { + let bytes = bs58::decode(s).into_vec()?; + let mut dec = minicbor::Decoder::new(&bytes); + + let len = dec.array()?.unwrap_or(0); + if len != 2 { + anyhow::bail!("Invalid Byron address CBOR array length"); + } + + let tag = dec.tag()?; + if tag != IanaTag::Cbor.into() { + anyhow::bail!("Invalid Byron address CBOR tag, expected 24"); + } + + let payload = dec.bytes()?.to_vec(); + let crc = dec.u32()?; + + let address = ByronAddress { payload }; + let computed = address.compute_crc32(); + + if crc != computed { + anyhow::bail!("Byron address CRC mismatch"); + } + + Ok(address) + } +} + /// Address network identifier #[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub enum AddressNetwork { @@ -306,10 +357,9 @@ impl Address { } else if text.starts_with("stake1") || text.starts_with("stake_test1") { Ok(Self::Stake(StakeAddress::from_string(text)?)) } else { - if let Ok(bytes) = bs58::decode(text).into_vec() { - Ok(Self::Byron(ByronAddress { payload: bytes })) - } else { - Ok(Self::None) + match ByronAddress::from_string(text) { + Ok(byron) => Ok(Self::Byron(byron)), + Err(_) => Ok(Self::None), } } } @@ -318,7 +368,7 @@ impl Address { pub fn to_string(&self) -> Result { match self { Self::None => Err(anyhow!("No address")), - Self::Byron(byron) => Ok(bs58::encode(&byron.payload).into_string()), + Self::Byron(byron) => byron.to_string(), Self::Shelley(shelley) => shelley.to_string(), Self::Stake(stake) => stake.to_string(), } diff --git a/common/src/queries/addresses.rs b/common/src/queries/addresses.rs index 24dd945b..3ad8f389 100644 --- a/common/src/queries/addresses.rs +++ b/common/src/queries/addresses.rs @@ -1,22 +1,20 @@ -use crate::{Address, AddressTotalsEntry, TxHash, UTxOIdentifier}; +use crate::{Address, AddressTotalsEntry, TxIdentifier, UTxOIdentifier}; pub const DEFAULT_ADDRESS_QUERY_TOPIC: (&str, &str) = ("address-state-query-topic", "cardano.query.address"); #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub enum AddressStateQuery { - GetAddressTotals { address_key: Address }, - GetAddressUTxOs { address_key: Address }, - GetAddressAssetUTxOs { address_key: Address, asset_id: u64 }, - GetAddressTransactions { address_key: Address }, + GetAddressTotals { address: Address }, + GetAddressUTxOs { address: Address }, + GetAddressTransactions { address: Address }, } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub enum AddressStateQueryResponse { AddressTotals(AddressTotalsEntry), AddressUTxOs(Vec), - AddressAssetUTxOs(Vec), - AddressTransactions(Vec), + AddressTransactions(Vec), NotFound, Error(String), } diff --git a/common/src/types.rs b/common/src/types.rs index d7772c4e..3b9f27d8 100644 --- a/common/src/types.rs +++ b/common/src/types.rs @@ -141,8 +141,11 @@ pub struct AddressDelta { /// Address pub address: Address, + /// UTxO causing address delta + pub utxo: UTxOIdentifier, + /// Balance change - pub delta: ValueDelta, + pub value: ValueDelta, } /// Stake balance change diff --git a/modules/address_state/src/address_registry.rs b/modules/address_state/src/address_registry.rs deleted file mode 100644 index 2170ab14..00000000 --- a/modules/address_state/src/address_registry.rs +++ /dev/null @@ -1,120 +0,0 @@ -use acropolis_common::Address; -use std::collections::HashMap; -use std::sync::Arc; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub struct AddressId(pub usize); - -impl AddressId { - pub fn new(index: usize) -> Self { - AddressId(index) - } - - pub fn index(self) -> usize { - self.0 as usize - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct AddressKey(pub Arc
); - -pub struct AddressRegistry { - key_to_id: HashMap, - id_to_key: Vec, -} - -impl AddressRegistry { - pub fn new() -> Self { - Self { - key_to_id: HashMap::new(), - id_to_key: Vec::new(), - } - } - - pub fn get_or_insert(&mut self, address: Address) -> AddressId { - let key = AddressKey(Arc::new(address)); - - if let Some(&id) = self.key_to_id.get(&key) { - id - } else { - let id = AddressId::new(self.id_to_key.len()); - self.id_to_key.push(key.clone()); - self.key_to_id.insert(key, id); - id - } - } - - pub fn lookup_id(&self, address: &Address) -> Option { - self.key_to_id.get(&AddressKey(Arc::new(address.clone()))).copied() - } - - pub fn lookup(&self, id: AddressId) -> Option<&AddressKey> { - self.id_to_key.get(id.index()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use acropolis_common::Address; - - fn dummy_address(index: u8) -> Address { - match index { - 0 => Address::from_string("addr1qx8ypzkt3m80umslkjnc8qdwcevnclgwkmd46hy5apefmp90qrvwae5c50xvdujen203xg8gmnflrlfqhr29x396ekjqx77clq").unwrap(), - 1 => Address::from_string("addr1q8spsl7vakwfnte0jsztd33rsfw89zvsnu78ejpr6dtnqm4xwfz4f7dmvxc4758jrhr3xp436kmums8py8s2djpks9ss85s7a0").unwrap(), - _ => Address::from_string("addr1q8l7hny7x96fadvq8cukyqkcfca5xmkrvfrrkt7hp76v3qvssm7fz9ajmtd58ksljgkyvqu6gl23hlcfgv7um5v0rn8qtnzlfk").unwrap(), - } - } - - #[test] - fn only_insert_once() { - let mut registry = AddressRegistry::new(); - let addr = dummy_address(1); - - let id1 = registry.get_or_insert(addr.clone()); - let id2 = registry.get_or_insert(addr.clone()); - - assert_eq!(id1, id2); - } - - #[test] - fn different_addresses_get_different_ids() { - let mut registry = AddressRegistry::new(); - let id1 = registry.get_or_insert(dummy_address(1)); - let id2 = registry.get_or_insert(dummy_address(2)); - - assert_ne!(id1, id2); - assert_eq!(id1.index(), 0); - assert_eq!(id2.index(), 1); - } - - #[test] - fn lookup_id_returns_correct_id() { - let mut registry = AddressRegistry::new(); - let addr = dummy_address(1); - - let id1 = registry.get_or_insert(addr.clone()); - let id2 = registry.lookup_id(&addr).unwrap(); - - assert_eq!(id1, id2); - } - - #[test] - fn lookup_id_returns_none_for_missing_key() { - let registry = AddressRegistry::new(); - let addr = dummy_address(1); - - assert!(registry.lookup_id(&addr).is_none()); - } - - #[test] - fn lookup_returns_correct_address() { - let mut registry = AddressRegistry::new(); - let addr = dummy_address(1); - - let id = registry.get_or_insert(addr.clone()); - let key = registry.lookup(id).unwrap(); - - assert_eq!(addr, *key.0); - } -} diff --git a/modules/address_state/src/address_state.rs b/modules/address_state/src/address_state.rs index ef1a8251..1ec2d3be 100644 --- a/modules/address_state/src/address_state.rs +++ b/modules/address_state/src/address_state.rs @@ -18,23 +18,17 @@ use config::Config; use tokio::sync::Mutex; use tracing::{error, info, info_span, Instrument}; -use crate::{ - address_registry::AddressRegistry, - state::{AddressStorageConfig, State}, -}; -mod address_registry; +use crate::state::{AddressStorageConfig, State}; mod state; // Subscription topics -const DEFAULT_ASSET_DELTAS_SUBSCRIBE_TOPIC: (&str, &str) = - ("address-deltas-subscribe-topic", "cardano.asset.deltas"); +const DEFAULT_ADDRESS_DELTAS_SUBSCRIBE_TOPIC: (&str, &str) = + ("address-deltas-subscribe-topic", "cardano.address.delta"); // Configuration defaults -const DEFAULT_ENABLE_REGISTRY: (&str, bool) = ("enable-registry", false); const DEFAULT_STORE_INFO: (&str, bool) = ("store-info", false); const DEFAULT_STORE_TOTALS: (&str, bool) = ("store-totals", false); const DEFAULT_STORE_TRANSACTIONS: (&str, bool) = ("store-transactions", false); -const DEFAULT_INDEX_UTXOS_BY_ASSET: (&str, bool) = ("index-utxos-by-asset", false); /// Address State module #[module( @@ -47,14 +41,9 @@ pub struct AddressState; impl AddressState { async fn run( history: Arc>>, - mut utxo_deltas_subscription: Option>>, + mut address_deltas_subscription: Option>>, storage_config: AddressStorageConfig, - registry: Arc>, ) -> Result<()> { - if let Some(sub) = utxo_deltas_subscription.as_mut() { - let _ = sub.read().await?; - info!("Consumed initial message from utxo_deltas_subscription"); - } // Main loop of synchronised messages loop { // Get current state snapshot @@ -64,9 +53,9 @@ impl AddressState { }; // Handle UTxO deltas if subscription is registered (store-info or store-transactions enabled) - if let Some(sub) = utxo_deltas_subscription.as_mut() { - let (_, utxo_msg) = sub.read().await?; - match utxo_msg.as_ref() { + if let Some(sub) = address_deltas_subscription.as_mut() { + let (_, deltas_msg) = sub.read().await?; + match deltas_msg.as_ref() { Message::Cardano(( ref block_info, CardanoMessage::AddressDeltas(address_deltas_msg), @@ -75,13 +64,10 @@ impl AddressState { state = history.lock().await.get_rolled_back_state(block_info.number); } - let mut reg = registry.lock().await; - state = match state - .handle_address_deltas(&address_deltas_msg.deltas, &mut *reg) - { + state = match state.handle_address_deltas(&address_deltas_msg.deltas) { Ok(new_state) => new_state, Err(e) => { - error!("CIP-68 metadata handling error: {e:#}"); + error!("address deltas handling error: {e:#}"); state } }; @@ -109,15 +95,13 @@ impl AddressState { // Get configuration flags and topis let storage_config = AddressStorageConfig { - enable_registry: get_bool_flag(&config, DEFAULT_ENABLE_REGISTRY), store_info: get_bool_flag(&config, DEFAULT_STORE_INFO), store_totals: get_bool_flag(&config, DEFAULT_STORE_TOTALS), store_transactions: get_bool_flag(&config, DEFAULT_STORE_TRANSACTIONS), - index_utxos_by_asset: get_bool_flag(&config, DEFAULT_INDEX_UTXOS_BY_ASSET), }; - let asset_deltas_subscribe_topic: Option = if storage_config.any_enabled() { - let topic = get_string_flag(&config, DEFAULT_ASSET_DELTAS_SUBSCRIBE_TOPIC); + let address_deltas_subscribe_topic: Option = if storage_config.any_enabled() { + let topic = get_string_flag(&config, DEFAULT_ADDRESS_DELTAS_SUBSCRIBE_TOPIC); info!("Creating subscriber on '{topic}'"); Some(topic) } else { @@ -136,15 +120,9 @@ impl AddressState { let query_history = history.clone(); let tick_history = history.clone(); - // Initialize asset registry - let registry = Arc::new(Mutex::new(address_registry::AddressRegistry::new())); - let registry_run = registry.clone(); - let query_registry = registry.clone(); - // Query handler context.handle(&address_query_topic, move |message| { let history = query_history.clone(); - let registry = query_registry.clone(); async move { let Message::StateQuery(StateQuery::Addresses(query)) = message.as_ref() else { return Arc::new(Message::StateQueryResponse(StateQueryResponse::Addresses( @@ -155,89 +133,25 @@ impl AddressState { let state = history.lock().await.get_current_state(); let response = match query { - AddressStateQuery::GetAddressUTxOs { address_key } => { - let reg = registry.lock().await; - match reg.lookup_id(&address_key) { - Some(address_id) => match state.get_address_utxos(&address_id) { - Ok(Some(utxos)) => AddressStateQueryResponse::AddressUTxOs(utxos), - Ok(None) => AddressStateQueryResponse::NotFound, - Err(e) => AddressStateQueryResponse::Error(e.to_string()), - }, - None => { - if state.config.store_info { - AddressStateQueryResponse::NotFound - } else { - AddressStateQueryResponse::Error( - "address info storage disabled in config".to_string(), - ) - } - } - } - } - AddressStateQuery::GetAddressTotals { address_key } => { - let reg = registry.lock().await; - match reg.lookup_id(&address_key) { - Some(address_id) => match state.get_address_totals(&address_id) { - Ok(totals) => AddressStateQueryResponse::AddressTotals(totals), - Err(e) => AddressStateQueryResponse::Error(e.to_string()), - }, - None => { - if state.config.store_totals { - AddressStateQueryResponse::NotFound - } else { - AddressStateQueryResponse::Error( - "address totals storage disabled in config".to_string(), - ) - } - } + AddressStateQuery::GetAddressUTxOs { address } => { + match state.get_address_utxos(&address) { + Ok(Some(utxos)) => AddressStateQueryResponse::AddressUTxOs(utxos), + Ok(None) => AddressStateQueryResponse::NotFound, + Err(e) => AddressStateQueryResponse::Error(e.to_string()), } } - AddressStateQuery::GetAddressAssetUTxOs { - address_key, - asset_id, - } => { - let reg = registry.lock().await; - match reg.lookup_id(&address_key) { - Some(address_id) => { - match state.get_address_asset_utxos(&address_id, *asset_id) { - Ok(Some(utxos)) => { - AddressStateQueryResponse::AddressAssetUTxOs(utxos) - } - Ok(None) => AddressStateQueryResponse::NotFound, - Err(e) => AddressStateQueryResponse::Error(e.to_string()), - } - } - None => { - if state.config.index_utxos_by_asset { - AddressStateQueryResponse::NotFound - } else { - AddressStateQueryResponse::Error( - "indexing utxos by asset disabled in config".to_string(), - ) - } - } + + AddressStateQuery::GetAddressTotals { address } => { + match state.get_address_totals(&address) { + Ok(totals) => AddressStateQueryResponse::AddressTotals(totals), + Err(e) => AddressStateQueryResponse::Error(e.to_string()), } } - AddressStateQuery::GetAddressTransactions { address_key } => { - let reg = registry.lock().await; - match reg.lookup_id(&address_key) { - Some(address_id) => match state.get_address_transactions(&address_id) { - Ok(Some(txs)) => { - AddressStateQueryResponse::AddressTransactions(txs) - } - Ok(None) => AddressStateQueryResponse::NotFound, - Err(e) => AddressStateQueryResponse::Error(e.to_string()), - }, - None => { - if state.config.store_transactions { - AddressStateQueryResponse::NotFound - } else { - AddressStateQueryResponse::Error( - "address transactions storage disabled in config" - .to_string(), - ) - } - } + AddressStateQuery::GetAddressTransactions { address } => { + match state.get_address_transactions(&address) { + Ok(Some(txs)) => AddressStateQueryResponse::AddressTransactions(txs), + Ok(None) => AddressStateQueryResponse::NotFound, + Err(e) => AddressStateQueryResponse::Error(e.to_string()), } } }; @@ -275,7 +189,7 @@ impl AddressState { }); // Subscribe to enabled topics - let asset_deltas_sub = if let Some(topic) = &asset_deltas_subscribe_topic { + let address_deltas_sub = if let Some(topic) = &address_deltas_subscribe_topic { Some(context.subscribe(topic).await?) } else { None @@ -283,7 +197,7 @@ impl AddressState { // Start run task context.run(async move { - Self::run(history_run, asset_deltas_sub, storage_config, registry_run) + Self::run(history_run, address_deltas_sub, storage_config) .await .unwrap_or_else(|e| error!("Failed: {e}")); }); diff --git a/modules/address_state/src/state.rs b/modules/address_state/src/state.rs index 7fbb98cb..74c498f4 100644 --- a/modules/address_state/src/state.rs +++ b/modules/address_state/src/state.rs @@ -1,70 +1,39 @@ -use acropolis_common::{AddressDelta, AddressTotalsEntry, TxHash, UTxOIdentifier}; +use acropolis_common::{Address, AddressDelta, AddressTotalsEntry, TxIdentifier, UTxOIdentifier}; use anyhow::Result; use imbl::{HashMap, Vector}; -use crate::address_registry::{AddressId, AddressRegistry}; - #[derive(Debug, Default, Clone, Copy)] pub struct AddressStorageConfig { - pub enable_registry: bool, pub store_info: bool, pub store_totals: bool, pub store_transactions: bool, - pub index_utxos_by_asset: bool, } impl AddressStorageConfig { pub fn any_enabled(&self) -> bool { - self.enable_registry - || self.store_info - || self.store_totals - || self.store_transactions - || self.index_utxos_by_asset + self.store_info || self.store_totals || self.store_transactions } } +#[derive(Debug, Default, Clone)] +pub struct AddressEntry { + pub utxos: Option>, + pub transactions: Option>, + pub totals: Option, +} + #[derive(Debug, Default, Clone)] pub struct State { pub config: AddressStorageConfig, - /// Addresses mapped to utxos - pub utxos: Option>>, - - /// Addresses mapped to sent / receieved totals - pub totals: Option>, - - /// Index of UTxOs by (address, asset) - pub asset_index: Option>>, - - /// Addresses mapped to transactions - pub transactions: Option>>, + pub addresses: Option>, } impl State { pub fn new(config: AddressStorageConfig) -> Self { - let store_info = config.store_info; - let store_totals = config.store_totals; - let store_transactions = config.store_transactions; - let index_utxos_by_asset = config.index_utxos_by_asset; - Self { config, - utxos: if store_info { - Some(HashMap::new()) - } else { - None - }, - totals: if store_totals { - Some(HashMap::new()) - } else { - None - }, - asset_index: if index_utxos_by_asset { - Some(HashMap::new()) - } else { - None - }, - transactions: if store_transactions { + addresses: if config.any_enabled() { Some(HashMap::new()) } else { None @@ -72,87 +41,85 @@ impl State { } } - pub fn get_address_utxos(&self, address_id: &AddressId) -> Result>> { + pub fn get_address_utxos(&self, address: &Address) -> Result>> { if !self.config.store_info { - return Err(anyhow::anyhow!("address info storage disabled in config")); + anyhow::bail!("address info storage disabled in config"); } - Ok( - self.utxos - .as_ref() - .and_then(|m| m.get(address_id)) - .map(|v| v.iter().cloned().collect()), - ) - } - pub fn get_address_totals(&self, id: &AddressId) -> Result { - if !self.config.store_totals { - return Err(anyhow::anyhow!("address totals storage disabled in config")); - } - - self.totals + Ok(self + .addresses .as_ref() - .and_then(|m| m.get(id).cloned()) - .ok_or_else(|| anyhow::anyhow!("address not initialized in totals map")) + .and_then(|map| map.get(address)) + .and_then(|entry| entry.utxos.as_ref()) + .map(|m| m.keys().cloned().collect())) } - pub fn get_address_asset_utxos( - &self, - address_id: &AddressId, - asset_id: u64, - ) -> Result>> { - if !self.config.index_utxos_by_asset { - return Err(anyhow::anyhow!("asset index storage disabled in config")); + pub fn get_address_totals(&self, address: &Address) -> Result { + if !self.config.store_totals { + anyhow::bail!("address totals storage disabled in config"); } - Ok(self - .asset_index + self.addresses .as_ref() - .and_then(|m| m.get(&(*address_id, asset_id))) - .map(|v| v.iter().cloned().collect())) + .and_then(|map| map.get(address)) + .and_then(|entry| entry.totals.clone()) + .ok_or_else(|| anyhow::anyhow!("address not initialized in totals map")) } - pub fn get_address_transactions(&self, address_id: &AddressId) -> Result>> { + pub fn get_address_transactions(&self, address: &Address) -> Result>> { if !self.config.store_transactions { - return Err(anyhow::anyhow!( - "address transactions storage disabled in config" - )); + anyhow::bail!("address transactions storage disabled in config"); } Ok(self - .transactions + .addresses .as_ref() - .and_then(|m| m.get(address_id)) + .and_then(|map| map.get(address)) + .and_then(|entry| entry.transactions.as_ref()) .map(|v| v.iter().cloned().collect())) } pub fn tick(&self) -> Result<()> { - let count = if let Some(m) = &self.utxos { - m.len() - } else if let Some(m) = &self.totals { - m.len() - } else if let Some(m) = &self.transactions { - m.len() - } else if let Some(m) = &self.asset_index { - let unique: std::collections::HashSet<_> = m.keys().map(|(addr, _)| *addr).collect(); - unique.len() - } else { - 0 - }; + let count = self.addresses.as_ref().map(|m| m.len()).unwrap_or(0); if count != 0 { tracing::info!("Tracking {} addresses", count); } else { tracing::info!("address_state storage disabled in config"); } + Ok(()) } - pub fn handle_address_deltas( - &self, - deltas: &[AddressDelta], - _registry: &mut AddressRegistry, - ) -> Result { - for _delta in deltas {} - Ok(self.clone()) + pub fn handle_address_deltas(&self, deltas: &[AddressDelta]) -> Result { + let mut new_state = self.clone(); + + let Some(addresses) = new_state.addresses.as_mut() else { + return Ok(new_state); + }; + + for delta in deltas { + let entry = addresses.entry(delta.address.clone()).or_default(); + + if self.config.store_info { + let utxos = entry.utxos.get_or_insert_with(HashMap::new); + if delta.value.lovelace > 0 { + utxos.insert(delta.utxo, ()); + } else { + utxos.remove(&delta.utxo); + } + } + + if self.config.store_transactions { + let transactions = entry.transactions.get_or_insert_with(Vector::new); + + let tx_id = delta.utxo.to_tx_identifier(); + + if transactions.last() != Some(&tx_id) { + transactions.push_back(tx_id); + } + } + } + Ok(new_state) } } diff --git a/modules/assets_state/src/state.rs b/modules/assets_state/src/state.rs index 66ddf8eb..d8c2a866 100644 --- a/modules/assets_state/src/state.rs +++ b/modules/assets_state/src/state.rs @@ -423,7 +423,7 @@ impl State { for address_delta in deltas { if let Address::Shelley(shelley_addr) = &address_delta.address { - for (policy_id, asset_deltas) in &address_delta.delta.assets { + for (policy_id, asset_deltas) in &address_delta.value.assets { for asset_delta in asset_deltas { if let Some(asset_id) = registry.lookup_id(policy_id, &asset_delta.name) { if let Some(holders) = addr_map.get_mut(&asset_id) { @@ -740,7 +740,8 @@ mod tests { fn make_address_delta(policy_id: PolicyId, name: AssetName, amount: i64) -> AddressDelta { AddressDelta { address: dummy_address(), - delta: ValueDelta { + utxo: UTxOIdentifier::new(0, 0, 0), + value: ValueDelta { lovelace: 0, assets: vec![(policy_id, vec![NativeAssetDelta { name, amount }])] .into_iter() diff --git a/modules/rest_blockfrost/src/handlers/addresses.rs b/modules/rest_blockfrost/src/handlers/addresses.rs new file mode 100644 index 00000000..429c3778 --- /dev/null +++ b/modules/rest_blockfrost/src/handlers/addresses.rs @@ -0,0 +1,130 @@ +use anyhow::Result; +use std::sync::Arc; + +use acropolis_common::{ + messages::{Message, RESTResponse, StateQuery, StateQueryResponse}, + queries::{ + addresses::{AddressStateQuery, AddressStateQueryResponse}, + utils::query_state, + }, + Address, +}; +use caryatid_sdk::Context; + +use crate::handlers_config::HandlersConfig; + +pub async fn handle_address_single_blockfrost( + context: Arc>, + params: Vec, + handlers_config: Arc, +) -> Result { + let address = match Address::from_string(¶ms[0]) { + Ok(Address::None) => { + return Ok(RESTResponse::with_text( + 400, + &format!("Invalid address '{}'", params[0]), + )); + } + Ok(address) => address, + Err(e) => { + return Ok(RESTResponse::with_text( + 400, + &format!("Invalid address '{}': {e}", params[0]), + )); + } + }; + + let address_query_msg = Arc::new(Message::StateQuery(StateQuery::Addresses( + AddressStateQuery::GetAddressUTxOs { address }, + ))); + + let response = query_state( + &context, + &handlers_config.addresses_query_topic, + address_query_msg, + |message| match message { + Message::StateQueryResponse(StateQueryResponse::Addresses( + AddressStateQueryResponse::AddressUTxOs(utxos), + )) => { + let rest_utxos: Vec = utxos + .iter() + .map(|entry| { + format!( + "{}:{}:{}", + entry.block_number(), + entry.tx_index(), + entry.output_index() + ) + }) + .collect(); + + match serde_json::to_string_pretty(&rest_utxos) { + Ok(json) => Ok(RESTResponse::with_json(200, &json)), + Err(e) => Ok(RESTResponse::with_text( + 500, + &format!("Failed to serialize UTxOs: {e}"), + )), + } + } + Message::StateQueryResponse(StateQueryResponse::Addresses( + AddressStateQueryResponse::NotFound, + )) => Ok(RESTResponse::with_text(404, "Address not found")), + Message::StateQueryResponse(StateQueryResponse::Addresses( + AddressStateQueryResponse::Error(_), + )) => Ok(RESTResponse::with_text( + 501, + "Addresses info storage is disabled in config", + )), + _ => Ok(RESTResponse::with_text( + 500, + "Unexpected response while retrieving address info", + )), + }, + ) + .await; + + match response { + Ok(rest) => Ok(rest), + Err(e) => Ok(RESTResponse::with_text(500, &format!("Query failed: {e}"))), + } +} + +pub async fn handle_address_extended_blockfrost( + _context: Arc>, + _params: Vec, + _handlers_config: Arc, +) -> Result { + Ok(RESTResponse::with_text(501, "Not implemented")) +} + +pub async fn handle_address_totals_blockfrost( + _context: Arc>, + _params: Vec, + _handlers_config: Arc, +) -> Result { + Ok(RESTResponse::with_text(501, "Not implemented")) +} + +pub async fn handle_address_utxos_blockfrost( + _context: Arc>, + _params: Vec, + _handlers_config: Arc, +) -> Result { + Ok(RESTResponse::with_text(501, "Not implemented")) +} + +pub async fn handle_address_asset_utxos_blockfrost( + _context: Arc>, + _params: Vec, + _handlers_config: Arc, +) -> Result { + Ok(RESTResponse::with_text(501, "Not implemented")) +} + +pub async fn handle_address_transactions_blockfrost( + _context: Arc>, + _params: Vec, + _handlers_config: Arc, +) -> Result { + Ok(RESTResponse::with_text(501, "Not implemented")) +} diff --git a/modules/rest_blockfrost/src/handlers/mod.rs b/modules/rest_blockfrost/src/handlers/mod.rs index 008683c4..aca50eb5 100644 --- a/modules/rest_blockfrost/src/handlers/mod.rs +++ b/modules/rest_blockfrost/src/handlers/mod.rs @@ -1,4 +1,5 @@ pub mod accounts; +pub mod addresses; pub mod assets; pub mod epochs; pub mod governance; diff --git a/modules/rest_blockfrost/src/handlers_config.rs b/modules/rest_blockfrost/src/handlers_config.rs index d2a03c4e..c038a725 100644 --- a/modules/rest_blockfrost/src/handlers_config.rs +++ b/modules/rest_blockfrost/src/handlers_config.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use acropolis_common::queries::{ accounts::DEFAULT_ACCOUNTS_QUERY_TOPIC, + addresses::DEFAULT_ADDRESS_QUERY_TOPIC, assets::{DEFAULT_ASSETS_QUERY_TOPIC, DEFAULT_OFFCHAIN_TOKEN_REGISTRY_URL}, epochs::DEFAULT_EPOCHS_QUERY_TOPIC, governance::{DEFAULT_DREPS_QUERY_TOPIC, DEFAULT_GOVERNANCE_QUERY_TOPIC}, @@ -15,6 +16,7 @@ const DEFAULT_EXTERNAL_API_TIMEOUT: (&str, i64) = ("external_api_timeout", 3); / #[derive(Clone)] pub struct HandlersConfig { pub accounts_query_topic: String, + pub addresses_query_topic: String, pub assets_query_topic: String, pub pools_query_topic: String, pub dreps_query_topic: String, @@ -31,6 +33,10 @@ impl From> for HandlersConfig { .get_string(DEFAULT_ACCOUNTS_QUERY_TOPIC.0) .unwrap_or(DEFAULT_ACCOUNTS_QUERY_TOPIC.1.to_string()); + let addresses_query_topic = config + .get_string(DEFAULT_ADDRESS_QUERY_TOPIC.0) + .unwrap_or(DEFAULT_ADDRESS_QUERY_TOPIC.1.to_string()); + let assets_query_topic = config .get_string(DEFAULT_ASSETS_QUERY_TOPIC.0) .unwrap_or(DEFAULT_ASSETS_QUERY_TOPIC.1.to_string()); @@ -65,6 +71,7 @@ impl From> for HandlersConfig { Self { accounts_query_topic, + addresses_query_topic, assets_query_topic, pools_query_topic, dreps_query_topic, diff --git a/modules/rest_blockfrost/src/rest_blockfrost.rs b/modules/rest_blockfrost/src/rest_blockfrost.rs index 745bcaa5..84639b7b 100644 --- a/modules/rest_blockfrost/src/rest_blockfrost.rs +++ b/modules/rest_blockfrost/src/rest_blockfrost.rs @@ -17,9 +17,15 @@ mod types; mod utils; use handlers::{ accounts::handle_single_account_blockfrost, + addresses::{ + handle_address_asset_utxos_blockfrost, handle_address_extended_blockfrost, + handle_address_single_blockfrost, handle_address_totals_blockfrost, + handle_address_transactions_blockfrost, handle_address_utxos_blockfrost, + }, assets::{ handle_asset_addresses_blockfrost, handle_asset_history_blockfrost, - handle_asset_transactions_blockfrost, handle_assets_list_blockfrost, + handle_asset_single_blockfrost, handle_asset_transactions_blockfrost, + handle_assets_list_blockfrost, handle_policy_assets_blockfrost, }, epochs::{ handle_epoch_info_blockfrost, handle_epoch_next_blockfrost, handle_epoch_params_blockfrost, @@ -44,10 +50,7 @@ use handlers::{ }, }; -use crate::{ - handlers::assets::{handle_asset_single_blockfrost, handle_policy_assets_blockfrost}, - handlers_config::HandlersConfig, -}; +use crate::handlers_config::HandlersConfig; // Accounts topics const DEFAULT_HANDLE_SINGLE_ACCOUNT_TOPIC: (&str, &str) = @@ -153,10 +156,8 @@ const DEFAULT_HANDLE_EPOCH_POOL_BLOCKS_TOPIC: (&str, &str) = ( // Assets topics const DEFAULT_HANDLE_ASSETS_LIST_TOPIC: (&str, &str) = ("handle-topic-assets-list", "rest.get.assets"); -const DEFAULT_HANDLE_ASSET_SINGLE_TOPIC: (&str, &str) = ( - "handle-topic-policy-assets-asset-single", - "rest.get.assets.*", -); +const DEFAULT_HANDLE_ASSET_SINGLE_TOPIC: (&str, &str) = + ("handle-topic-asset-single", "rest.get.assets.*"); const DEFAULT_HANDLE_ASSET_HISTORY_TOPIC: (&str, &str) = ("handle-topic-asset-history", "rest.get.assets.*.history"); const DEFAULT_HANDLE_ASSET_TRANSACTIONS_TOPIC: (&str, &str) = ( @@ -170,6 +171,27 @@ const DEFAULT_HANDLE_ASSET_ADDRESSES_TOPIC: (&str, &str) = ( const DEFAULT_HANDLE_POLICY_ASSETS_TOPIC: (&str, &str) = ("handle-topic-policy-assets", "rest.get.assets.policy.*"); +// Addresses topics +const DEFAULT_HANDLE_ADDRESS_SINGLE_TOPIC: (&str, &str) = + ("handle-topic-address-single", "rest.get.addresses.*"); + +const DEFAULT_HANDLE_ADDRESS_EXTENDED_TOPIC: (&str, &str) = ( + "handle-topic-address-extended", + "rest.get.addresses.*.extended", +); +const DEFAULT_HANDLE_ADDRESS_TOTALS_TOPIC: (&str, &str) = + ("handle-topic-address-totals", "rest.get.addresses.*.total"); +const DEFAULT_HANDLE_ADDRESS_UTXOS_TOPIC: (&str, &str) = + ("handle-topic-address-utxos", "rest.get.addresses.*.utxos"); +const DEFAULT_HANDLE_ADDRESS_ASSET_UTXOS_TOPIC: (&str, &str) = ( + "handle-topic-address-asset-utxos", + "rest.get.addresses.*.utxos.*", +); +const DEFAULT_HANDLE_ADDRESS_TRANSACTIONS_TOPIC: (&str, &str) = ( + "handle-topic-address-transactions", + "rest.get.addresses.*.transactions", +); + #[module( message_type(Message), name = "rest-blockfrost", @@ -473,6 +495,54 @@ impl BlockfrostREST { handle_policy_assets_blockfrost, ); + // Handler for /addresses/{address} + register_handler( + context.clone(), + DEFAULT_HANDLE_ADDRESS_SINGLE_TOPIC, + handlers_config.clone(), + handle_address_single_blockfrost, + ); + + // Handler for /addresses/{address}/extended + register_handler( + context.clone(), + DEFAULT_HANDLE_ADDRESS_EXTENDED_TOPIC, + handlers_config.clone(), + handle_address_extended_blockfrost, + ); + + // Handler for /addresses/{address}/total + register_handler( + context.clone(), + DEFAULT_HANDLE_ADDRESS_TOTALS_TOPIC, + handlers_config.clone(), + handle_address_totals_blockfrost, + ); + + // Handler for /addresses/{address}/utxos + register_handler( + context.clone(), + DEFAULT_HANDLE_ADDRESS_UTXOS_TOPIC, + handlers_config.clone(), + handle_address_utxos_blockfrost, + ); + + // Handler for /addresses/{address}/utxos/{asset} + register_handler( + context.clone(), + DEFAULT_HANDLE_ADDRESS_ASSET_UTXOS_TOPIC, + handlers_config.clone(), + handle_address_asset_utxos_blockfrost, + ); + + // Handler for /addresses/{address}/transactions + register_handler( + context.clone(), + DEFAULT_HANDLE_ADDRESS_TRANSACTIONS_TOPIC, + handlers_config.clone(), + handle_address_transactions_blockfrost, + ); + Ok(()) } } diff --git a/modules/stake_delta_filter/src/utils.rs b/modules/stake_delta_filter/src/utils.rs index 334df1cd..8ad6ae24 100644 --- a/modules/stake_delta_filter/src/utils.rs +++ b/modules/stake_delta_filter/src/utils.rs @@ -277,13 +277,13 @@ impl Tracker { .map(|a| a.to_string()) .unwrap_or(Ok("(none)".to_owned())) .unwrap_or("(???)".to_owned()); - delta += event.address_delta.delta.lovelace; + delta += event.address_delta.value.lovelace; chunk.push(format!( " blk {}, {}: {} ({:?}) => {} ({:?})", event.block.number, src_addr, - event.address_delta.delta.lovelace, + event.address_delta.value.lovelace, event.address_delta.address, dst_addr, event.stake_address @@ -388,7 +388,7 @@ pub fn process_message( let stake_delta = StakeAddressDelta { address: stake_address, - delta: d.delta.lovelace, + delta: d.value.lovelace, }; result.deltas.push(stake_delta); } @@ -402,7 +402,7 @@ mod test { use acropolis_common::{ messages::AddressDeltasMessage, Address, AddressDelta, BlockHash, BlockInfo, BlockStatus, ByronAddress, Era, ShelleyAddress, ShelleyAddressDelegationPart, ShelleyAddressPaymentPart, - ShelleyAddressPointer, StakeAddress, StakeAddressPayload, ValueDelta, + ShelleyAddressPointer, StakeAddress, StakeAddressPayload, UTxOIdentifier, ValueDelta, }; use bech32::{Bech32, Hrp}; @@ -410,7 +410,8 @@ mod test { let a = pallas::ledger::addresses::Address::from_bech32(s)?; Ok(AddressDelta { address: map_address(&a)?, - delta: ValueDelta::new(1, Vec::new()), + utxo: UTxOIdentifier::new(0, 0, 0), + value: ValueDelta::new(1, Vec::new()), }) } diff --git a/modules/utxo_state/src/address_delta_publisher.rs b/modules/utxo_state/src/address_delta_publisher.rs index 68a33a1b..29a2b222 100644 --- a/modules/utxo_state/src/address_delta_publisher.rs +++ b/modules/utxo_state/src/address_delta_publisher.rs @@ -1,7 +1,7 @@ //! Address delta publisher for the UTXO state Acropolis module use acropolis_common::{ messages::{AddressDeltasMessage, CardanoMessage, Message}, - Address, AddressDelta, BlockInfo, ValueDelta, + AddressDelta, BlockInfo, }; use async_trait::async_trait; use caryatid_sdk::Context; @@ -44,12 +44,9 @@ impl AddressDeltaObserver for AddressDeltaPublisher { } /// Observe an address delta and publish messages - async fn observe_delta(&self, address: &Address, delta: ValueDelta) { + async fn observe_delta(&self, delta: &AddressDelta) { // Accumulate the delta - self.deltas.lock().await.push(AddressDelta { - address: address.clone(), - delta, - }); + self.deltas.lock().await.push(delta.clone()); } async fn finalise_block(&self, block: &BlockInfo) { diff --git a/modules/utxo_state/src/state.rs b/modules/utxo_state/src/state.rs index c9cd580e..53d25f46 100644 --- a/modules/utxo_state/src/state.rs +++ b/modules/utxo_state/src/state.rs @@ -4,7 +4,7 @@ use acropolis_common::{ messages::UTXODeltasMessage, params::SECURITY_PARAMETER_K, Address, BlockInfo, BlockStatus, TxInput, TxOutput, UTXODelta, }; -use acropolis_common::{UTxOIdentifier, Value, ValueDelta}; +use acropolis_common::{AddressDelta, UTxOIdentifier, Value, ValueDelta}; use anyhow::Result; use async_trait::async_trait; use std::collections::HashMap; @@ -30,7 +30,7 @@ pub trait AddressDeltaObserver: Send + Sync { async fn start_block(&self, block: &BlockInfo); /// Observe a delta - async fn observe_delta(&self, address: &Address, delta: ValueDelta); + async fn observe_delta(&self, address: &AddressDelta); /// Finalise a block async fn finalise_block(&self, block: &BlockInfo); @@ -128,7 +128,11 @@ impl State { // Tell the observer to debit it if let Some(observer) = self.address_delta_observer.as_ref() { observer - .observe_delta(&utxo.address, -ValueDelta::from(&utxo.value)) + .observe_delta(&AddressDelta { + address: utxo.address.clone(), + utxo: key.clone(), + value: ValueDelta::from(&utxo.value), + }) .await; } } @@ -142,7 +146,11 @@ impl State { // Tell the observer to recredit it if let Some(observer) = self.address_delta_observer.as_ref() { observer - .observe_delta(&utxo.address, ValueDelta::from(&utxo.value)) + .observe_delta(&AddressDelta { + address: utxo.address.clone(), + utxo: key.clone(), + value: ValueDelta::from(&utxo.value), + }) .await; } } @@ -196,8 +204,13 @@ impl State { } // Tell the observer it's spent - if let Some(observer) = self.address_delta_observer.as_ref() { - observer.observe_delta(&utxo.address, -ValueDelta::from(&utxo.value)).await; + if let Some(obs) = &self.address_delta_observer { + obs.observe_delta(&AddressDelta { + address: utxo.address.clone(), + utxo: key.clone(), + value: ValueDelta::from(&utxo.value), + }) + .await; } match block.status { @@ -270,8 +283,13 @@ impl State { }; // Tell the observer - if let Some(observer) = self.address_delta_observer.as_ref() { - observer.observe_delta(&output.address, ValueDelta::from(&output.value)).await; + if let Some(obs) = &self.address_delta_observer { + obs.observe_delta(&AddressDelta { + address: output.address.clone(), + utxo: output.utxo_identifier.clone(), + value: ValueDelta::from(&output.value), + }) + .await; } Ok(()) @@ -737,18 +755,18 @@ mod tests { #[async_trait] impl AddressDeltaObserver for TestDeltaObserver { async fn start_block(&self, _block: &BlockInfo) {} - async fn observe_delta(&self, address: &Address, delta: ValueDelta) { + async fn observe_delta(&self, delta: &AddressDelta) { assert!(matches!( - &address, + &delta.address, Address::Byron(ByronAddress { payload }) if payload[0] == 99 )); - assert!(delta.lovelace == 42 || delta.lovelace == -42); + assert!(delta.value.lovelace == 42 || delta.value.lovelace == -42); let mut balance = self.balance.lock().await; - *balance += delta.lovelace; + *balance += delta.value.lovelace; let mut asset_balances = self.asset_balances.lock().await; - for (policy, assets) in &delta.assets { + for (policy, assets) in &delta.value.assets { assert_eq!([1u8; 28], *policy); for asset in assets { assert!( diff --git a/processes/omnibus/omnibus.toml b/processes/omnibus/omnibus.toml index 6a6b84ff..7a74ee79 100644 --- a/processes/omnibus/omnibus.toml +++ b/processes/omnibus/omnibus.toml @@ -98,8 +98,6 @@ store-addresses = false index-by-policy = false [module.address-state] -# Enables address registry for query lookups -enable-registry = false # Enables /addresses/{address}, /addresses/{address}/extended, # and /addresses/{address}/utxos endpoints store-info = false diff --git a/processes/omnibus/src/main.rs b/processes/omnibus/src/main.rs index 1dbda819..e4b856ee 100644 --- a/processes/omnibus/src/main.rs +++ b/processes/omnibus/src/main.rs @@ -10,6 +10,7 @@ use tracing_subscriber; // External modules use acropolis_module_accounts_state::AccountsState; +use acropolis_module_address_state::AddressState; use acropolis_module_assets_state::AssetsState; use acropolis_module_block_unpacker::BlockUnpacker; use acropolis_module_drdd_state::DRDDState; @@ -99,8 +100,8 @@ pub async fn main() -> Result<()> { StakeDeltaFilter::register(&mut process); EpochsState::register(&mut process); AccountsState::register(&mut process); + AddressState::register(&mut process); AssetsState::register(&mut process); - TransactionState::register(&mut process); BlockfrostREST::register(&mut process); SPDDState::register(&mut process); DRDDState::register(&mut process); From 0e1314de1bd1904bc9b27af5da74913f357cf4fa Mon Sep 17 00:00:00 2001 From: William Hankins Date: Wed, 1 Oct 2025 00:58:22 +0000 Subject: [PATCH 04/18] store utxos and txs outside rollback window on disk Signed-off-by: William Hankins --- processes/omnibus/data/address_state/journals/0 | Bin 0 -> 232 bytes .../partitions/address_totals/config | Bin 0 -> 30 bytes .../partitions/address_totals/levels | Bin 0 -> 33 bytes .../partitions/address_totals/manifest | Bin 0 -> 7 bytes .../address_state/partitions/address_txs/config | Bin 0 -> 30 bytes .../address_state/partitions/address_txs/levels | Bin 0 -> 33 bytes .../address_state/partitions/address_txs/manifest | Bin 0 -> 7 bytes .../address_state/partitions/address_utxos/config | Bin 0 -> 30 bytes .../address_state/partitions/address_utxos/levels | Bin 0 -> 33 bytes .../partitions/address_utxos/manifest | Bin 0 -> 7 bytes processes/omnibus/data/address_state/version | 1 + processes/omnibus/omnibus.toml | 6 +++--- 12 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 processes/omnibus/data/address_state/journals/0 create mode 100644 processes/omnibus/data/address_state/partitions/address_totals/config create mode 100644 processes/omnibus/data/address_state/partitions/address_totals/levels create mode 100644 processes/omnibus/data/address_state/partitions/address_totals/manifest create mode 100644 processes/omnibus/data/address_state/partitions/address_txs/config create mode 100644 processes/omnibus/data/address_state/partitions/address_txs/levels create mode 100644 processes/omnibus/data/address_state/partitions/address_txs/manifest create mode 100644 processes/omnibus/data/address_state/partitions/address_utxos/config create mode 100644 processes/omnibus/data/address_state/partitions/address_utxos/levels create mode 100644 processes/omnibus/data/address_state/partitions/address_utxos/manifest create mode 100644 processes/omnibus/data/address_state/version diff --git a/processes/omnibus/data/address_state/journals/0 b/processes/omnibus/data/address_state/journals/0 new file mode 100644 index 0000000000000000000000000000000000000000..c4f0fc3faa443e00bd81a536efeb50007fe4ae8b GIT binary patch literal 232 zcmZQ%U|?VbVklr@;7v?PDM~FajxQ~#$S-CPiH9)aQw#EwGvec|auSP6fQmW(Ljm*Y zVsORw` Date: Wed, 1 Oct 2025 23:41:35 +0000 Subject: [PATCH 05/18] refactor: batch persistence of address state Signed-off-by: William Hankins --- Cargo.lock | 3 + common/src/address.rs | 92 +++++++ common/src/queries/addresses.rs | 4 +- common/src/types.rs | 145 ++++++++-- modules/address_state/Cargo.toml | 19 +- modules/address_state/src/address_state.rs | 242 +++++++++++++---- modules/address_state/src/address_store.rs | 30 +++ .../src/fjall_immutable_address_store.rs | 253 ++++++++++++++++++ modules/address_state/src/state.rs | 171 ++++++++---- modules/address_state/src/volatile_index.rs | 80 ++++++ .../omnibus/data/address_state/journals/0 | Bin 232 -> 0 bytes .../partitions/address_totals/config | Bin 30 -> 0 bytes .../partitions/address_totals/levels | Bin 33 -> 0 bytes .../partitions/address_totals/manifest | Bin 7 -> 0 bytes .../partitions/address_txs/config | Bin 30 -> 0 bytes .../partitions/address_txs/levels | Bin 33 -> 0 bytes .../partitions/address_txs/manifest | Bin 7 -> 0 bytes .../partitions/address_utxos/config | Bin 30 -> 0 bytes .../partitions/address_utxos/levels | Bin 33 -> 0 bytes .../partitions/address_utxos/manifest | Bin 7 -> 0 bytes processes/omnibus/data/address_state/version | 1 - 21 files changed, 907 insertions(+), 133 deletions(-) create mode 100644 modules/address_state/src/address_store.rs create mode 100644 modules/address_state/src/fjall_immutable_address_store.rs create mode 100644 modules/address_state/src/volatile_index.rs delete mode 100644 processes/omnibus/data/address_state/journals/0 delete mode 100644 processes/omnibus/data/address_state/partitions/address_totals/config delete mode 100644 processes/omnibus/data/address_state/partitions/address_totals/levels delete mode 100644 processes/omnibus/data/address_state/partitions/address_totals/manifest delete mode 100644 processes/omnibus/data/address_state/partitions/address_txs/config delete mode 100644 processes/omnibus/data/address_state/partitions/address_txs/levels delete mode 100644 processes/omnibus/data/address_state/partitions/address_txs/manifest delete mode 100644 processes/omnibus/data/address_state/partitions/address_utxos/config delete mode 100644 processes/omnibus/data/address_state/partitions/address_utxos/levels delete mode 100644 processes/omnibus/data/address_state/partitions/address_utxos/manifest delete mode 100644 processes/omnibus/data/address_state/version diff --git a/Cargo.lock b/Cargo.lock index 9bdb08e5..66605c8f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -61,10 +61,13 @@ version = "0.1.0" dependencies = [ "acropolis_common", "anyhow", + "async-trait", "caryatid_sdk", "config", + "fjall", "hex", "imbl", + "minicbor 0.26.5", "serde_cbor", "tokio", "tracing", diff --git a/common/src/address.rs b/common/src/address.rs index 9000161f..97466a52 100644 --- a/common/src/address.rs +++ b/common/src/address.rs @@ -62,6 +62,21 @@ impl ByronAddress { Ok(address) } + + pub fn to_bytes_key(&self) -> Result> { + let crc = self.compute_crc32(); + + let mut buf = Vec::new(); + { + let mut enc = minicbor::Encoder::new(&mut buf); + enc.array(2)?; + enc.tag(minicbor::data::IanaTag::Cbor)?; + enc.bytes(&self.payload)?; + enc.u32(crc)?; + } + + Ok(buf) + } } /// Address network identifier @@ -221,6 +236,53 @@ impl ShelleyAddress { data.extend(delegation_hash); Ok(bech32::encode::(hrp, &data)?) } + + pub fn to_bytes_key(&self) -> Result> { + let network_bits = match self.network { + AddressNetwork::Main => 1u8, + AddressNetwork::Test => 0u8, + }; + + let (payment_hash, payment_bits): (&Vec, u8) = match &self.payment { + ShelleyAddressPaymentPart::PaymentKeyHash(data) => (data, 0), + ShelleyAddressPaymentPart::ScriptHash(data) => (data, 1), + }; + + let mut data = Vec::new(); + + match &self.delegation { + ShelleyAddressDelegationPart::None => { + let header = network_bits | (payment_bits << 4) | (3 << 5); + data.push(header); + data.extend(payment_hash); + } + ShelleyAddressDelegationPart::StakeKeyHash(hash) => { + let header = network_bits | (payment_bits << 4) | (0 << 5); + data.push(header); + data.extend(payment_hash); + data.extend(hash); + } + ShelleyAddressDelegationPart::ScriptHash(hash) => { + let header = network_bits | (payment_bits << 4) | (1 << 5); + data.push(header); + data.extend(payment_hash); + data.extend(hash); + } + ShelleyAddressDelegationPart::Pointer(pointer) => { + let header = network_bits | (payment_bits << 4) | (2 << 5); + data.push(header); + data.extend(payment_hash); + + let mut encoder = VarIntEncoder::new(); + encoder.push(pointer.slot); + encoder.push(pointer.tx_index); + encoder.push(pointer.cert_index); + data.extend(encoder.to_vec()); + } + } + + Ok(data) + } } /// Payload of a stake address @@ -322,6 +384,24 @@ impl StakeAddress { data.extend(stake_hash); Ok(bech32::encode::(hrp, &data)?) } + + pub fn to_bytes_key(&self) -> Result> { + let mut out = Vec::new(); + let (bits, hash): (u8, &[u8]) = match &self.payload { + StakeAddressPayload::StakeKeyHash(h) => (0b1110, h), + StakeAddressPayload::ScriptHash(h) => (0b1111, h), + }; + + let net_bit = match self.network { + AddressNetwork::Main => 1, + AddressNetwork::Test => 0, + }; + + let header = net_bit | (bits << 4); + out.push(header); + out.extend_from_slice(hash); + Ok(out) + } } /// A Cardano address @@ -373,6 +453,18 @@ impl Address { Self::Stake(stake) => stake.to_string(), } } + + pub fn to_bytes_key(&self) -> Result> { + match self { + Address::Byron(b) => b.to_bytes_key(), + + Address::Shelley(s) => s.to_bytes_key(), + + Address::Stake(stake) => stake.to_bytes_key(), + + Address::None => Err(anyhow!("No address to convert")), + } + } } // -- Tests -- diff --git a/common/src/queries/addresses.rs b/common/src/queries/addresses.rs index 3ad8f389..01985834 100644 --- a/common/src/queries/addresses.rs +++ b/common/src/queries/addresses.rs @@ -1,4 +1,4 @@ -use crate::{Address, AddressTotalsEntry, TxIdentifier, UTxOIdentifier}; +use crate::{Address, AddressTotals, TxIdentifier, UTxOIdentifier}; pub const DEFAULT_ADDRESS_QUERY_TOPIC: (&str, &str) = ("address-state-query-topic", "cardano.query.address"); @@ -12,7 +12,7 @@ pub enum AddressStateQuery { #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub enum AddressStateQueryResponse { - AddressTotals(AddressTotalsEntry), + AddressTotals(AddressTotals), AddressUTxOs(Vec), AddressTransactions(Vec), NotFound, diff --git a/common/src/types.rs b/common/src/types.rs index 3b9f27d8..ebbf407a 100644 --- a/common/src/types.rs +++ b/common/src/types.rs @@ -168,10 +168,24 @@ pub struct StakeRewardDelta { pub type PolicyId = [u8; 28]; pub type NativeAssets = Vec<(PolicyId, Vec)>; pub type NativeAssetsDelta = Vec<(PolicyId, Vec)>; +pub type NativeAssetsMap = HashMap>; -#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, serde::Serialize, serde::Deserialize)] +#[derive( + Debug, + Copy, + Clone, + Eq, + PartialEq, + Hash, + serde::Serialize, + serde::Deserialize, + minicbor::Encode, + minicbor::Decode, +)] pub struct AssetName { + #[n(0)] len: u8, + #[n(1)] bytes: [u8; 32], } @@ -197,15 +211,23 @@ impl AssetName { } } -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[derive( + Debug, Clone, serde::Serialize, serde::Deserialize, minicbor::Encode, minicbor::Decode, +)] pub struct NativeAsset { + #[n(0)] pub name: AssetName, + #[n(1)] pub amount: u64, } -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[derive( + Debug, Clone, serde::Serialize, serde::Deserialize, minicbor::Encode, minicbor::Decode, +)] pub struct NativeAssetDelta { + #[n(0)] pub name: AssetName, + #[n(1)] pub amount: i64, } @@ -233,12 +255,33 @@ impl Value { } } -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +/// Hashmap representation of Value (lovelace + multiasset) +#[derive( + Debug, Default, Clone, serde::Serialize, serde::Deserialize, minicbor::Encode, minicbor::Decode, +)] +pub struct ValueMap { + #[n(0)] + pub lovelace: u64, + #[n(1)] + pub assets: NativeAssetsMap, +} + +#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)] pub struct ValueDelta { pub lovelace: i64, pub assets: NativeAssetsDelta, } +#[derive( + Debug, Default, Clone, serde::Serialize, serde::Deserialize, minicbor::Encode, minicbor::Decode, +)] +pub struct AddressTotalsMap { + #[n(0)] + pub lovelace: i64, + #[n(1)] + pub assets: NativeAssetsMap, +} + impl ValueDelta { pub fn new(lovelace: i64, assets: NativeAssetsDelta) -> Self { Self { lovelace, assets } @@ -337,9 +380,19 @@ pub type TxHash = [u8; 32]; /// Compact transaction identifier (block_number, tx_index). #[derive( - Debug, Default, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize, + Debug, + Default, + Clone, + Copy, + PartialEq, + Eq, + Hash, + serde::Serialize, + serde::Deserialize, + minicbor::Encode, + minicbor::Decode, )] -pub struct TxIdentifier([u8; 6]); +pub struct TxIdentifier(#[n(0)] [u8; 6]); impl TxIdentifier { pub fn new(block_number: u32, tx_index: u16) -> Self { @@ -367,8 +420,19 @@ impl TxIdentifier { } // Compact UTxO identifier (block_number, tx_index, output_index) -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] -pub struct UTxOIdentifier([u8; 8]); +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Hash, + serde::Serialize, + serde::Deserialize, + minicbor::Encode, + minicbor::Decode, +)] +pub struct UTxOIdentifier(#[n(0)] [u8; 8]); impl UTxOIdentifier { pub fn new(block_number: u32, tx_index: u16, output_index: u16) -> Self { @@ -1727,19 +1791,70 @@ pub struct PolicyAsset { pub quantity: u64, } -#[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct AddressTotalsEntry { - pub sent: NativeAssets, - pub received: NativeAssets, - pub tx_count: u64, -} - #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct AssetAddressEntry { pub address: ShelleyAddress, pub quantity: u64, } +#[derive( + Debug, Default, Clone, serde::Serialize, serde::Deserialize, minicbor::Encode, minicbor::Decode, +)] +pub struct AddressTotals { + #[n(0)] + pub sent: ValueMap, + #[n(1)] + pub received: ValueMap, + #[n(2)] + pub tx_count: u64, +} + +impl AddressTotals { + pub fn apply_delta(&mut self, delta: &ValueDelta) { + if delta.lovelace > 0 { + self.received.lovelace += delta.lovelace as u64; + } else if delta.lovelace < 0 { + self.sent.lovelace += (-delta.lovelace) as u64; + } + + for (policy, assets) in &delta.assets { + for a in assets { + if a.amount > 0 { + Self::apply_asset( + &mut self.received.assets, + *policy, + a.name.clone(), + a.amount as u64, + ); + } else if a.amount < 0 { + Self::apply_asset( + &mut self.sent.assets, + *policy, + a.name.clone(), + a.amount.unsigned_abs(), + ); + } + } + } + + self.tx_count += 1; + } + + fn apply_asset( + target: &mut HashMap<[u8; 28], HashMap>, + policy: [u8; 28], + name: AssetName, + amount: u64, + ) { + target + .entry(policy) + .or_default() + .entry(name) + .and_modify(|v| *v += amount) + .or_insert(amount); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/modules/address_state/Cargo.toml b/modules/address_state/Cargo.toml index 3b44f9dd..210ae9b5 100644 --- a/modules/address_state/Cargo.toml +++ b/modules/address_state/Cargo.toml @@ -9,15 +9,20 @@ description = "Address State Tracker" license = "Apache-2.0" [dependencies] -caryatid_sdk = "0.12" acropolis_common = { path = "../../common" } -config = "0.15.11" -tokio = { version = "1", features = ["full"] } -tracing = "0.1.40" -anyhow = "1.0" -imbl = { version = "5.0.0", features = ["serde"] } -hex = "0.4.3" + +caryatid_sdk = { workspace = true } + +anyhow = { workspace = true } +async-trait = "0.1" +config = { workspace = true } +fjall = "2.7.0" +hex = { workspace = true } +imbl = { workspace = true } +minicbor = { version = "0.26.0", features = ["std", "derive"] } serde_cbor = "0.11" +tokio = { workspace = true } +tracing = { workspace = true } [lib] path = "src/address_state.rs" diff --git a/modules/address_state/src/address_state.rs b/modules/address_state/src/address_state.rs index 1ec2d3be..58d59a7e 100644 --- a/modules/address_state/src/address_state.rs +++ b/modules/address_state/src/address_state.rs @@ -9,8 +9,7 @@ use acropolis_common::{ queries::addresses::{ AddressStateQuery, AddressStateQueryResponse, DEFAULT_ADDRESS_QUERY_TOPIC, }, - state_history::{StateHistory, StateHistoryStore}, - BlockStatus, + BlockInfo, BlockStatus, }; use anyhow::Result; use caryatid_sdk::{module, Context, Module, Subscription}; @@ -18,12 +17,21 @@ use config::Config; use tokio::sync::Mutex; use tracing::{error, info, info_span, Instrument}; -use crate::state::{AddressStorageConfig, State}; +use crate::{ + address_store::AddressStore, + state::{AddressStorageConfig, State}, +}; +mod address_store; +mod fjall_immutable_address_store; +use fjall_immutable_address_store::FjallImmutableAddressStore; mod state; +mod volatile_index; // Subscription topics const DEFAULT_ADDRESS_DELTAS_SUBSCRIBE_TOPIC: (&str, &str) = ("address-deltas-subscribe-topic", "cardano.address.delta"); +const DEFAULT_PARAMETERS_SUBSCRIBE_TOPIC: (&str, &str) = + ("parameters-subscribe-topic", "cardano.protocol.parameters"); // Configuration defaults const DEFAULT_STORE_INFO: (&str, bool) = ("store-info", false); @@ -40,42 +48,90 @@ pub struct AddressState; impl AddressState { async fn run( - history: Arc>>, + state_mutex: Arc>, mut address_deltas_subscription: Option>>, - storage_config: AddressStorageConfig, + mut params_subscription: Option>>, + persist_epoch: Option, + store: Option>, ) -> Result<()> { + if let Some(sub) = params_subscription.as_mut() { + let _ = sub.read().await?; + info!("Consumed initial genesis params from params_subscription"); + } // Main loop of synchronised messages loop { - // Get current state snapshot - let mut state = { - let mut h = history.lock().await; - h.get_or_init_with(|| State::new(storage_config)) - }; + let mut current_block: Option = None; + let mut state = state_mutex.lock().await; // Handle UTxO deltas if subscription is registered (store-info or store-transactions enabled) if let Some(sub) = address_deltas_subscription.as_mut() { let (_, deltas_msg) = sub.read().await?; + let new_epoch = match deltas_msg.as_ref() { + Message::Cardano((ref block_info, _)) => { + if block_info.status == BlockStatus::RolledBack { + state.volatile_entries.rollback_before(block_info.number); + } else { + state.volatile_entries.next_block(); + } + current_block = Some(block_info.clone()); + block_info.new_epoch && block_info.epoch > 0 + } + _ => false, + }; + + if new_epoch { + if let Some(sub) = params_subscription.as_mut() { + let (_, message) = sub.read().await?; + if let Message::Cardano(( + ref block_info, + CardanoMessage::ProtocolParams(params), + )) = message.as_ref() + { + Self::check_sync(¤t_block, &block_info, "params"); + state.volatile_entries.start_new_epoch(block_info.number); + if let Some(shelley) = ¶ms.params.shelley { + state.volatile_entries.update_k(shelley.security_param); + } + } + } + } + match deltas_msg.as_ref() { Message::Cardano(( ref block_info, CardanoMessage::AddressDeltas(address_deltas_msg), )) => { - if block_info.status == BlockStatus::RolledBack { - state = history.lock().await.get_rolled_back_state(block_info.number); + // Skip processing for epochs already stored to DB + if let Some(min_epoch) = persist_epoch { + if block_info.epoch <= min_epoch { + continue; + } } - state = match state.handle_address_deltas(&address_deltas_msg.deltas) { - Ok(new_state) => new_state, - Err(e) => { - error!("address deltas handling error: {e:#}"); - state - } - }; + // Update volatile entries + if let Err(e) = state.handle_address_deltas(&address_deltas_msg.deltas) { + error!("address deltas handling error: {e:#}"); + } - // Commit state - { - let mut h = history.lock().await; - h.commit(block_info.number, state); + if block_info.epoch > 0 { + // Compute the safe_block at which the previous epoch can be removed from volatile + let safe_block = state.volatile_entries.epoch_start_block + + state.volatile_entries.security_param_k; + + // Persist to disk and prune from volatile when block number exceeds safe block + if block_info.number > safe_block { + if Some(block_info.epoch) + != state.volatile_entries.last_persisted_epoch + { + if let Some(address_store) = &store { + let config = state.config.clone(); + state + .volatile_entries + .persist_all(address_store.as_ref(), &config) + .await?; + } + } + } } } other => error!("Unexpected message on utxo-deltas subscription: {other:?}"), @@ -84,6 +140,26 @@ impl AddressState { } } + fn check_sync(expected: &Option, actual: &BlockInfo, source: &str) { + if let Some(ref block) = expected { + if block.number != actual.number { + error!( + expected = block.number, + actual = actual.number, + source = source, + "Messages out of sync (expected certs block {}, got {} from {})", + block.number, + actual.number, + source, + ); + panic!( + "Message streams diverged: certs at {} vs {} from {}", + block.number, actual.number, source + ); + } + } + } + pub async fn init(&self, context: Arc>, config: Arc) -> Result<()> { fn get_bool_flag(config: &Config, key: (&str, bool)) -> bool { config.get_bool(key.0).unwrap_or(key.1) @@ -108,21 +184,51 @@ impl AddressState { None }; + let params_subscribe_topic: Option = if storage_config.any_enabled() { + let topic = get_string_flag(&config, DEFAULT_PARAMETERS_SUBSCRIBE_TOPIC); + info!("Creating subscriber on '{topic}'"); + Some(topic) + } else { + None + }; + let address_query_topic = get_string_flag(&config, DEFAULT_ADDRESS_QUERY_TOPIC); info!("Creating asset query handler on '{address_query_topic}'"); - // Initalize state history - let history = Arc::new(Mutex::new(StateHistory::::new( - "AddressState", - StateHistoryStore::default_block_store(), - ))); - let history_run = history.clone(); - let query_history = history.clone(); - let tick_history = history.clone(); + // Initialize state history + let state = Arc::new(Mutex::new(State::new(storage_config))); + let state_run = state.clone(); + let state_query = state.clone(); + let state_tick = state.clone(); + + // Initialize Fjall store + let (store, persist_epoch): (Option>, Option) = + if storage_config.any_enabled() { + let path = config + .get_string("address_state.path") + .unwrap_or_else(|_| "./data/address_state".to_string()); + + let store = FjallImmutableAddressStore::new(path)?; + let persist_after = store.get_last_epoch_stored().await?; + ( + Some(Arc::new(store) as Arc), + persist_after, + ) + } else { + (None, None) + }; + + match persist_epoch { + Some(epoch) => info!("Persist epoch marker found: {}", epoch), + None => info!("No persist epoch marker found in store"), + } + let query_store = store.clone(); + let store_run = store.clone(); // Query handler context.handle(&address_query_topic, move |message| { - let history = query_history.clone(); + let state_mutex = state_query.clone(); + let store = query_store.clone(); async move { let Message::StateQuery(StateQuery::Addresses(query)) = message.as_ref() else { return Arc::new(Message::StateQueryResponse(StateQueryResponse::Addresses( @@ -130,28 +236,40 @@ impl AddressState { ))); }; - let state = history.lock().await.get_current_state(); - + let state = state_mutex.lock().await; let response = match query { AddressStateQuery::GetAddressUTxOs { address } => { - match state.get_address_utxos(&address) { - Ok(Some(utxos)) => AddressStateQueryResponse::AddressUTxOs(utxos), - Ok(None) => AddressStateQueryResponse::NotFound, - Err(e) => AddressStateQueryResponse::Error(e.to_string()), + if let Some(ref s) = store { + match state.get_address_utxos(s.as_ref(), &address).await { + Ok(Some(utxos)) => AddressStateQueryResponse::AddressUTxOs(utxos), + Ok(None) => AddressStateQueryResponse::NotFound, + Err(e) => AddressStateQueryResponse::Error(e.to_string()), + } + } else { + AddressStateQueryResponse::Error("Address store not initialized".into()) } } - - AddressStateQuery::GetAddressTotals { address } => { - match state.get_address_totals(&address) { - Ok(totals) => AddressStateQueryResponse::AddressTotals(totals), - Err(e) => AddressStateQueryResponse::Error(e.to_string()), + AddressStateQuery::GetAddressTransactions { address } => { + if let Some(ref s) = store { + match state.get_address_transactions(s.as_ref(), &address).await { + Ok(Some(txs)) => { + AddressStateQueryResponse::AddressTransactions(txs) + } + Ok(None) => AddressStateQueryResponse::NotFound, + Err(e) => AddressStateQueryResponse::Error(e.to_string()), + } + } else { + AddressStateQueryResponse::Error("Address store not initialized".into()) } } - AddressStateQuery::GetAddressTransactions { address } => { - match state.get_address_transactions(&address) { - Ok(Some(txs)) => AddressStateQueryResponse::AddressTransactions(txs), - Ok(None) => AddressStateQueryResponse::NotFound, - Err(e) => AddressStateQueryResponse::Error(e.to_string()), + AddressStateQuery::GetAddressTotals { address } => { + if let Some(ref s) = store { + match state.get_address_totals(s.as_ref(), &address).await { + Ok(totals) => AddressStateQueryResponse::AddressTotals(totals), + Err(e) => AddressStateQueryResponse::Error(e.to_string()), + } + } else { + AddressStateQueryResponse::Error("Address store not initialized".into()) } } }; @@ -172,13 +290,9 @@ impl AddressState { if message.number % 60 == 0 { let span = info_span!("address_state.tick", number = message.number); async { - let guard = tick_history.lock().await; - if let Some(state) = guard.current() { - if let Err(e) = state.tick() { - error!("Tick error: {e}"); - } - } else { - info!("no state yet"); + let state = state_tick.lock().await; + if let Err(e) = state.tick() { + error!("Tick error: {e}"); } } .instrument(span) @@ -195,11 +309,23 @@ impl AddressState { None }; + let params_sub = if let Some(topic) = ¶ms_subscribe_topic { + Some(context.subscribe(topic).await?) + } else { + None + }; + // Start run task context.run(async move { - Self::run(history_run, address_deltas_sub, storage_config) - .await - .unwrap_or_else(|e| error!("Failed: {e}")); + Self::run( + state_run, + address_deltas_sub, + params_sub, + persist_epoch, + store_run, + ) + .await + .unwrap_or_else(|e| error!("Failed: {e}")); }); Ok(()) diff --git a/modules/address_state/src/address_store.rs b/modules/address_state/src/address_store.rs new file mode 100644 index 00000000..22fe31bb --- /dev/null +++ b/modules/address_state/src/address_store.rs @@ -0,0 +1,30 @@ +use std::collections::HashMap; + +use acropolis_common::{Address, AddressTotals, TxIdentifier, UTxOIdentifier}; +use anyhow::Result; +use async_trait::async_trait; +use fjall::Partition; + +use crate::state::{AddressEntry, AddressStorageConfig}; + +#[async_trait] +pub trait AddressStore: Send + Sync { + async fn get_utxos(&self, address: &Address) -> Result>>; + async fn get_txs(&self, address: &Address) -> Result>>; + async fn get_totals(&self, address: &Address) -> Result>; + + async fn persist_epoch( + &self, + epoch: u64, + drained_blocks: Vec>, + config: &AddressStorageConfig, + ) -> Result<()>; + + async fn get_last_epoch_stored(&self) -> Result>; + async fn epoch_exists( + &self, + partition: Partition, + key: &'static [u8], + epoch: u64, + ) -> Result; +} diff --git a/modules/address_state/src/fjall_immutable_address_store.rs b/modules/address_state/src/fjall_immutable_address_store.rs new file mode 100644 index 00000000..dc247b88 --- /dev/null +++ b/modules/address_state/src/fjall_immutable_address_store.rs @@ -0,0 +1,253 @@ +use std::{ + collections::{HashMap, HashSet}, + path::Path, +}; + +use crate::{ + address_store::AddressStore, + state::{AddressEntry, AddressStorageConfig, UtxoDelta}, +}; +use acropolis_common::{Address, AddressTotals, TxIdentifier, UTxOIdentifier}; +use anyhow::Result; +use async_trait::async_trait; +use fjall::{Keyspace, Partition, PartitionCreateOptions}; +use minicbor::{decode, to_vec}; +use tokio::task; +use tracing::info; + +// Metadata keys which store the last epoch saved in each partition +const ADDRESS_UTXOS_EPOCH_COUNTER: &[u8] = b"utxos_epoch_last"; +const ADDRESS_TXS_EPOCH_COUNTER: &[u8] = b"txs_epoch_last"; +const ADDRESS_TOTALS_EPOCH_COUNTER: &[u8] = b"totals_epoch_last"; + +pub struct FjallImmutableAddressStore { + utxos: Partition, + txs: Partition, + totals: Partition, + keyspace: Keyspace, +} + +impl FjallImmutableAddressStore { + pub fn new(path: impl AsRef) -> Result { + let cfg = fjall::Config::new(path); + let keyspace = Keyspace::open(cfg)?; + + let utxos = keyspace.open_partition("address_utxos", PartitionCreateOptions::default())?; + let txs = keyspace.open_partition("address_txs", PartitionCreateOptions::default())?; + let totals = + keyspace.open_partition("address_totals", PartitionCreateOptions::default())?; + + Ok(Self { + utxos, + txs, + totals, + keyspace, + }) + } +} + +#[async_trait] +impl AddressStore for FjallImmutableAddressStore { + async fn persist_epoch( + &self, + epoch: u64, + drained_blocks: Vec>, + config: &AddressStorageConfig, + ) -> Result<()> { + let persist_utxos = config.store_info + && !self.epoch_exists(self.utxos.clone(), ADDRESS_UTXOS_EPOCH_COUNTER, epoch).await?; + let persist_txs = config.store_transactions + && !self.epoch_exists(self.txs.clone(), ADDRESS_TXS_EPOCH_COUNTER, epoch).await?; + let persist_totals = config.store_totals + && !self.epoch_exists(self.totals.clone(), ADDRESS_TOTALS_EPOCH_COUNTER, epoch).await?; + + if !(persist_utxos || persist_txs || persist_totals) { + return Ok(()); + } + + let keyspace = self.keyspace.clone(); + let utxos = self.utxos.clone(); + let txs = self.txs.clone(); + let totals = self.totals.clone(); + + task::spawn_blocking(move || { + let mut batch = keyspace.batch(); + + for block_map in drained_blocks { + for (addr, entry) in block_map { + let addr_key = addr.to_bytes_key()?; + + if persist_utxos { + let mut live: HashSet = match utxos.get(&addr_key)? { + Some(bytes) => decode(&bytes)?, + None => HashSet::new(), + }; + + if let Some(deltas) = &entry.utxos { + for delta in deltas { + match delta { + UtxoDelta::Created(u) => { + live.insert(*u); + } + UtxoDelta::Spent(u) => { + live.remove(u); + } + } + } + } + + batch.insert(&utxos, &addr_key, to_vec(&live)?); + } + + if persist_txs { + let mut live: Vec = match txs.get(&addr_key)? { + Some(bytes) => decode(&bytes)?, + None => Vec::new(), + }; + + if let Some(txs_deltas) = &entry.transactions { + live.extend(txs_deltas.iter().cloned()); + } + + batch.insert(&txs, &addr_key, to_vec(&live)?); + } + + if persist_totals { + let mut live: AddressTotals = match totals.get(&addr_key)? { + Some(bytes) => decode(&bytes)?, + None => AddressTotals::default(), + }; + + if let Some(deltas) = &entry.totals { + for delta in deltas { + live.apply_delta(delta); + } + } + + batch.insert(&totals, &addr_key, to_vec(&live)?); + } + } + } + + // Metadata markers + if persist_utxos { + batch.insert(&utxos, ADDRESS_UTXOS_EPOCH_COUNTER, &epoch.to_le_bytes()); + } + if persist_txs { + batch.insert(&txs, ADDRESS_TXS_EPOCH_COUNTER, &epoch.to_le_bytes()); + } + if persist_totals { + batch.insert(&totals, ADDRESS_TOTALS_EPOCH_COUNTER, &epoch.to_le_bytes()); + } + + batch.commit()?; + Ok::<_, anyhow::Error>(()) + }) + .await??; + + Ok(()) + } + + async fn get_utxos(&self, address: &Address) -> Result>> { + let key = address.to_bytes_key()?; + let partition = self.utxos.clone(); + task::spawn_blocking(move || match partition.get(key)? { + Some(bytes) => { + let decoded: Vec = decode(&bytes)?; + Ok(Some(decoded)) + } + None => Ok(None), + }) + .await? + } + + async fn get_txs(&self, address: &Address) -> Result>> { + let key = address.to_bytes_key()?; + let partition = self.txs.clone(); + task::spawn_blocking(move || match partition.get(key)? { + Some(bytes) => { + let decoded: Vec = decode(&bytes)?; + Ok(Some(decoded)) + } + None => Ok(None), + }) + .await? + } + + async fn get_totals(&self, address: &Address) -> Result> { + let key = address.to_bytes_key()?; + let partition = self.totals.clone(); + task::spawn_blocking(move || match partition.get(key)? { + Some(bytes) => { + let decoded: AddressTotals = decode(&bytes)?; + Ok(Some(decoded)) + } + None => Ok(None), + }) + .await? + } + + async fn get_last_epoch_stored(&self) -> Result> { + let read_marker = |partition: Partition, key: &'static [u8]| async move { + task::spawn_blocking(move || { + Ok::<_, anyhow::Error>(match partition.get(key)? { + Some(bytes) if bytes.len() == 8 => { + let mut arr = [0u8; 8]; + arr.copy_from_slice(&bytes); + let val = u64::from_le_bytes(arr); + if val == u64::MAX { + None + } else { + Some(val) + } + } + _ => None, + }) + }) + .await? + }; + + let u = read_marker(self.utxos.clone(), ADDRESS_UTXOS_EPOCH_COUNTER).await?; + let t = read_marker(self.txs.clone(), ADDRESS_TXS_EPOCH_COUNTER).await?; + let tot = read_marker(self.totals.clone(), ADDRESS_TOTALS_EPOCH_COUNTER).await?; + + let min_epoch = [u, t, tot].into_iter().flatten().min(); + + if let Some(epoch) = min_epoch { + info!("last epoch already stored across partitions: {epoch}"); + } else { + info!("no epoch markers found across partitions"); + } + + Ok(min_epoch) + } + + async fn epoch_exists( + &self, + partition: Partition, + key: &'static [u8], + epoch: u64, + ) -> Result { + let result = task::spawn_blocking(move || { + Ok::<_, anyhow::Error>(match partition.get(key)? { + Some(bytes) if bytes.len() == 8 => { + let mut arr = [0u8; 8]; + arr.copy_from_slice(&bytes); + let last_epoch = u64::from_le_bytes(arr); + epoch <= last_epoch + } + _ => false, + }) + }) + .await??; + + if result { + match std::str::from_utf8(key) { + Ok(s) => info!("epoch {epoch} already stored for {s}"), + Err(_) => info!("epoch {epoch} already stored for key {:?}", key), + } + } + + Ok(result) + } +} diff --git a/modules/address_state/src/state.rs b/modules/address_state/src/state.rs index 74c498f4..1f647c5a 100644 --- a/modules/address_state/src/state.rs +++ b/modules/address_state/src/state.rs @@ -1,6 +1,11 @@ -use acropolis_common::{Address, AddressDelta, AddressTotalsEntry, TxIdentifier, UTxOIdentifier}; +use std::collections::HashSet; + +use acropolis_common::{ + Address, AddressDelta, AddressTotals, TxIdentifier, UTxOIdentifier, ValueDelta, +}; use anyhow::Result; -use imbl::{HashMap, Vector}; + +use crate::{address_store::AddressStore, volatile_index::VolatileIndex}; #[derive(Debug, Default, Clone, Copy)] pub struct AddressStorageConfig { @@ -15,75 +20,137 @@ impl AddressStorageConfig { } } +#[derive(Debug, Clone, PartialEq, Eq, Hash, minicbor::Encode, minicbor::Decode)] +pub enum UtxoDelta { + #[n(0)] + Created(#[n(0)] UTxOIdentifier), + #[n(1)] + Spent(#[n(0)] UTxOIdentifier), +} + #[derive(Debug, Default, Clone)] pub struct AddressEntry { - pub utxos: Option>, - pub transactions: Option>, - pub totals: Option, + pub utxos: Option>, + pub transactions: Option>, + pub totals: Option>, } #[derive(Debug, Default, Clone)] pub struct State { pub config: AddressStorageConfig, - - pub addresses: Option>, + pub volatile_entries: VolatileIndex, } impl State { pub fn new(config: AddressStorageConfig) -> Self { Self { config, - addresses: if config.any_enabled() { - Some(HashMap::new()) - } else { - None - }, + volatile_entries: VolatileIndex::default(), } } - pub fn get_address_utxos(&self, address: &Address) -> Result>> { + pub async fn get_address_utxos( + &self, + store: &dyn AddressStore, + address: &Address, + ) -> Result>> { if !self.config.store_info { - anyhow::bail!("address info storage disabled in config"); + return Err(anyhow::anyhow!("address info storage disabled in config")); + } + + let mut combined: HashSet = match store.get_utxos(address).await? { + Some(db) => db.into_iter().collect(), + None => HashSet::new(), + }; + + for map in self.volatile_entries.window.iter() { + if let Some(entry) = map.get(address) { + if let Some(deltas) = &entry.utxos { + for delta in deltas { + match delta { + UtxoDelta::Created(u) => { + combined.insert(*u); + } + UtxoDelta::Spent(u) => { + combined.remove(u); + } + } + } + } + } + } + + if combined.is_empty() { + Ok(None) + } else { + Ok(Some(combined.into_iter().collect())) + } + } + + pub async fn get_address_transactions( + &self, + store: &dyn AddressStore, + address: &Address, + ) -> Result>> { + if !self.config.store_transactions { + return Err(anyhow::anyhow!( + "address transactions storage disabled in config" + )); } - Ok(self - .addresses - .as_ref() - .and_then(|map| map.get(address)) - .and_then(|entry| entry.utxos.as_ref()) - .map(|m| m.keys().cloned().collect())) + let mut combined: Vec = match store.get_txs(address).await? { + Some(db) => db, + None => Vec::new(), + }; + + for map in self.volatile_entries.window.iter() { + if let Some(entry) = map.get(address) { + if let Some(txs) = &entry.transactions { + combined.extend(txs.iter().cloned()); + } + } + } + + if combined.is_empty() { + Ok(None) + } else { + Ok(Some(combined)) + } } - pub fn get_address_totals(&self, address: &Address) -> Result { + pub async fn get_address_totals( + &self, + store: &dyn AddressStore, + address: &Address, + ) -> Result { if !self.config.store_totals { anyhow::bail!("address totals storage disabled in config"); } - self.addresses - .as_ref() - .and_then(|map| map.get(address)) - .and_then(|entry| entry.totals.clone()) - .ok_or_else(|| anyhow::anyhow!("address not initialized in totals map")) - } + let mut totals = match store.get_totals(address).await? { + Some(db) => db, + None => AddressTotals::default(), + }; - pub fn get_address_transactions(&self, address: &Address) -> Result>> { - if !self.config.store_transactions { - anyhow::bail!("address transactions storage disabled in config"); + for map in self.volatile_entries.window.iter() { + if let Some(entry) = map.get(address) { + if let Some(address_deltas) = &entry.totals { + for delta in address_deltas { + totals.apply_delta(delta); + } + } + } } - Ok(self - .addresses - .as_ref() - .and_then(|map| map.get(address)) - .and_then(|entry| entry.transactions.as_ref()) - .map(|v| v.iter().cloned().collect())) + Ok(totals) } pub fn tick(&self) -> Result<()> { - let count = self.addresses.as_ref().map(|m| m.len()).unwrap_or(0); + let count: usize = + self.volatile_entries.window.iter().map(|block_map| block_map.len()).sum(); if count != 0 { - tracing::info!("Tracking {} addresses", count); + tracing::info!("Tracking {} volatile addresses", count); } else { tracing::info!("address_state storage disabled in config"); } @@ -94,32 +161,36 @@ impl State { pub fn handle_address_deltas(&self, deltas: &[AddressDelta]) -> Result { let mut new_state = self.clone(); - let Some(addresses) = new_state.addresses.as_mut() else { - return Ok(new_state); - }; + // Always work on the most recent block in the window + let addresses = new_state + .volatile_entries + .window + .back_mut() + .expect("next_block() must be called before handle_address_deltas"); for delta in deltas { let entry = addresses.entry(delta.address.clone()).or_default(); if self.config.store_info { - let utxos = entry.utxos.get_or_insert_with(HashMap::new); + let utxos = entry.utxos.get_or_insert(Vec::new()); if delta.value.lovelace > 0 { - utxos.insert(delta.utxo, ()); + utxos.push(UtxoDelta::Created(delta.utxo)); } else { - utxos.remove(&delta.utxo); + utxos.push(UtxoDelta::Spent(delta.utxo)); } } if self.config.store_transactions { - let transactions = entry.transactions.get_or_insert_with(Vector::new); - - let tx_id = delta.utxo.to_tx_identifier(); + let txs = entry.transactions.get_or_insert(Vec::new()); + txs.push(delta.utxo.to_tx_identifier()) + } - if transactions.last() != Some(&tx_id) { - transactions.push_back(tx_id); - } + if self.config.store_totals { + let totals = entry.totals.get_or_insert(Vec::new()); + totals.push(delta.value.clone()); } } + Ok(new_state) } } diff --git a/modules/address_state/src/volatile_index.rs b/modules/address_state/src/volatile_index.rs new file mode 100644 index 00000000..2b78253c --- /dev/null +++ b/modules/address_state/src/volatile_index.rs @@ -0,0 +1,80 @@ +use std::collections::{HashMap, VecDeque}; + +use acropolis_common::Address; +use anyhow::Result; + +use crate::{ + address_store::AddressStore, + state::{AddressEntry, AddressStorageConfig}, +}; + +#[derive(Debug, Clone)] +pub struct VolatileIndex { + pub window: VecDeque>, + pub start_block: u64, + pub epoch_start_block: u64, + pub last_persisted_epoch: Option, + pub security_param_k: u64, +} + +impl Default for VolatileIndex { + fn default() -> Self { + Self::new() + } +} + +impl VolatileIndex { + pub fn new() -> Self { + VolatileIndex { + window: VecDeque::new(), + start_block: 0, + epoch_start_block: 0, + last_persisted_epoch: None, + security_param_k: 0, + } + } + + pub fn update_k(&mut self, k: u32) { + self.security_param_k = k as u64; + } + + pub fn next_block(&mut self) { + self.window.push_back(HashMap::new()); + } + + pub fn start_new_epoch(&mut self, block_number: u64) { + self.epoch_start_block = block_number; + } + + pub fn rollback_before(&mut self, block: u64) -> Vec<(Address, AddressEntry)> { + let mut out = Vec::new(); + + while self.start_block + self.window.len() as u64 > block { + if let Some(map) = self.window.pop_back() { + out.extend(map.into_iter()); + } else { + break; + } + } + out + } +} + +impl VolatileIndex { + pub async fn persist_all( + &mut self, + store: &dyn AddressStore, + config: &AddressStorageConfig, + ) -> Result<()> { + let epoch = self.last_persisted_epoch.map(|e| e + 1).unwrap_or(0); + let blocks_to_drain = (self.epoch_start_block - self.start_block) as usize; + + let drained: Vec<_> = self.window.drain(..blocks_to_drain).collect(); + store.persist_epoch(epoch, drained, config).await?; + + self.start_block += blocks_to_drain as u64; + self.last_persisted_epoch = Some(epoch); + + Ok(()) + } +} diff --git a/processes/omnibus/data/address_state/journals/0 b/processes/omnibus/data/address_state/journals/0 deleted file mode 100644 index c4f0fc3faa443e00bd81a536efeb50007fe4ae8b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 232 zcmZQ%U|?VbVklr@;7v?PDM~FajxQ~#$S-CPiH9)aQw#EwGvec|auSP6fQmW(Ljm*Y zVsORw` Date: Mon, 6 Oct 2025 18:41:01 +0000 Subject: [PATCH 06/18] fix: cleanup address state Signed-off-by: William Hankins --- Cargo.lock | 1 + modules/address_state/Cargo.toml | 3 + modules/address_state/src/address_state.rs | 68 ++++++++++++++++-- modules/address_state/src/address_store.rs | 11 +-- .../src/fjall_immutable_address_store.rs | 13 ++-- modules/address_state/src/state.rs | 2 +- .../omnibus/data/address_state/journals/0 | Bin 0 -> 94136 bytes .../partitions/address_totals/config | Bin 0 -> 30 bytes .../partitions/address_totals/levels | Bin 0 -> 33 bytes .../partitions/address_totals/manifest | Bin 0 -> 7 bytes .../partitions/address_txs/config | Bin 0 -> 30 bytes .../partitions/address_txs/levels | Bin 0 -> 33 bytes .../partitions/address_txs/manifest | Bin 0 -> 7 bytes .../partitions/address_utxos/config | Bin 0 -> 30 bytes .../partitions/address_utxos/levels | Bin 0 -> 33 bytes .../partitions/address_utxos/manifest | Bin 0 -> 7 bytes processes/omnibus/data/address_state/version | 1 + 17 files changed, 78 insertions(+), 21 deletions(-) create mode 100644 processes/omnibus/data/address_state/journals/0 create mode 100644 processes/omnibus/data/address_state/partitions/address_totals/config create mode 100644 processes/omnibus/data/address_state/partitions/address_totals/levels create mode 100644 processes/omnibus/data/address_state/partitions/address_totals/manifest create mode 100644 processes/omnibus/data/address_state/partitions/address_txs/config create mode 100644 processes/omnibus/data/address_state/partitions/address_txs/levels create mode 100644 processes/omnibus/data/address_state/partitions/address_txs/manifest create mode 100644 processes/omnibus/data/address_state/partitions/address_utxos/config create mode 100644 processes/omnibus/data/address_state/partitions/address_utxos/levels create mode 100644 processes/omnibus/data/address_state/partitions/address_utxos/manifest create mode 100644 processes/omnibus/data/address_state/version diff --git a/Cargo.lock b/Cargo.lock index 66605c8f..0ba9e781 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -69,6 +69,7 @@ dependencies = [ "imbl", "minicbor 0.26.5", "serde_cbor", + "tempfile", "tokio", "tracing", ] diff --git a/modules/address_state/Cargo.toml b/modules/address_state/Cargo.toml index 210ae9b5..e6e96bb1 100644 --- a/modules/address_state/Cargo.toml +++ b/modules/address_state/Cargo.toml @@ -24,5 +24,8 @@ serde_cbor = "0.11" tokio = { workspace = true } tracing = { workspace = true } +[dev-dependencies] +tempfile = "3" + [lib] path = "src/address_state.rs" diff --git a/modules/address_state/src/address_state.rs b/modules/address_state/src/address_state.rs index 58d59a7e..47f359b2 100644 --- a/modules/address_state/src/address_state.rs +++ b/modules/address_state/src/address_state.rs @@ -218,10 +218,6 @@ impl AddressState { (None, None) }; - match persist_epoch { - Some(epoch) => info!("Persist epoch marker found: {}", epoch), - None => info!("No persist epoch marker found in store"), - } let query_store = store.clone(); let store_run = store.clone(); @@ -331,3 +327,67 @@ impl AddressState { Ok(()) } } + +#[cfg(test)] +mod tests { + use crate::state::{AddressEntry, UtxoDelta}; + + use super::*; + use acropolis_common::{Address, TxIdentifier, UTxOIdentifier}; + use std::collections::HashMap; + use tempfile::tempdir; + + fn dummy_address() -> Address { + Address::from_string("DdzFFzCqrht7fNAHwdou7iXPJ5NZrssAH53yoRMUtF9t6momHH52EAxM5KmqDwhrjT7QsHjbMPJUBywmzAgmF4hj2h9eKj4U6Ahandyy").unwrap() + } + + #[tokio::test] + async fn test_persist_and_read_epoch() -> Result<()> { + let tmpdir = tempdir().unwrap(); + let store = FjallImmutableAddressStore::new(tmpdir.path())?; + + let addr = dummy_address(); + + let mut entry = AddressEntry::default(); + entry.utxos = Some(vec![ + UtxoDelta::Created(UTxOIdentifier::new(0, 0, 0)), + UtxoDelta::Created(UTxOIdentifier::new(0, 1, 0)), + ]); + entry.transactions = Some(vec![TxIdentifier::new(0, 0)]); + entry.totals = Some(Default::default()); + + let mut block = HashMap::new(); + block.insert(addr.clone(), entry); + + let drained_blocks = vec![block]; + + let config = AddressStorageConfig { + store_info: true, + store_transactions: true, + store_totals: true, + }; + + // Persist epoch 1 + store.persist_epoch(1, drained_blocks, &config).await?; + + // Assert we can read back UTxOs + let utxos = store.get_utxos(&addr)?; + assert!(utxos.is_some()); + assert_eq!(utxos.unwrap().len(), 2); + + // Assert we can read back Txs + let txs = store.get_txs(&addr).await?; + assert!(txs.is_some()); + assert_eq!(txs.unwrap().len(), 1); + + // Assert totals exists + let totals = store.get_totals(&addr).await?; + assert!(totals.is_some()); + + // Assert epoch marker written + let last_epoch = store.get_last_epoch_stored().await?; + assert_eq!(last_epoch, Some(1)); + + Ok(()) + } +} diff --git a/modules/address_state/src/address_store.rs b/modules/address_state/src/address_store.rs index 22fe31bb..5fdc8e24 100644 --- a/modules/address_state/src/address_store.rs +++ b/modules/address_state/src/address_store.rs @@ -3,13 +3,12 @@ use std::collections::HashMap; use acropolis_common::{Address, AddressTotals, TxIdentifier, UTxOIdentifier}; use anyhow::Result; use async_trait::async_trait; -use fjall::Partition; use crate::state::{AddressEntry, AddressStorageConfig}; #[async_trait] pub trait AddressStore: Send + Sync { - async fn get_utxos(&self, address: &Address) -> Result>>; + fn get_utxos(&self, address: &Address) -> Result>>; async fn get_txs(&self, address: &Address) -> Result>>; async fn get_totals(&self, address: &Address) -> Result>; @@ -19,12 +18,4 @@ pub trait AddressStore: Send + Sync { drained_blocks: Vec>, config: &AddressStorageConfig, ) -> Result<()>; - - async fn get_last_epoch_stored(&self) -> Result>; - async fn epoch_exists( - &self, - partition: Partition, - key: &'static [u8], - epoch: u64, - ) -> Result; } diff --git a/modules/address_state/src/fjall_immutable_address_store.rs b/modules/address_state/src/fjall_immutable_address_store.rs index dc247b88..9b36de38 100644 --- a/modules/address_state/src/fjall_immutable_address_store.rs +++ b/modules/address_state/src/fjall_immutable_address_store.rs @@ -148,17 +148,16 @@ impl AddressStore for FjallImmutableAddressStore { Ok(()) } - async fn get_utxos(&self, address: &Address) -> Result>> { + fn get_utxos(&self, address: &Address) -> Result>> { let key = address.to_bytes_key()?; - let partition = self.utxos.clone(); - task::spawn_blocking(move || match partition.get(key)? { + info!("searching for {}", hex::encode(&key)); + match self.utxos.get(key)? { Some(bytes) => { let decoded: Vec = decode(&bytes)?; Ok(Some(decoded)) } None => Ok(None), - }) - .await? + } } async fn get_txs(&self, address: &Address) -> Result>> { @@ -186,8 +185,10 @@ impl AddressStore for FjallImmutableAddressStore { }) .await? } +} - async fn get_last_epoch_stored(&self) -> Result> { +impl FjallImmutableAddressStore { + pub async fn get_last_epoch_stored(&self) -> Result> { let read_marker = |partition: Partition, key: &'static [u8]| async move { task::spawn_blocking(move || { Ok::<_, anyhow::Error>(match partition.get(key)? { diff --git a/modules/address_state/src/state.rs b/modules/address_state/src/state.rs index 1f647c5a..1df412a4 100644 --- a/modules/address_state/src/state.rs +++ b/modules/address_state/src/state.rs @@ -58,7 +58,7 @@ impl State { return Err(anyhow::anyhow!("address info storage disabled in config")); } - let mut combined: HashSet = match store.get_utxos(address).await? { + let mut combined: HashSet = match store.get_utxos(address)? { Some(db) => db.into_iter().collect(), None => HashSet::new(), }; diff --git a/processes/omnibus/data/address_state/journals/0 b/processes/omnibus/data/address_state/journals/0 new file mode 100644 index 0000000000000000000000000000000000000000..aac2e8f7f5c35ddd9f45234b4b7f7103f63a7f0e GIT binary patch literal 94136 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 Date: Tue, 7 Oct 2025 21:55:11 +0000 Subject: [PATCH 07/18] feat: add address info REST handler Signed-off-by: William Hankins --- Cargo.lock | 1 + common/src/address.rs | 50 ++++++++ common/src/messages.rs | 7 +- common/src/queries/mod.rs | 1 + common/src/queries/utxos.rs | 18 +++ common/src/types.rs | 26 +++- modules/address_state/Cargo.toml | 1 + modules/address_state/src/address_state.rs | 97 ++++++-------- .../src/fjall_immutable_address_store.rs | 25 +++- modules/address_state/src/state.rs | 24 +--- modules/address_state/src/volatile_index.rs | 5 +- .../rest_blockfrost/src/handlers/addresses.rs | 119 ++++++++++++------ .../rest_blockfrost/src/handlers_config.rs | 7 ++ modules/rest_blockfrost/src/types.rs | 10 ++ modules/utxo_state/src/state.rs | 19 +++ modules/utxo_state/src/utxo_state.rs | 35 +++++- .../omnibus/data/address_state/journals/0 | Bin 94136 -> 0 bytes .../partitions/address_totals/config | Bin 30 -> 0 bytes .../partitions/address_totals/levels | Bin 33 -> 0 bytes .../partitions/address_totals/manifest | Bin 7 -> 0 bytes .../partitions/address_txs/config | Bin 30 -> 0 bytes .../partitions/address_txs/levels | Bin 33 -> 0 bytes .../partitions/address_txs/manifest | Bin 7 -> 0 bytes .../partitions/address_utxos/config | Bin 30 -> 0 bytes .../partitions/address_utxos/levels | Bin 33 -> 0 bytes .../partitions/address_utxos/manifest | Bin 7 -> 0 bytes processes/omnibus/data/address_state/version | 1 - 27 files changed, 315 insertions(+), 131 deletions(-) create mode 100644 common/src/queries/utxos.rs delete mode 100644 processes/omnibus/data/address_state/journals/0 delete mode 100644 processes/omnibus/data/address_state/partitions/address_totals/config delete mode 100644 processes/omnibus/data/address_state/partitions/address_totals/levels delete mode 100644 processes/omnibus/data/address_state/partitions/address_totals/manifest delete mode 100644 processes/omnibus/data/address_state/partitions/address_txs/config delete mode 100644 processes/omnibus/data/address_state/partitions/address_txs/levels delete mode 100644 processes/omnibus/data/address_state/partitions/address_txs/manifest delete mode 100644 processes/omnibus/data/address_state/partitions/address_utxos/config delete mode 100644 processes/omnibus/data/address_state/partitions/address_utxos/levels delete mode 100644 processes/omnibus/data/address_state/partitions/address_utxos/manifest delete mode 100644 processes/omnibus/data/address_state/version diff --git a/Cargo.lock b/Cargo.lock index 0ba9e781..aedb1fed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -72,6 +72,7 @@ dependencies = [ "tempfile", "tokio", "tracing", + "tracing-subscriber", ] [[package]] diff --git a/common/src/address.rs b/common/src/address.rs index 97466a52..053ee9ff 100644 --- a/common/src/address.rs +++ b/common/src/address.rs @@ -283,6 +283,33 @@ impl ShelleyAddress { Ok(data) } + + pub fn stake_address_string(&self) -> Result> { + let network_bit = match self.network { + AddressNetwork::Main => 1, + AddressNetwork::Test => 0, + }; + + match &self.delegation { + ShelleyAddressDelegationPart::StakeKeyHash(key_hash) => { + let mut data = Vec::with_capacity(29); + data.push(network_bit | (0b1110 << 4)); + data.extend_from_slice(key_hash); + let stake = StakeAddress::from_binary(&data)?.to_string()?; + Ok(Some(stake)) + } + ShelleyAddressDelegationPart::ScriptHash(script_hash) => { + let mut data = Vec::with_capacity(29); + data.push(network_bit | (0b1111 << 4)); + data.extend_from_slice(script_hash); + let stake = StakeAddress::from_binary(&data)?.to_string()?; + Ok(Some(stake)) + } + // TODO: Use chain store to resolve pointer delegation addresses + ShelleyAddressDelegationPart::Pointer(_pointer) => Ok(None), + ShelleyAddressDelegationPart::None => Ok(None), + } + } } /// Payload of a stake address @@ -465,6 +492,29 @@ impl Address { Address::None => Err(anyhow!("No address to convert")), } } + + pub fn kind(&self) -> &'static str { + match self { + Address::Byron(_) => "byron", + Address::Shelley(_) => "shelley", + Address::Stake(_) => "stake", + Address::None => "none", + } + } + + pub fn is_script(&self) -> bool { + match self { + Address::Shelley(shelley) => match shelley.payment { + ShelleyAddressPaymentPart::PaymentKeyHash(_) => false, + ShelleyAddressPaymentPart::ScriptHash(_) => true, + }, + Address::Stake(stake) => match stake.payload { + StakeAddressPayload::StakeKeyHash(_) => false, + StakeAddressPayload::ScriptHash(_) => true, + }, + Address::Byron(_) | Address::None => false, + } + } } // -- Tests -- diff --git a/common/src/messages.rs b/common/src/messages.rs index 922b7140..82d11bfe 100644 --- a/common/src/messages.rs +++ b/common/src/messages.rs @@ -7,6 +7,7 @@ use crate::genesis_values::GenesisValues; use crate::ledger_state::SPOState; use crate::protocol_params::{NonceHash, ProtocolParams}; use crate::queries::parameters::{ParametersStateQuery, ParametersStateQueryResponse}; +use crate::queries::utxos::{UTxOStateQuery, UTxOStateQueryResponse}; use crate::queries::{ accounts::{AccountsStateQuery, AccountsStateQueryResponse}, addresses::{AddressStateQuery, AddressStateQueryResponse}, @@ -362,10 +363,11 @@ pub enum StateQuery { Mempool(MempoolStateQuery), Metadata(MetadataStateQuery), Network(NetworkStateQuery), + Parameters(ParametersStateQuery), Pools(PoolsStateQuery), Scripts(ScriptsStateQuery), Transactions(TransactionsStateQuery), - Parameters(ParametersStateQuery), + UTxOs(UTxOStateQuery), } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] @@ -380,8 +382,9 @@ pub enum StateQueryResponse { Mempool(MempoolStateQueryResponse), Metadata(MetadataStateQueryResponse), Network(NetworkStateQueryResponse), + Parameters(ParametersStateQueryResponse), Pools(PoolsStateQueryResponse), Scripts(ScriptsStateQueryResponse), Transactions(TransactionsStateQueryResponse), - Parameters(ParametersStateQueryResponse), + UTxOs(UTxOStateQueryResponse), } diff --git a/common/src/queries/mod.rs b/common/src/queries/mod.rs index da1beb83..cf401159 100644 --- a/common/src/queries/mod.rs +++ b/common/src/queries/mod.rs @@ -17,6 +17,7 @@ pub mod pools; pub mod scripts; pub mod transactions; pub mod utils; +pub mod utxos; pub fn get_query_topic(context: Arc>, topic: (&str, &str)) -> String { context.config.get_string(topic.0).unwrap_or_else(|_| topic.1.to_string()) diff --git a/common/src/queries/utxos.rs b/common/src/queries/utxos.rs new file mode 100644 index 00000000..bb75c4c0 --- /dev/null +++ b/common/src/queries/utxos.rs @@ -0,0 +1,18 @@ +use crate::{UTxOIdentifier, Value}; + +pub const DEFAULT_UTXOS_QUERY_TOPIC: (&str, &str) = + ("utxo-state-query-topic", "cardano.query.utxos"); + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub enum UTxOStateQuery { + GetUTxOsSum { + utxo_identifiers: Vec, + }, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub enum UTxOStateQueryResponse { + UTxOsSum(Value), + NotFound, + Error(String), +} diff --git a/common/src/types.rs b/common/src/types.rs index 89b8cd10..f6b1e192 100644 --- a/common/src/types.rs +++ b/common/src/types.rs @@ -15,7 +15,7 @@ use serde::{Deserialize, Serialize}; use serde_with::{hex::Hex, serde_as}; use std::collections::{HashMap, HashSet}; use std::fmt::{Display, Formatter}; -use std::ops::Neg; +use std::ops::{AddAssign, Neg}; use std::{cmp::Ordering, fmt}; #[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -255,6 +255,30 @@ impl Value { } } +impl AddAssign<&Value> for Value { + fn add_assign(&mut self, other: &Value) { + self.lovelace += other.lovelace; + + for (policy_id, other_assets) in &other.assets { + if let Some((_, existing_assets)) = + self.assets.iter_mut().find(|(pid, _)| pid == policy_id) + { + for other_asset in other_assets { + if let Some(existing) = + existing_assets.iter_mut().find(|a| a.name == other_asset.name) + { + existing.amount += other_asset.amount; + } else { + existing_assets.push(other_asset.clone()); + } + } + } else { + self.assets.push((*policy_id, other_assets.clone())); + } + } + } +} + /// Hashmap representation of Value (lovelace + multiasset) #[derive( Debug, Default, Clone, serde::Serialize, serde::Deserialize, minicbor::Encode, minicbor::Decode, diff --git a/modules/address_state/Cargo.toml b/modules/address_state/Cargo.toml index e6e96bb1..081da56a 100644 --- a/modules/address_state/Cargo.toml +++ b/modules/address_state/Cargo.toml @@ -26,6 +26,7 @@ tracing = { workspace = true } [dev-dependencies] tempfile = "3" +tracing-subscriber = { version = "0.3", features = ["fmt"] } [lib] path = "src/address_state.rs" diff --git a/modules/address_state/src/address_state.rs b/modules/address_state/src/address_state.rs index 47f359b2..55d9975e 100644 --- a/modules/address_state/src/address_state.rs +++ b/modules/address_state/src/address_state.rs @@ -15,7 +15,7 @@ use anyhow::Result; use caryatid_sdk::{module, Context, Module, Subscription}; use config::Config; use tokio::sync::Mutex; -use tracing::{error, info, info_span, Instrument}; +use tracing::{error, info}; use crate::{ address_store::AddressStore, @@ -70,7 +70,6 @@ impl AddressState { Message::Cardano((ref block_info, _)) => { if block_info.status == BlockStatus::RolledBack { state.volatile_entries.rollback_before(block_info.number); - } else { state.volatile_entries.next_block(); } current_block = Some(block_info.clone()); @@ -136,6 +135,8 @@ impl AddressState { } other => error!("Unexpected message on utxo-deltas subscription: {other:?}"), } + + state.volatile_entries.next_block(); } } } @@ -199,7 +200,6 @@ impl AddressState { let state = Arc::new(Mutex::new(State::new(storage_config))); let state_run = state.clone(); let state_query = state.clone(); - let state_tick = state.clone(); // Initialize Fjall store let (store, persist_epoch): (Option>, Option) = @@ -275,29 +275,6 @@ impl AddressState { } }); - // Ticker to log stats - let mut subscription = context.subscribe("clock.tick").await?; - context.run(async move { - loop { - let Ok((_, message)) = subscription.read().await else { - return; - }; - if let Message::Clock(message) = message.as_ref() { - if message.number % 60 == 0 { - let span = info_span!("address_state.tick", number = message.number); - async { - let state = state_tick.lock().await; - if let Err(e) = state.tick() { - error!("Tick error: {e}"); - } - } - .instrument(span) - .await; - } - } - } - }); - // Subscribe to enabled topics let address_deltas_sub = if let Some(topic) = &address_deltas_subscribe_topic { Some(context.subscribe(topic).await?) @@ -330,11 +307,8 @@ impl AddressState { #[cfg(test)] mod tests { - use crate::state::{AddressEntry, UtxoDelta}; - use super::*; - use acropolis_common::{Address, TxIdentifier, UTxOIdentifier}; - use std::collections::HashMap; + use acropolis_common::{Address, AddressDelta, UTxOIdentifier, ValueDelta}; use tempfile::tempdir; fn dummy_address() -> Address { @@ -342,51 +316,54 @@ mod tests { } #[tokio::test] - async fn test_persist_and_read_epoch() -> Result<()> { - let tmpdir = tempdir().unwrap(); - let store = FjallImmutableAddressStore::new(tmpdir.path())?; - - let addr = dummy_address(); - - let mut entry = AddressEntry::default(); - entry.utxos = Some(vec![ - UtxoDelta::Created(UTxOIdentifier::new(0, 0, 0)), - UtxoDelta::Created(UTxOIdentifier::new(0, 1, 0)), - ]); - entry.transactions = Some(vec![TxIdentifier::new(0, 0)]); - entry.totals = Some(Default::default()); + async fn test_persist_all_and_read_back() -> Result<()> { + let _ = tracing_subscriber::fmt::try_init(); - let mut block = HashMap::new(); - block.insert(addr.clone(), entry); - - let drained_blocks = vec![block]; + // Temp store directory + let tmpdir = tempdir().unwrap(); + let store = Arc::new(FjallImmutableAddressStore::new(tmpdir.path())?); + // Config: store everything let config = AddressStorageConfig { store_info: true, store_transactions: true, store_totals: true, }; - // Persist epoch 1 - store.persist_epoch(1, drained_blocks, &config).await?; - - // Assert we can read back UTxOs + // Build fake delta + let addr = dummy_address(); + let deltas = vec![AddressDelta { + address: addr.clone(), + utxo: UTxOIdentifier::new(0, 0, 0), + value: ValueDelta { + lovelace: 1, + assets: Vec::new(), + }, + }]; + + // Create a ready state + let mut state = State::new(config.clone()); + state.volatile_entries.epoch_start_block = 1; + + // Apply deltas + state.handle_address_deltas(&deltas)?; + + // Persist everything + state.volatile_entries.persist_all(store.as_ref(), &config).await?; + + // Verify persisted UTxOs let utxos = store.get_utxos(&addr)?; assert!(utxos.is_some()); - assert_eq!(utxos.unwrap().len(), 2); - - // Assert we can read back Txs - let txs = store.get_txs(&addr).await?; - assert!(txs.is_some()); - assert_eq!(txs.unwrap().len(), 1); + assert_eq!(utxos.as_ref().unwrap().len(), 1); + assert_eq!(utxos.as_ref().unwrap()[0], UTxOIdentifier::new(0, 0, 0)); - // Assert totals exists + // Totals should exist let totals = store.get_totals(&addr).await?; assert!(totals.is_some()); - // Assert epoch marker written + // Epoch marker advanced let last_epoch = store.get_last_epoch_stored().await?; - assert_eq!(last_epoch, Some(1)); + assert_eq!(last_epoch, Some(0)); Ok(()) } diff --git a/modules/address_state/src/fjall_immutable_address_store.rs b/modules/address_state/src/fjall_immutable_address_store.rs index 9b36de38..84bdaecd 100644 --- a/modules/address_state/src/fjall_immutable_address_store.rs +++ b/modules/address_state/src/fjall_immutable_address_store.rs @@ -29,7 +29,7 @@ pub struct FjallImmutableAddressStore { impl FjallImmutableAddressStore { pub fn new(path: impl AsRef) -> Result { - let cfg = fjall::Config::new(path); + let cfg = fjall::Config::new(path).max_write_buffer_size(512 * 1024 * 1024); let keyspace = Keyspace::open(cfg)?; let utxos = keyspace.open_partition("address_utxos", PartitionCreateOptions::default())?; @@ -62,6 +62,7 @@ impl AddressStore for FjallImmutableAddressStore { && !self.epoch_exists(self.totals.clone(), ADDRESS_TOTALS_EPOCH_COUNTER, epoch).await?; if !(persist_utxos || persist_txs || persist_totals) { + tracing::debug!("skipping epoch {} (already persisted or disabled)", epoch); return Ok(()); } @@ -72,9 +73,14 @@ impl AddressStore for FjallImmutableAddressStore { task::spawn_blocking(move || { let mut batch = keyspace.batch(); + let mut change_count = 0; + for block_map in drained_blocks.into_iter() { + if block_map.is_empty() { + continue; + } - for block_map in drained_blocks { for (addr, entry) in block_map { + change_count += 1; let addr_key = addr.to_bytes_key()?; if persist_utxos { @@ -140,8 +146,19 @@ impl AddressStore for FjallImmutableAddressStore { batch.insert(&totals, ADDRESS_TOTALS_EPOCH_COUNTER, &epoch.to_le_bytes()); } - batch.commit()?; - Ok::<_, anyhow::Error>(()) + match batch.commit() { + Ok(_) => { + tracing::info!( + "address_state: wrote {} address changes to Fjall.", + change_count, + ); + Ok::<_, anyhow::Error>(()) + } + Err(e) => { + tracing::error!("address_state: failed to commit batch: {}", e); + Err(e.into()) + } + } }) .await??; diff --git a/modules/address_state/src/state.rs b/modules/address_state/src/state.rs index 1df412a4..1224ad42 100644 --- a/modules/address_state/src/state.rs +++ b/modules/address_state/src/state.rs @@ -145,24 +145,8 @@ impl State { Ok(totals) } - pub fn tick(&self) -> Result<()> { - let count: usize = - self.volatile_entries.window.iter().map(|block_map| block_map.len()).sum(); - - if count != 0 { - tracing::info!("Tracking {} volatile addresses", count); - } else { - tracing::info!("address_state storage disabled in config"); - } - - Ok(()) - } - - pub fn handle_address_deltas(&self, deltas: &[AddressDelta]) -> Result { - let mut new_state = self.clone(); - - // Always work on the most recent block in the window - let addresses = new_state + pub fn handle_address_deltas(&mut self, deltas: &[AddressDelta]) -> Result<()> { + let addresses = self .volatile_entries .window .back_mut() @@ -182,7 +166,7 @@ impl State { if self.config.store_transactions { let txs = entry.transactions.get_or_insert(Vec::new()); - txs.push(delta.utxo.to_tx_identifier()) + txs.push(TxIdentifier::from(delta.utxo)) } if self.config.store_totals { @@ -191,6 +175,6 @@ impl State { } } - Ok(new_state) + Ok(()) } } diff --git a/modules/address_state/src/volatile_index.rs b/modules/address_state/src/volatile_index.rs index 2b78253c..1c86d6dc 100644 --- a/modules/address_state/src/volatile_index.rs +++ b/modules/address_state/src/volatile_index.rs @@ -25,8 +25,11 @@ impl Default for VolatileIndex { impl VolatileIndex { pub fn new() -> Self { + let mut window = VecDeque::new(); + window.push_back(HashMap::new()); + VolatileIndex { - window: VecDeque::new(), + window, start_block: 0, epoch_start_block: 0, last_persisted_epoch: None, diff --git a/modules/rest_blockfrost/src/handlers/addresses.rs b/modules/rest_blockfrost/src/handlers/addresses.rs index 429c3778..29b792c4 100644 --- a/modules/rest_blockfrost/src/handlers/addresses.rs +++ b/modules/rest_blockfrost/src/handlers/addresses.rs @@ -6,26 +6,44 @@ use acropolis_common::{ queries::{ addresses::{AddressStateQuery, AddressStateQueryResponse}, utils::query_state, + utxos::{UTxOStateQuery, UTxOStateQueryResponse}, }, Address, }; use caryatid_sdk::Context; -use crate::handlers_config::HandlersConfig; +use crate::{handlers_config::HandlersConfig, types::AddressInfoREST}; pub async fn handle_address_single_blockfrost( context: Arc>, params: Vec, handlers_config: Arc, ) -> Result { - let address = match Address::from_string(¶ms[0]) { - Ok(Address::None) => { + let [address_str] = ¶ms[..] else { + return Ok(RESTResponse::with_text(400, "Missing address parameter")); + }; + + let (address, stake_address) = match Address::from_string(address_str) { + Ok(Address::None) | Ok(Address::Stake(_)) => { return Ok(RESTResponse::with_text( 400, - &format!("Invalid address '{}'", params[0]), + &format!("Invalid address '{address_str}'"), )); } - Ok(address) => address, + Ok(Address::Byron(byron)) => (Address::Byron(byron), None), + Ok(Address::Shelley(shelley)) => { + let stake_addr = match shelley.stake_address_string() { + Ok(stake_addr) => stake_addr, + Err(e) => { + return Ok(RESTResponse::with_text( + 400, + &format!("Invalid address '{address_str}': {e}"), + )); + } + }; + + (Address::Shelley(shelley), stake_addr) + } Err(e) => { return Ok(RESTResponse::with_text( 400, @@ -34,58 +52,77 @@ pub async fn handle_address_single_blockfrost( } }; + let address_type = address.kind().to_string(); + let is_script = address.is_script(); + let address_query_msg = Arc::new(Message::StateQuery(StateQuery::Addresses( AddressStateQuery::GetAddressUTxOs { address }, ))); - let response = query_state( + let utxo_identifiers = match query_state( &context, &handlers_config.addresses_query_topic, address_query_msg, |message| match message { Message::StateQueryResponse(StateQueryResponse::Addresses( - AddressStateQueryResponse::AddressUTxOs(utxos), - )) => { - let rest_utxos: Vec = utxos - .iter() - .map(|entry| { - format!( - "{}:{}:{}", - entry.block_number(), - entry.tx_index(), - entry.output_index() - ) - }) - .collect(); - - match serde_json::to_string_pretty(&rest_utxos) { - Ok(json) => Ok(RESTResponse::with_json(200, &json)), - Err(e) => Ok(RESTResponse::with_text( - 500, - &format!("Failed to serialize UTxOs: {e}"), - )), - } - } + AddressStateQueryResponse::AddressUTxOs(utxo_identifiers), + )) => Ok(utxo_identifiers), Message::StateQueryResponse(StateQueryResponse::Addresses( AddressStateQueryResponse::NotFound, - )) => Ok(RESTResponse::with_text(404, "Address not found")), + )) => Err(anyhow::anyhow!("Address not found")), Message::StateQueryResponse(StateQueryResponse::Addresses( AddressStateQueryResponse::Error(_), - )) => Ok(RESTResponse::with_text( - 501, - "Addresses info storage is disabled in config", - )), - _ => Ok(RESTResponse::with_text( - 500, - "Unexpected response while retrieving address info", - )), + )) => Err(anyhow::anyhow!("Address info storage disabled")), + _ => Err(anyhow::anyhow!("Unexpected response")), + }, + ) + .await + { + Ok(utxo_identifiers) => utxo_identifiers, + Err(e) => return Ok(RESTResponse::with_text(500, &format!("Query failed: {e}"))), + }; + + let utxos_query_msg = Arc::new(Message::StateQuery(StateQuery::UTxOs( + UTxOStateQuery::GetUTxOsSum { utxo_identifiers }, + ))); + + let address_balance = match query_state( + &context, + &handlers_config.utxos_query_topic, + utxos_query_msg, + |message| match message { + Message::StateQueryResponse(StateQueryResponse::UTxOs( + UTxOStateQueryResponse::UTxOsSum(balance), + )) => Ok(balance), + Message::StateQueryResponse(StateQueryResponse::UTxOs( + UTxOStateQueryResponse::NotFound, + )) => Err(anyhow::anyhow!("UTxOs not found")), + Message::StateQueryResponse(StateQueryResponse::UTxOs( + UTxOStateQueryResponse::Error(e), + )) => Err(anyhow::anyhow!(format!("UTxO query error: {e}"))), + _ => Err(anyhow::anyhow!("Unexpected response")), }, ) - .await; + .await + { + Ok(address_balance) => address_balance, + Err(e) => return Ok(RESTResponse::with_text(500, &format!("Query failed: {e}"))), + }; + + let rest_response = AddressInfoREST { + address: address_str.to_string(), + amount: address_balance, + stake_address, + address_type, + script: is_script, + }; - match response { - Ok(rest) => Ok(rest), - Err(e) => Ok(RESTResponse::with_text(500, &format!("Query failed: {e}"))), + match serde_json::to_string(&rest_response) { + Ok(json) => Ok(RESTResponse::with_json(200, &json)), + Err(e) => Ok(RESTResponse::with_text( + 500, + &format!("Internal server error while retrieving address info: {e}"), + )), } } diff --git a/modules/rest_blockfrost/src/handlers_config.rs b/modules/rest_blockfrost/src/handlers_config.rs index c038a725..7ff100f1 100644 --- a/modules/rest_blockfrost/src/handlers_config.rs +++ b/modules/rest_blockfrost/src/handlers_config.rs @@ -8,6 +8,7 @@ use acropolis_common::queries::{ governance::{DEFAULT_DREPS_QUERY_TOPIC, DEFAULT_GOVERNANCE_QUERY_TOPIC}, parameters::DEFAULT_PARAMETERS_QUERY_TOPIC, pools::DEFAULT_POOLS_QUERY_TOPIC, + utxos::DEFAULT_UTXOS_QUERY_TOPIC, }; use config::Config; @@ -23,6 +24,7 @@ pub struct HandlersConfig { pub governance_query_topic: String, pub epochs_query_topic: String, pub parameters_query_topic: String, + pub utxos_query_topic: String, pub external_api_timeout: u64, pub offchain_token_registry_url: String, } @@ -61,6 +63,10 @@ impl From> for HandlersConfig { .get_string(DEFAULT_PARAMETERS_QUERY_TOPIC.0) .unwrap_or(DEFAULT_PARAMETERS_QUERY_TOPIC.1.to_string()); + let utxos_query_topic = config + .get_string(DEFAULT_UTXOS_QUERY_TOPIC.0) + .unwrap_or(DEFAULT_UTXOS_QUERY_TOPIC.1.to_string()); + let external_api_timeout = config .get_int(DEFAULT_EXTERNAL_API_TIMEOUT.0) .unwrap_or(DEFAULT_EXTERNAL_API_TIMEOUT.1) as u64; @@ -78,6 +84,7 @@ impl From> for HandlersConfig { governance_query_topic, epochs_query_topic, parameters_query_topic, + utxos_query_topic, external_api_timeout, offchain_token_registry_url, } diff --git a/modules/rest_blockfrost/src/types.rs b/modules/rest_blockfrost/src/types.rs index f8deaadd..fb031ed7 100644 --- a/modules/rest_blockfrost/src/types.rs +++ b/modules/rest_blockfrost/src/types.rs @@ -779,3 +779,13 @@ impl TryFrom<&AssetAddressEntry> for AssetAddressRest { }) } } + +#[derive(Debug, Serialize)] +pub struct AddressInfoREST { + pub address: String, + pub amount: acropolis_common::Value, + pub stake_address: Option, + #[serde(rename = "type")] + pub address_type: String, + pub script: bool, +} diff --git a/modules/utxo_state/src/state.rs b/modules/utxo_state/src/state.rs index 1f3767aa..ff9bed32 100644 --- a/modules/utxo_state/src/state.rs +++ b/modules/utxo_state/src/state.rs @@ -91,6 +91,25 @@ impl State { } } + /// Get the total value of multiple utxos + pub async fn get_utxos_sum(&self, utxo_identifiers: &Vec) -> Result { + let mut balance = Value::new(0, Vec::new()); + for identifier in utxo_identifiers { + match self.lookup_utxo(&identifier).await { + Ok(Some(utxo)) => balance += &utxo.value, + Ok(None) => return Err(anyhow::anyhow!("UTxO {} does not exist", identifier)), + Err(e) => { + return Err(anyhow::anyhow!( + "Failed to look up UTxO {}: {}", + identifier, + e + )); + } + } + } + Ok(balance) + } + /// Register the delta observer pub fn register_address_delta_observer(&mut self, observer: Arc) { self.address_delta_observer = Some(observer); diff --git a/modules/utxo_state/src/utxo_state.rs b/modules/utxo_state/src/utxo_state.rs index 193c2443..842f2458 100644 --- a/modules/utxo_state/src/utxo_state.rs +++ b/modules/utxo_state/src/utxo_state.rs @@ -1,7 +1,10 @@ //! Acropolis UTXO state module for Caryatid //! Accepts UTXO events and derives the current ledger state in memory -use acropolis_common::messages::{CardanoMessage, Message}; +use acropolis_common::{ + messages::{CardanoMessage, Message, StateQuery, StateQueryResponse}, + queries::utxos::{UTxOStateQuery, UTxOStateQueryResponse, DEFAULT_UTXOS_QUERY_TOPIC}, +}; use caryatid_sdk::{module, Context, Module}; use anyhow::{anyhow, Result}; @@ -50,6 +53,10 @@ impl UTXOState { config.get_string("subscribe-topic").unwrap_or(DEFAULT_SUBSCRIBE_TOPIC.to_string()); info!("Creating subscriber on '{subscribe_topic}'"); + let utxos_query_topic = config + .get_string(DEFAULT_UTXOS_QUERY_TOPIC.0) + .unwrap_or(DEFAULT_UTXOS_QUERY_TOPIC.1.to_string()); + // Create store let store_type = config.get_string("store").unwrap_or(DEFAULT_STORE.to_string()); let store: Arc = match store_type.as_str() { @@ -98,6 +105,32 @@ impl UTXOState { } }); + // Query handler + let state_query = state.clone(); + context.handle(&utxos_query_topic, move |message| { + let state_mutex = state_query.clone(); + async move { + let Message::StateQuery(StateQuery::UTxOs(query)) = message.as_ref() else { + return Arc::new(Message::StateQueryResponse(StateQueryResponse::UTxOs( + UTxOStateQueryResponse::Error("Invalid message for utxo-state".into()), + ))); + }; + + let state = state_mutex.lock().await; + let response = match query { + UTxOStateQuery::GetUTxOsSum { utxo_identifiers } => { + match state.get_utxos_sum(utxo_identifiers).await { + Ok(balance) => UTxOStateQueryResponse::UTxOsSum(balance), + Err(e) => UTxOStateQueryResponse::Error(e.to_string()), + } + } + }; + Arc::new(Message::StateQueryResponse(StateQueryResponse::UTxOs( + response, + ))) + } + }); + // Ticker to log stats and prune state let state2 = state.clone(); let mut subscription = context.subscribe("clock.tick").await?; diff --git a/processes/omnibus/data/address_state/journals/0 b/processes/omnibus/data/address_state/journals/0 deleted file mode 100644 index aac2e8f7f5c35ddd9f45234b4b7f7103f63a7f0e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 94136 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 Date: Tue, 7 Oct 2025 23:32:00 +0000 Subject: [PATCH 08/18] test: add coverage for address_state UTxOs (creation, persistence, removal) Signed-off-by: William Hankins --- modules/address_state/src/address_state.rs | 138 ++++++++++++++++++--- 1 file changed, 118 insertions(+), 20 deletions(-) diff --git a/modules/address_state/src/address_state.rs b/modules/address_state/src/address_state.rs index 55d9975e..bcd926d1 100644 --- a/modules/address_state/src/address_state.rs +++ b/modules/address_state/src/address_state.rs @@ -315,35 +315,44 @@ mod tests { Address::from_string("DdzFFzCqrht7fNAHwdou7iXPJ5NZrssAH53yoRMUtF9t6momHH52EAxM5KmqDwhrjT7QsHjbMPJUBywmzAgmF4hj2h9eKj4U6Ahandyy").unwrap() } - #[tokio::test] - async fn test_persist_all_and_read_back() -> Result<()> { - let _ = tracing_subscriber::fmt::try_init(); - - // Temp store directory - let tmpdir = tempdir().unwrap(); - let store = Arc::new(FjallImmutableAddressStore::new(tmpdir.path())?); - - // Config: store everything - let config = AddressStorageConfig { + fn test_config() -> AddressStorageConfig { + AddressStorageConfig { store_info: true, store_transactions: true, store_totals: true, - }; + } + } - // Build fake delta - let addr = dummy_address(); - let deltas = vec![AddressDelta { + async fn setup_state_and_store() -> Result<(Arc, State)> { + let tmpdir = tempdir().unwrap(); + let store = Arc::new(FjallImmutableAddressStore::new(tmpdir.path())?); + let config = test_config(); + let mut state = State::new(config.clone()); + state.volatile_entries.epoch_start_block = 1; + Ok((store, state)) + } + + fn delta(addr: &Address, utxo: &UTxOIdentifier, lovelace: i64) -> AddressDelta { + AddressDelta { address: addr.clone(), - utxo: UTxOIdentifier::new(0, 0, 0), + utxo: utxo.clone(), value: ValueDelta { - lovelace: 1, + lovelace, assets: Vec::new(), }, - }]; + } + } - // Create a ready state - let mut state = State::new(config.clone()); - state.volatile_entries.epoch_start_block = 1; + #[tokio::test] + async fn test_persist_all_and_read_back() -> Result<()> { + let _ = tracing_subscriber::fmt::try_init(); + + let (store, mut state) = setup_state_and_store().await?; + let config = test_config(); + + let addr = dummy_address(); + let utxo = UTxOIdentifier::new(0, 0, 0); + let deltas = vec![delta(&addr, &utxo, 1)]; // Apply deltas state.handle_address_deltas(&deltas)?; @@ -367,4 +376,93 @@ mod tests { Ok(()) } + + #[tokio::test] + async fn test_utxo_removed_when_spent() -> Result<()> { + let _ = tracing_subscriber::fmt::try_init(); + + let (store, mut state) = setup_state_and_store().await?; + let config = test_config(); + + let addr = dummy_address(); + let utxo = UTxOIdentifier::new(0, 0, 0); + + // Before processing + assert!( + state.get_address_utxos(store.as_ref(), &addr).await?.is_none(), + "Expected no UTxOs before creation" + ); + + let created = vec![delta(&addr, &utxo, 1)]; + + state.handle_address_deltas(&created)?; + + // After processing creation + let after_create = state.get_address_utxos(store.as_ref(), &addr).await?; + assert_eq!(after_create.as_ref().unwrap(), &[utxo]); + + state.volatile_entries.persist_all(store.as_ref(), &config).await?; + + // After persisting creation + let after_persist = state.get_address_utxos(store.as_ref(), &addr).await?; + assert_eq!(after_persist.as_ref().unwrap(), &[utxo]); + + state.volatile_entries.next_block(); + state.volatile_entries.epoch_start_block = 2; + state.handle_address_deltas(&[delta(&addr, &utxo, -1)])?; + + // After processing spend + let after_spend_volatile = state.get_address_utxos(store.as_ref(), &addr).await?; + assert!(after_spend_volatile.as_ref().map_or(true, |u| u.is_empty())); + + state.volatile_entries.persist_all(store.as_ref(), &config).await?; + + // After persisting spend + let after_spend_disk = state.get_address_utxos(store.as_ref(), &addr).await?; + assert!(after_spend_disk.as_ref().map_or(true, |u| u.is_empty())); + + Ok(()) + } + + #[tokio::test] + async fn test_utxo_spent_and_created_across_blocks_in_volatile() -> Result<()> { + let _ = tracing_subscriber::fmt::try_init(); + + let (store, mut state) = setup_state_and_store().await?; + let config = test_config(); + + let addr = dummy_address(); + let utxo_old = UTxOIdentifier::new(0, 0, 0); + let utxo_new = UTxOIdentifier::new(0, 1, 0); + + state.volatile_entries.epoch_start_block = 1; + + state.handle_address_deltas(&[delta(&addr, &utxo_old, 1)])?; + state.volatile_entries.next_block(); + state.handle_address_deltas(&[delta(&addr, &utxo_old, -1), delta(&addr, &utxo_new, 1)])?; + + // Create and spend both in volatile is not included in address utxos + let volatile = state.get_address_utxos(store.as_ref(), &addr).await?; + assert!( + volatile.as_ref().is_some_and(|u| u.contains(&utxo_new) && !u.contains(&utxo_old)), + "Expected only new UTxO {:?} in volatile view, got {:?}", + utxo_new, + volatile + ); + + state.volatile_entries.persist_all(store.as_ref(), &config).await?; + + // UTxO not persisted to disk if created and spent in pruned volatile window + let persisted_view = state.get_address_utxos(store.as_ref(), &addr).await?; + assert!( + persisted_view + .as_ref() + .is_some_and(|u| u.contains(&utxo_new) && !u.contains(&utxo_old)), + "Expected only new UTxO {:?} after persistence, got {:?}", + utxo_new, + persisted_view + ); + + Ok(()) + } } From e9f840abb180c36951b18d8eeeb657140149fc2e Mon Sep 17 00:00:00 2001 From: William Hankins Date: Wed, 8 Oct 2025 15:56:23 +0000 Subject: [PATCH 09/18] fix: properly filter spends vs creations in address deltas message Signed-off-by: William Hankins --- modules/address_state/src/state.rs | 7 ++----- modules/rest_blockfrost/src/handlers/addresses.rs | 2 +- modules/utxo_state/src/state.rs | 2 +- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/modules/address_state/src/state.rs b/modules/address_state/src/state.rs index 1224ad42..472da3d8 100644 --- a/modules/address_state/src/state.rs +++ b/modules/address_state/src/state.rs @@ -146,11 +146,8 @@ impl State { } pub fn handle_address_deltas(&mut self, deltas: &[AddressDelta]) -> Result<()> { - let addresses = self - .volatile_entries - .window - .back_mut() - .expect("next_block() must be called before handle_address_deltas"); + let addresses = + self.volatile_entries.window.back_mut().expect("window should never be empty"); for delta in deltas { let entry = addresses.entry(delta.address.clone()).or_default(); diff --git a/modules/rest_blockfrost/src/handlers/addresses.rs b/modules/rest_blockfrost/src/handlers/addresses.rs index 29b792c4..52ea13db 100644 --- a/modules/rest_blockfrost/src/handlers/addresses.rs +++ b/modules/rest_blockfrost/src/handlers/addresses.rs @@ -117,7 +117,7 @@ pub async fn handle_address_single_blockfrost( script: is_script, }; - match serde_json::to_string(&rest_response) { + match serde_json::to_string_pretty(&rest_response) { Ok(json) => Ok(RESTResponse::with_json(200, &json)), Err(e) => Ok(RESTResponse::with_text( 500, diff --git a/modules/utxo_state/src/state.rs b/modules/utxo_state/src/state.rs index ff9bed32..e2692709 100644 --- a/modules/utxo_state/src/state.rs +++ b/modules/utxo_state/src/state.rs @@ -222,7 +222,7 @@ impl State { obs.observe_delta(&AddressDelta { address: utxo.address.clone(), utxo: key.clone(), - value: ValueDelta::from(&utxo.value), + value: -ValueDelta::from(&utxo.value), }) .await; } From 33b17c6bbbe9064ad732ea5f12f1e8840c9b0128 Mon Sep 17 00:00:00 2001 From: William Hankins Date: Wed, 8 Oct 2025 17:20:01 +0000 Subject: [PATCH 10/18] refactor: remove AddressStore trait and use Fjall as the only store Signed-off-by: William Hankins --- modules/address_state/src/address_state.rs | 65 +++++++++---------- modules/address_state/src/address_store.rs | 21 ------ ...ss_store.rs => immutable_address_store.rs} | 23 ++----- modules/address_state/src/state.rs | 23 +++---- ...olatile_index.rs => volatile_addresses.rs} | 14 ++-- 5 files changed, 54 insertions(+), 92 deletions(-) delete mode 100644 modules/address_state/src/address_store.rs rename modules/address_state/src/{fjall_immutable_address_store.rs => immutable_address_store.rs} (93%) rename modules/address_state/src/{volatile_index.rs => volatile_addresses.rs} (90%) diff --git a/modules/address_state/src/address_state.rs b/modules/address_state/src/address_state.rs index bcd926d1..32ec108a 100644 --- a/modules/address_state/src/address_state.rs +++ b/modules/address_state/src/address_state.rs @@ -18,14 +18,12 @@ use tokio::sync::Mutex; use tracing::{error, info}; use crate::{ - address_store::AddressStore, + immutable_address_store::ImmutableAddressStore, state::{AddressStorageConfig, State}, }; -mod address_store; -mod fjall_immutable_address_store; -use fjall_immutable_address_store::FjallImmutableAddressStore; +mod immutable_address_store; mod state; -mod volatile_index; +mod volatile_addresses; // Subscription topics const DEFAULT_ADDRESS_DELTAS_SUBSCRIBE_TOPIC: (&str, &str) = @@ -52,7 +50,7 @@ impl AddressState { mut address_deltas_subscription: Option>>, mut params_subscription: Option>>, persist_epoch: Option, - store: Option>, + store: Option>, ) -> Result<()> { if let Some(sub) = params_subscription.as_mut() { let _ = sub.read().await?; @@ -69,8 +67,8 @@ impl AddressState { let new_epoch = match deltas_msg.as_ref() { Message::Cardano((ref block_info, _)) => { if block_info.status == BlockStatus::RolledBack { - state.volatile_entries.rollback_before(block_info.number); - state.volatile_entries.next_block(); + state.volatile.rollback_before(block_info.number); + state.volatile.next_block(); } current_block = Some(block_info.clone()); block_info.new_epoch && block_info.epoch > 0 @@ -87,9 +85,9 @@ impl AddressState { )) = message.as_ref() { Self::check_sync(¤t_block, &block_info, "params"); - state.volatile_entries.start_new_epoch(block_info.number); + state.volatile.start_new_epoch(block_info.number); if let Some(shelley) = ¶ms.params.shelley { - state.volatile_entries.update_k(shelley.security_param); + state.volatile.update_k(shelley.security_param); } } } @@ -114,18 +112,16 @@ impl AddressState { if block_info.epoch > 0 { // Compute the safe_block at which the previous epoch can be removed from volatile - let safe_block = state.volatile_entries.epoch_start_block - + state.volatile_entries.security_param_k; + let safe_block = + state.volatile.epoch_start_block + state.volatile.security_param_k; // Persist to disk and prune from volatile when block number exceeds safe block if block_info.number > safe_block { - if Some(block_info.epoch) - != state.volatile_entries.last_persisted_epoch - { + if Some(block_info.epoch) != state.volatile.last_persisted_epoch { if let Some(address_store) = &store { let config = state.config.clone(); state - .volatile_entries + .volatile .persist_all(address_store.as_ref(), &config) .await?; } @@ -136,7 +132,7 @@ impl AddressState { other => error!("Unexpected message on utxo-deltas subscription: {other:?}"), } - state.volatile_entries.next_block(); + state.volatile.next_block(); } } } @@ -202,18 +198,15 @@ impl AddressState { let state_query = state.clone(); // Initialize Fjall store - let (store, persist_epoch): (Option>, Option) = + let (store, persist_epoch): (Option>, Option) = if storage_config.any_enabled() { let path = config .get_string("address_state.path") .unwrap_or_else(|_| "./data/address_state".to_string()); - let store = FjallImmutableAddressStore::new(path)?; + let store = ImmutableAddressStore::new(path)?; let persist_after = store.get_last_epoch_stored().await?; - ( - Some(Arc::new(store) as Arc), - persist_after, - ) + (Some(Arc::new(store)), persist_after) } else { (None, None) }; @@ -236,7 +229,7 @@ impl AddressState { let response = match query { AddressStateQuery::GetAddressUTxOs { address } => { if let Some(ref s) = store { - match state.get_address_utxos(s.as_ref(), &address).await { + match state.get_address_utxos(s, &address).await { Ok(Some(utxos)) => AddressStateQueryResponse::AddressUTxOs(utxos), Ok(None) => AddressStateQueryResponse::NotFound, Err(e) => AddressStateQueryResponse::Error(e.to_string()), @@ -247,7 +240,7 @@ impl AddressState { } AddressStateQuery::GetAddressTransactions { address } => { if let Some(ref s) = store { - match state.get_address_transactions(s.as_ref(), &address).await { + match state.get_address_transactions(s, &address).await { Ok(Some(txs)) => { AddressStateQueryResponse::AddressTransactions(txs) } @@ -323,12 +316,12 @@ mod tests { } } - async fn setup_state_and_store() -> Result<(Arc, State)> { + async fn setup_state_and_store() -> Result<(Arc, State)> { let tmpdir = tempdir().unwrap(); - let store = Arc::new(FjallImmutableAddressStore::new(tmpdir.path())?); + let store = Arc::new(ImmutableAddressStore::new(tmpdir.path())?); let config = test_config(); let mut state = State::new(config.clone()); - state.volatile_entries.epoch_start_block = 1; + state.volatile.epoch_start_block = 1; Ok((store, state)) } @@ -358,7 +351,7 @@ mod tests { state.handle_address_deltas(&deltas)?; // Persist everything - state.volatile_entries.persist_all(store.as_ref(), &config).await?; + state.volatile.persist_all(store.as_ref(), &config).await?; // Verify persisted UTxOs let utxos = store.get_utxos(&addr)?; @@ -401,21 +394,21 @@ mod tests { let after_create = state.get_address_utxos(store.as_ref(), &addr).await?; assert_eq!(after_create.as_ref().unwrap(), &[utxo]); - state.volatile_entries.persist_all(store.as_ref(), &config).await?; + state.volatile.persist_all(store.as_ref(), &config).await?; // After persisting creation let after_persist = state.get_address_utxos(store.as_ref(), &addr).await?; assert_eq!(after_persist.as_ref().unwrap(), &[utxo]); - state.volatile_entries.next_block(); - state.volatile_entries.epoch_start_block = 2; + state.volatile.next_block(); + state.volatile.epoch_start_block = 2; state.handle_address_deltas(&[delta(&addr, &utxo, -1)])?; // After processing spend let after_spend_volatile = state.get_address_utxos(store.as_ref(), &addr).await?; assert!(after_spend_volatile.as_ref().map_or(true, |u| u.is_empty())); - state.volatile_entries.persist_all(store.as_ref(), &config).await?; + state.volatile.persist_all(store.as_ref(), &config).await?; // After persisting spend let after_spend_disk = state.get_address_utxos(store.as_ref(), &addr).await?; @@ -435,10 +428,10 @@ mod tests { let utxo_old = UTxOIdentifier::new(0, 0, 0); let utxo_new = UTxOIdentifier::new(0, 1, 0); - state.volatile_entries.epoch_start_block = 1; + state.volatile.epoch_start_block = 1; state.handle_address_deltas(&[delta(&addr, &utxo_old, 1)])?; - state.volatile_entries.next_block(); + state.volatile.next_block(); state.handle_address_deltas(&[delta(&addr, &utxo_old, -1), delta(&addr, &utxo_new, 1)])?; // Create and spend both in volatile is not included in address utxos @@ -450,7 +443,7 @@ mod tests { volatile ); - state.volatile_entries.persist_all(store.as_ref(), &config).await?; + state.volatile.persist_all(store.as_ref(), &config).await?; // UTxO not persisted to disk if created and spent in pruned volatile window let persisted_view = state.get_address_utxos(store.as_ref(), &addr).await?; diff --git a/modules/address_state/src/address_store.rs b/modules/address_state/src/address_store.rs deleted file mode 100644 index 5fdc8e24..00000000 --- a/modules/address_state/src/address_store.rs +++ /dev/null @@ -1,21 +0,0 @@ -use std::collections::HashMap; - -use acropolis_common::{Address, AddressTotals, TxIdentifier, UTxOIdentifier}; -use anyhow::Result; -use async_trait::async_trait; - -use crate::state::{AddressEntry, AddressStorageConfig}; - -#[async_trait] -pub trait AddressStore: Send + Sync { - fn get_utxos(&self, address: &Address) -> Result>>; - async fn get_txs(&self, address: &Address) -> Result>>; - async fn get_totals(&self, address: &Address) -> Result>; - - async fn persist_epoch( - &self, - epoch: u64, - drained_blocks: Vec>, - config: &AddressStorageConfig, - ) -> Result<()>; -} diff --git a/modules/address_state/src/fjall_immutable_address_store.rs b/modules/address_state/src/immutable_address_store.rs similarity index 93% rename from modules/address_state/src/fjall_immutable_address_store.rs rename to modules/address_state/src/immutable_address_store.rs index 84bdaecd..35a8f348 100644 --- a/modules/address_state/src/fjall_immutable_address_store.rs +++ b/modules/address_state/src/immutable_address_store.rs @@ -3,13 +3,9 @@ use std::{ path::Path, }; -use crate::{ - address_store::AddressStore, - state::{AddressEntry, AddressStorageConfig, UtxoDelta}, -}; +use crate::state::{AddressEntry, AddressStorageConfig, UtxoDelta}; use acropolis_common::{Address, AddressTotals, TxIdentifier, UTxOIdentifier}; use anyhow::Result; -use async_trait::async_trait; use fjall::{Keyspace, Partition, PartitionCreateOptions}; use minicbor::{decode, to_vec}; use tokio::task; @@ -20,14 +16,14 @@ const ADDRESS_UTXOS_EPOCH_COUNTER: &[u8] = b"utxos_epoch_last"; const ADDRESS_TXS_EPOCH_COUNTER: &[u8] = b"txs_epoch_last"; const ADDRESS_TOTALS_EPOCH_COUNTER: &[u8] = b"totals_epoch_last"; -pub struct FjallImmutableAddressStore { +pub struct ImmutableAddressStore { utxos: Partition, txs: Partition, totals: Partition, keyspace: Keyspace, } -impl FjallImmutableAddressStore { +impl ImmutableAddressStore { pub fn new(path: impl AsRef) -> Result { let cfg = fjall::Config::new(path).max_write_buffer_size(512 * 1024 * 1024); let keyspace = Keyspace::open(cfg)?; @@ -44,11 +40,8 @@ impl FjallImmutableAddressStore { keyspace, }) } -} -#[async_trait] -impl AddressStore for FjallImmutableAddressStore { - async fn persist_epoch( + pub async fn persist_epoch( &self, epoch: u64, drained_blocks: Vec>, @@ -165,7 +158,7 @@ impl AddressStore for FjallImmutableAddressStore { Ok(()) } - fn get_utxos(&self, address: &Address) -> Result>> { + pub fn get_utxos(&self, address: &Address) -> Result>> { let key = address.to_bytes_key()?; info!("searching for {}", hex::encode(&key)); match self.utxos.get(key)? { @@ -177,7 +170,7 @@ impl AddressStore for FjallImmutableAddressStore { } } - async fn get_txs(&self, address: &Address) -> Result>> { + pub async fn get_txs(&self, address: &Address) -> Result>> { let key = address.to_bytes_key()?; let partition = self.txs.clone(); task::spawn_blocking(move || match partition.get(key)? { @@ -190,7 +183,7 @@ impl AddressStore for FjallImmutableAddressStore { .await? } - async fn get_totals(&self, address: &Address) -> Result> { + pub async fn get_totals(&self, address: &Address) -> Result> { let key = address.to_bytes_key()?; let partition = self.totals.clone(); task::spawn_blocking(move || match partition.get(key)? { @@ -202,9 +195,7 @@ impl AddressStore for FjallImmutableAddressStore { }) .await? } -} -impl FjallImmutableAddressStore { pub async fn get_last_epoch_stored(&self) -> Result> { let read_marker = |partition: Partition, key: &'static [u8]| async move { task::spawn_blocking(move || { diff --git a/modules/address_state/src/state.rs b/modules/address_state/src/state.rs index 472da3d8..0b9c2792 100644 --- a/modules/address_state/src/state.rs +++ b/modules/address_state/src/state.rs @@ -5,7 +5,9 @@ use acropolis_common::{ }; use anyhow::Result; -use crate::{address_store::AddressStore, volatile_index::VolatileIndex}; +use crate::{ + immutable_address_store::ImmutableAddressStore, volatile_addresses::VolatileAddresses, +}; #[derive(Debug, Default, Clone, Copy)] pub struct AddressStorageConfig { @@ -38,20 +40,20 @@ pub struct AddressEntry { #[derive(Debug, Default, Clone)] pub struct State { pub config: AddressStorageConfig, - pub volatile_entries: VolatileIndex, + pub volatile: VolatileAddresses, } impl State { pub fn new(config: AddressStorageConfig) -> Self { Self { config, - volatile_entries: VolatileIndex::default(), + volatile: VolatileAddresses::default(), } } pub async fn get_address_utxos( &self, - store: &dyn AddressStore, + store: &ImmutableAddressStore, address: &Address, ) -> Result>> { if !self.config.store_info { @@ -63,7 +65,7 @@ impl State { None => HashSet::new(), }; - for map in self.volatile_entries.window.iter() { + for map in self.volatile.window.iter() { if let Some(entry) = map.get(address) { if let Some(deltas) = &entry.utxos { for delta in deltas { @@ -89,7 +91,7 @@ impl State { pub async fn get_address_transactions( &self, - store: &dyn AddressStore, + store: &ImmutableAddressStore, address: &Address, ) -> Result>> { if !self.config.store_transactions { @@ -103,7 +105,7 @@ impl State { None => Vec::new(), }; - for map in self.volatile_entries.window.iter() { + for map in self.volatile.window.iter() { if let Some(entry) = map.get(address) { if let Some(txs) = &entry.transactions { combined.extend(txs.iter().cloned()); @@ -120,7 +122,7 @@ impl State { pub async fn get_address_totals( &self, - store: &dyn AddressStore, + store: &ImmutableAddressStore, address: &Address, ) -> Result { if !self.config.store_totals { @@ -132,7 +134,7 @@ impl State { None => AddressTotals::default(), }; - for map in self.volatile_entries.window.iter() { + for map in self.volatile.window.iter() { if let Some(entry) = map.get(address) { if let Some(address_deltas) = &entry.totals { for delta in address_deltas { @@ -146,8 +148,7 @@ impl State { } pub fn handle_address_deltas(&mut self, deltas: &[AddressDelta]) -> Result<()> { - let addresses = - self.volatile_entries.window.back_mut().expect("window should never be empty"); + let addresses = self.volatile.window.back_mut().expect("window should never be empty"); for delta in deltas { let entry = addresses.entry(delta.address.clone()).or_default(); diff --git a/modules/address_state/src/volatile_index.rs b/modules/address_state/src/volatile_addresses.rs similarity index 90% rename from modules/address_state/src/volatile_index.rs rename to modules/address_state/src/volatile_addresses.rs index 1c86d6dc..82d14def 100644 --- a/modules/address_state/src/volatile_index.rs +++ b/modules/address_state/src/volatile_addresses.rs @@ -4,12 +4,12 @@ use acropolis_common::Address; use anyhow::Result; use crate::{ - address_store::AddressStore, + immutable_address_store::ImmutableAddressStore, state::{AddressEntry, AddressStorageConfig}, }; #[derive(Debug, Clone)] -pub struct VolatileIndex { +pub struct VolatileAddresses { pub window: VecDeque>, pub start_block: u64, pub epoch_start_block: u64, @@ -17,18 +17,18 @@ pub struct VolatileIndex { pub security_param_k: u64, } -impl Default for VolatileIndex { +impl Default for VolatileAddresses { fn default() -> Self { Self::new() } } -impl VolatileIndex { +impl VolatileAddresses { pub fn new() -> Self { let mut window = VecDeque::new(); window.push_back(HashMap::new()); - VolatileIndex { + VolatileAddresses { window, start_block: 0, epoch_start_block: 0, @@ -61,12 +61,10 @@ impl VolatileIndex { } out } -} -impl VolatileIndex { pub async fn persist_all( &mut self, - store: &dyn AddressStore, + store: &ImmutableAddressStore, config: &AddressStorageConfig, ) -> Result<()> { let epoch = self.last_persisted_epoch.map(|e| e + 1).unwrap_or(0); From 16dbe2d93250c24bdce07665a386a6bf7e107208 Mon Sep 17 00:00:00 2001 From: William Hankins Date: Wed, 8 Oct 2025 19:57:22 +0000 Subject: [PATCH 11/18] fix: cleanup address_state run loop and store logic Signed-off-by: William Hankins --- modules/address_state/src/address_state.rs | 423 +++++------------- .../src/immutable_address_store.rs | 88 ++-- modules/address_state/src/state.rs | 178 +++++++- .../address_state/src/volatile_addresses.rs | 2 +- 4 files changed, 334 insertions(+), 357 deletions(-) diff --git a/modules/address_state/src/address_state.rs b/modules/address_state/src/address_state.rs index 32ec108a..e8cc1cf6 100644 --- a/modules/address_state/src/address_state.rs +++ b/modules/address_state/src/address_state.rs @@ -1,6 +1,6 @@ //! Acropolis Address State module for Caryatid. -//! Consumes UTxO delta messages and indexes per-address -//! balances, transactions, and total sent/received amounts. +//! Consumes address delta messages and indexes per-address +//! utxos, transactions, and total sent/received amounts. use std::sync::Arc; @@ -40,120 +40,100 @@ const DEFAULT_STORE_TRANSACTIONS: (&str, bool) = ("store-transactions", false); #[module( message_type(Message), name = "address-state", - description = "In-memory Address State from utxo delta events" + description = "In-memory Address State from address delta events" )] pub struct AddressState; impl AddressState { async fn run( state_mutex: Arc>, - mut address_deltas_subscription: Option>>, - mut params_subscription: Option>>, - persist_epoch: Option, - store: Option>, + mut address_deltas_subscription: Box>, + mut params_subscription: Box>, + persist_after: Option, + store: Arc, ) -> Result<()> { - if let Some(sub) = params_subscription.as_mut() { - let _ = sub.read().await?; - info!("Consumed initial genesis params from params_subscription"); - } + let _ = params_subscription.read().await?; + info!("Consumed initial genesis params from params_subscription"); + // Main loop of synchronised messages loop { - let mut current_block: Option = None; + // Address deltas are the synchroniser + let (_, deltas_msg) = address_deltas_subscription.read().await?; + let (current_block, new_epoch) = match deltas_msg.as_ref() { + Message::Cardano((info, _)) => (info.clone(), info.new_epoch && info.epoch > 0), + _ => continue, + }; - let mut state = state_mutex.lock().await; - // Handle UTxO deltas if subscription is registered (store-info or store-transactions enabled) - if let Some(sub) = address_deltas_subscription.as_mut() { - let (_, deltas_msg) = sub.read().await?; - let new_epoch = match deltas_msg.as_ref() { - Message::Cardano((ref block_info, _)) => { - if block_info.status == BlockStatus::RolledBack { - state.volatile.rollback_before(block_info.number); - state.volatile.next_block(); - } - current_block = Some(block_info.clone()); - block_info.new_epoch && block_info.epoch > 0 - } - _ => false, - }; + if current_block.status == BlockStatus::RolledBack { + let mut state = state_mutex.lock().await; + state.volatile.rollback_before(current_block.number); + state.volatile.next_block(); + } - if new_epoch { - if let Some(sub) = params_subscription.as_mut() { - let (_, message) = sub.read().await?; - if let Message::Cardano(( - ref block_info, - CardanoMessage::ProtocolParams(params), - )) = message.as_ref() - { - Self::check_sync(¤t_block, &block_info, "params"); - state.volatile.start_new_epoch(block_info.number); - if let Some(shelley) = ¶ms.params.shelley { - state.volatile.update_k(shelley.security_param); - } - } + // Read params message on epoch bounday to update rollback window + // length if needed and set epoch start block for volatile pruning + if new_epoch { + let (_, message) = params_subscription.read().await?; + if let Message::Cardano((ref block_info, CardanoMessage::ProtocolParams(params))) = + message.as_ref() + { + Self::check_sync(¤t_block, &block_info, "params"); + let mut state = state_mutex.lock().await; + state.volatile.start_new_epoch(block_info.number); + if let Some(shelley) = ¶ms.params.shelley { + state.volatile.update_k(shelley.security_param); } } + } - match deltas_msg.as_ref() { - Message::Cardano(( - ref block_info, - CardanoMessage::AddressDeltas(address_deltas_msg), - )) => { - // Skip processing for epochs already stored to DB - if let Some(min_epoch) = persist_epoch { - if block_info.epoch <= min_epoch { - continue; - } - } - - // Update volatile entries - if let Err(e) = state.handle_address_deltas(&address_deltas_msg.deltas) { - error!("address deltas handling error: {e:#}"); + // Process address deltas into volatile and persist to disk if a full epoch is out of rollback window + match deltas_msg.as_ref() { + Message::Cardano(( + ref block_info, + CardanoMessage::AddressDeltas(address_deltas_msg), + )) => { + let mut state = state_mutex.lock().await; + // Skip processing for epochs already stored to DB + if let Some(min_epoch) = persist_after { + if block_info.epoch <= min_epoch { + state.volatile.next_block(); + continue; } + } - if block_info.epoch > 0 { - // Compute the safe_block at which the previous epoch can be removed from volatile - let safe_block = - state.volatile.epoch_start_block + state.volatile.security_param_k; + // Add deltas to volatile + if let Err(e) = state.apply_address_deltas(&address_deltas_msg.deltas) { + error!("address deltas handling error: {e:#}"); + } - // Persist to disk and prune from volatile when block number exceeds safe block - if block_info.number > safe_block { - if Some(block_info.epoch) != state.volatile.last_persisted_epoch { - if let Some(address_store) = &store { - let config = state.config.clone(); - state - .volatile - .persist_all(address_store.as_ref(), &config) - .await?; - } - } - } - } + // Persist full epoch to disk if ready + if state.ready_to_prune(¤t_block) { + let config = state.config.clone(); + state.volatile.persist_all(store.as_ref(), &config).await?; } - other => error!("Unexpected message on utxo-deltas subscription: {other:?}"), - } - state.volatile.next_block(); + state.volatile.next_block(); + } + other => error!("Unexpected message on address-deltas subscription: {other:?}"), } } } - fn check_sync(expected: &Option, actual: &BlockInfo, source: &str) { - if let Some(ref block) = expected { - if block.number != actual.number { - error!( - expected = block.number, - actual = actual.number, - source = source, - "Messages out of sync (expected certs block {}, got {} from {})", - block.number, - actual.number, - source, - ); - panic!( - "Message streams diverged: certs at {} vs {} from {}", - block.number, actual.number, source - ); - } + fn check_sync(expected: &BlockInfo, actual: &BlockInfo, source: &str) { + if expected.number != actual.number { + error!( + expected = expected.number, + actual = actual.number, + source = source, + "Messages out of sync (expected deltas block {}, got {} from {})", + expected.number, + actual.number, + source, + ); + panic!( + "Message streams diverged: deltas at {} vs {} from {}", + expected.number, actual.number, source + ); } } @@ -166,29 +146,13 @@ impl AddressState { config.get_string(key.0).unwrap_or_else(|_| key.1.to_string()) } - // Get configuration flags and topis + // Get configuration flags and query topic let storage_config = AddressStorageConfig { store_info: get_bool_flag(&config, DEFAULT_STORE_INFO), store_totals: get_bool_flag(&config, DEFAULT_STORE_TOTALS), store_transactions: get_bool_flag(&config, DEFAULT_STORE_TRANSACTIONS), }; - let address_deltas_subscribe_topic: Option = if storage_config.any_enabled() { - let topic = get_string_flag(&config, DEFAULT_ADDRESS_DELTAS_SUBSCRIBE_TOPIC); - info!("Creating subscriber on '{topic}'"); - Some(topic) - } else { - None - }; - - let params_subscribe_topic: Option = if storage_config.any_enabled() { - let topic = get_string_flag(&config, DEFAULT_PARAMETERS_SUBSCRIBE_TOPIC); - info!("Creating subscriber on '{topic}'"); - Some(topic) - } else { - None - }; - let address_query_topic = get_string_flag(&config, DEFAULT_ADDRESS_QUERY_TOPIC); info!("Creating asset query handler on '{address_query_topic}'"); @@ -198,21 +162,16 @@ impl AddressState { let state_query = state.clone(); // Initialize Fjall store - let (store, persist_epoch): (Option>, Option) = - if storage_config.any_enabled() { - let path = config - .get_string("address_state.path") - .unwrap_or_else(|_| "./data/address_state".to_string()); - - let store = ImmutableAddressStore::new(path)?; - let persist_after = store.get_last_epoch_stored().await?; - (Some(Arc::new(store)), persist_after) - } else { - (None, None) - }; - + let store = if storage_config.any_enabled() { + let path = config + .get_string("address_state.path") + .unwrap_or_else(|_| "./data/address_state".to_string()); + let store = ImmutableAddressStore::new(path)?; + Some(Arc::new(store)) + } else { + None + }; let query_store = store.clone(); - let store_run = store.clone(); // Query handler context.handle(&address_query_topic, move |message| { @@ -221,7 +180,9 @@ impl AddressState { async move { let Message::StateQuery(StateQuery::Addresses(query)) = message.as_ref() else { return Arc::new(Message::StateQueryResponse(StateQueryResponse::Addresses( - AddressStateQueryResponse::Error("Invalid message for assets-state".into()), + AddressStateQueryResponse::Error( + "Invalid message for address-state".into(), + ), ))); }; @@ -268,193 +229,33 @@ impl AddressState { } }); - // Subscribe to enabled topics - let address_deltas_sub = if let Some(topic) = &address_deltas_subscribe_topic { - Some(context.subscribe(topic).await?) - } else { - None - }; - - let params_sub = if let Some(topic) = ¶ms_subscribe_topic { - Some(context.subscribe(topic).await?) - } else { - None - }; - - // Start run task - context.run(async move { - Self::run( - state_run, - address_deltas_sub, - params_sub, - persist_epoch, - store_run, - ) - .await - .unwrap_or_else(|e| error!("Failed: {e}")); - }); - - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use acropolis_common::{Address, AddressDelta, UTxOIdentifier, ValueDelta}; - use tempfile::tempdir; - - fn dummy_address() -> Address { - Address::from_string("DdzFFzCqrht7fNAHwdou7iXPJ5NZrssAH53yoRMUtF9t6momHH52EAxM5KmqDwhrjT7QsHjbMPJUBywmzAgmF4hj2h9eKj4U6Ahandyy").unwrap() - } - - fn test_config() -> AddressStorageConfig { - AddressStorageConfig { - store_info: true, - store_transactions: true, - store_totals: true, - } - } - - async fn setup_state_and_store() -> Result<(Arc, State)> { - let tmpdir = tempdir().unwrap(); - let store = Arc::new(ImmutableAddressStore::new(tmpdir.path())?); - let config = test_config(); - let mut state = State::new(config.clone()); - state.volatile.epoch_start_block = 1; - Ok((store, state)) - } - - fn delta(addr: &Address, utxo: &UTxOIdentifier, lovelace: i64) -> AddressDelta { - AddressDelta { - address: addr.clone(), - utxo: utxo.clone(), - value: ValueDelta { - lovelace, - assets: Vec::new(), - }, + if let Some(store) = store { + // Get subscribe topics + let address_deltas_subscribe_topic = + get_string_flag(&config, DEFAULT_ADDRESS_DELTAS_SUBSCRIBE_TOPIC); + info!("Creating subscriber on '{address_deltas_subscribe_topic}'"); + let params_subscribe_topic = + get_string_flag(&config, DEFAULT_PARAMETERS_SUBSCRIBE_TOPIC); + info!("Creating subscriber on '{params_subscribe_topic}'"); + + // Subscribe to enabled topics + let address_deltas_sub = context.subscribe(&address_deltas_subscribe_topic).await?; + let params_sub = context.subscribe(¶ms_subscribe_topic).await?; + + let persist_after = store.get_last_epoch_stored().await?; + // Start run task + context.run(async move { + Self::run( + state_run, + address_deltas_sub, + params_sub, + persist_after, + store, + ) + .await + .unwrap_or_else(|e| error!("Failed: {e}")); + }); } - } - - #[tokio::test] - async fn test_persist_all_and_read_back() -> Result<()> { - let _ = tracing_subscriber::fmt::try_init(); - - let (store, mut state) = setup_state_and_store().await?; - let config = test_config(); - - let addr = dummy_address(); - let utxo = UTxOIdentifier::new(0, 0, 0); - let deltas = vec![delta(&addr, &utxo, 1)]; - - // Apply deltas - state.handle_address_deltas(&deltas)?; - - // Persist everything - state.volatile.persist_all(store.as_ref(), &config).await?; - - // Verify persisted UTxOs - let utxos = store.get_utxos(&addr)?; - assert!(utxos.is_some()); - assert_eq!(utxos.as_ref().unwrap().len(), 1); - assert_eq!(utxos.as_ref().unwrap()[0], UTxOIdentifier::new(0, 0, 0)); - - // Totals should exist - let totals = store.get_totals(&addr).await?; - assert!(totals.is_some()); - - // Epoch marker advanced - let last_epoch = store.get_last_epoch_stored().await?; - assert_eq!(last_epoch, Some(0)); - - Ok(()) - } - - #[tokio::test] - async fn test_utxo_removed_when_spent() -> Result<()> { - let _ = tracing_subscriber::fmt::try_init(); - - let (store, mut state) = setup_state_and_store().await?; - let config = test_config(); - - let addr = dummy_address(); - let utxo = UTxOIdentifier::new(0, 0, 0); - - // Before processing - assert!( - state.get_address_utxos(store.as_ref(), &addr).await?.is_none(), - "Expected no UTxOs before creation" - ); - - let created = vec![delta(&addr, &utxo, 1)]; - - state.handle_address_deltas(&created)?; - - // After processing creation - let after_create = state.get_address_utxos(store.as_ref(), &addr).await?; - assert_eq!(after_create.as_ref().unwrap(), &[utxo]); - - state.volatile.persist_all(store.as_ref(), &config).await?; - - // After persisting creation - let after_persist = state.get_address_utxos(store.as_ref(), &addr).await?; - assert_eq!(after_persist.as_ref().unwrap(), &[utxo]); - - state.volatile.next_block(); - state.volatile.epoch_start_block = 2; - state.handle_address_deltas(&[delta(&addr, &utxo, -1)])?; - - // After processing spend - let after_spend_volatile = state.get_address_utxos(store.as_ref(), &addr).await?; - assert!(after_spend_volatile.as_ref().map_or(true, |u| u.is_empty())); - - state.volatile.persist_all(store.as_ref(), &config).await?; - - // After persisting spend - let after_spend_disk = state.get_address_utxos(store.as_ref(), &addr).await?; - assert!(after_spend_disk.as_ref().map_or(true, |u| u.is_empty())); - - Ok(()) - } - - #[tokio::test] - async fn test_utxo_spent_and_created_across_blocks_in_volatile() -> Result<()> { - let _ = tracing_subscriber::fmt::try_init(); - - let (store, mut state) = setup_state_and_store().await?; - let config = test_config(); - - let addr = dummy_address(); - let utxo_old = UTxOIdentifier::new(0, 0, 0); - let utxo_new = UTxOIdentifier::new(0, 1, 0); - - state.volatile.epoch_start_block = 1; - - state.handle_address_deltas(&[delta(&addr, &utxo_old, 1)])?; - state.volatile.next_block(); - state.handle_address_deltas(&[delta(&addr, &utxo_old, -1), delta(&addr, &utxo_new, 1)])?; - - // Create and spend both in volatile is not included in address utxos - let volatile = state.get_address_utxos(store.as_ref(), &addr).await?; - assert!( - volatile.as_ref().is_some_and(|u| u.contains(&utxo_new) && !u.contains(&utxo_old)), - "Expected only new UTxO {:?} in volatile view, got {:?}", - utxo_new, - volatile - ); - - state.volatile.persist_all(store.as_ref(), &config).await?; - - // UTxO not persisted to disk if created and spent in pruned volatile window - let persisted_view = state.get_address_utxos(store.as_ref(), &addr).await?; - assert!( - persisted_view - .as_ref() - .is_some_and(|u| u.contains(&utxo_new) && !u.contains(&utxo_old)), - "Expected only new UTxO {:?} after persistence, got {:?}", - utxo_new, - persisted_view - ); Ok(()) } diff --git a/modules/address_state/src/immutable_address_store.rs b/modules/address_state/src/immutable_address_store.rs index 35a8f348..64de6289 100644 --- a/modules/address_state/src/immutable_address_store.rs +++ b/modules/address_state/src/immutable_address_store.rs @@ -9,7 +9,7 @@ use anyhow::Result; use fjall::{Keyspace, Partition, PartitionCreateOptions}; use minicbor::{decode, to_vec}; use tokio::task; -use tracing::info; +use tracing::{debug, error, info}; // Metadata keys which store the last epoch saved in each partition const ADDRESS_UTXOS_EPOCH_COUNTER: &[u8] = b"utxos_epoch_last"; @@ -41,6 +41,9 @@ impl ImmutableAddressStore { }) } + /// Persists volatile UTxOs, transactions, and totals into their respective Fjall partitions for an entire epoch. + /// Skips any partitions that have already stored the given epoch. + /// All writes are batched and committed atomically, preventing on-disk corruption in case of failure. pub async fn persist_epoch( &self, epoch: u64, @@ -55,7 +58,7 @@ impl ImmutableAddressStore { && !self.epoch_exists(self.totals.clone(), ADDRESS_TOTALS_EPOCH_COUNTER, epoch).await?; if !(persist_utxos || persist_txs || persist_totals) { - tracing::debug!("skipping epoch {} (already persisted or disabled)", epoch); + debug!("no persistence needed for epoch {epoch} (already persisted or disabled)",); return Ok(()); } @@ -64,9 +67,10 @@ impl ImmutableAddressStore { let txs = self.txs.clone(); let totals = self.totals.clone(); - task::spawn_blocking(move || { + task::spawn_blocking(move || -> Result<()> { let mut batch = keyspace.batch(); let mut change_count = 0; + for block_map in drained_blocks.into_iter() { if block_map.is_empty() { continue; @@ -77,10 +81,11 @@ impl ImmutableAddressStore { let addr_key = addr.to_bytes_key()?; if persist_utxos { - let mut live: HashSet = match utxos.get(&addr_key)? { - Some(bytes) => decode(&bytes)?, - None => HashSet::new(), - }; + let mut live: HashSet = utxos + .get(&addr_key)? + .map(|bytes| decode(&bytes)) + .transpose()? + .unwrap_or_default(); if let Some(deltas) = &entry.utxos { for delta in deltas { @@ -99,10 +104,11 @@ impl ImmutableAddressStore { } if persist_txs { - let mut live: Vec = match txs.get(&addr_key)? { - Some(bytes) => decode(&bytes)?, - None => Vec::new(), - }; + let mut live: Vec = txs + .get(&addr_key)? + .map(|bytes| decode(&bytes)) + .transpose()? + .unwrap_or_default(); if let Some(txs_deltas) = &entry.transactions { live.extend(txs_deltas.iter().cloned()); @@ -112,10 +118,11 @@ impl ImmutableAddressStore { } if persist_totals { - let mut live: AddressTotals = match totals.get(&addr_key)? { - Some(bytes) => decode(&bytes)?, - None => AddressTotals::default(), - }; + let mut live: AddressTotals = totals + .get(&addr_key)? + .map(|bytes| decode(&bytes)) + .transpose()? + .unwrap_or_default(); if let Some(deltas) = &entry.totals { for delta in deltas { @@ -141,14 +148,11 @@ impl ImmutableAddressStore { match batch.commit() { Ok(_) => { - tracing::info!( - "address_state: wrote {} address changes to Fjall.", - change_count, - ); - Ok::<_, anyhow::Error>(()) + info!("committed {change_count} address changes for epoch {epoch}"); + Ok(()) } Err(e) => { - tracing::error!("address_state: failed to commit batch: {}", e); + error!("batch commit failed for epoch {epoch}: {e}"); Err(e.into()) } } @@ -158,16 +162,17 @@ impl ImmutableAddressStore { Ok(()) } - pub fn get_utxos(&self, address: &Address) -> Result>> { + pub async fn get_utxos(&self, address: &Address) -> Result>> { let key = address.to_bytes_key()?; - info!("searching for {}", hex::encode(&key)); - match self.utxos.get(key)? { + let partition = self.utxos.clone(); + task::spawn_blocking(move || match partition.get(key)? { Some(bytes) => { let decoded: Vec = decode(&bytes)?; Ok(Some(decoded)) } None => Ok(None), - } + }) + .await? } pub async fn get_txs(&self, address: &Address) -> Result>> { @@ -237,26 +242,27 @@ impl ImmutableAddressStore { key: &'static [u8], epoch: u64, ) -> Result { - let result = task::spawn_blocking(move || { - Ok::<_, anyhow::Error>(match partition.get(key)? { - Some(bytes) if bytes.len() == 8 => { - let mut arr = [0u8; 8]; - arr.copy_from_slice(&bytes); - let last_epoch = u64::from_le_bytes(arr); - epoch <= last_epoch - } - _ => false, - }) + let exists = task::spawn_blocking(move || -> Result { + let bytes = match partition.get(key)? { + Some(b) if b.len() == 8 => b, + _ => return Ok(false), + }; + + let mut arr = [0u8; 8]; + arr.copy_from_slice(&bytes); + let last_epoch = u64::from_le_bytes(arr); + + Ok(epoch <= last_epoch) }) .await??; - if result { - match std::str::from_utf8(key) { - Ok(s) => info!("epoch {epoch} already stored for {s}"), - Err(_) => info!("epoch {epoch} already stored for key {:?}", key), - } + if exists { + let key_name = std::str::from_utf8(key) + .map(|s| s.to_string()) + .unwrap_or_else(|_| format!("{:?}", key)); + info!("epoch {epoch} already stored for {key_name}"); } - Ok(result) + Ok(exists) } } diff --git a/modules/address_state/src/state.rs b/modules/address_state/src/state.rs index 0b9c2792..4c82b115 100644 --- a/modules/address_state/src/state.rs +++ b/modules/address_state/src/state.rs @@ -1,7 +1,7 @@ use std::collections::HashSet; use acropolis_common::{ - Address, AddressDelta, AddressTotals, TxIdentifier, UTxOIdentifier, ValueDelta, + Address, AddressDelta, AddressTotals, BlockInfo, TxIdentifier, UTxOIdentifier, ValueDelta, }; use anyhow::Result; @@ -60,7 +60,7 @@ impl State { return Err(anyhow::anyhow!("address info storage disabled in config")); } - let mut combined: HashSet = match store.get_utxos(address)? { + let mut combined: HashSet = match store.get_utxos(address).await? { Some(db) => db.into_iter().collect(), None => HashSet::new(), }; @@ -108,7 +108,7 @@ impl State { for map in self.volatile.window.iter() { if let Some(entry) = map.get(address) { if let Some(txs) = &entry.transactions { - combined.extend(txs.iter().cloned()); + combined.extend(txs); } } } @@ -147,7 +147,13 @@ impl State { Ok(totals) } - pub fn handle_address_deltas(&mut self, deltas: &[AddressDelta]) -> Result<()> { + pub fn ready_to_prune(&self, block_info: &BlockInfo) -> bool { + block_info.epoch > 0 + && Some(block_info.epoch) != self.volatile.last_persisted_epoch + && block_info.number > self.volatile.epoch_start_block + self.volatile.security_param_k + } + + pub fn apply_address_deltas(&mut self, deltas: &[AddressDelta]) -> Result<()> { let addresses = self.volatile.window.back_mut().expect("window should never be empty"); for delta in deltas { @@ -176,3 +182,167 @@ impl State { Ok(()) } } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + use acropolis_common::{Address, AddressDelta, UTxOIdentifier, ValueDelta}; + use tempfile::tempdir; + + fn dummy_address() -> Address { + Address::from_string("DdzFFzCqrht7fNAHwdou7iXPJ5NZrssAH53yoRMUtF9t6momHH52EAxM5KmqDwhrjT7QsHjbMPJUBywmzAgmF4hj2h9eKj4U6Ahandyy").unwrap() + } + + fn test_config() -> AddressStorageConfig { + AddressStorageConfig { + store_info: true, + store_transactions: true, + store_totals: true, + } + } + + async fn setup_state_and_store() -> Result<(Arc, State)> { + let tmpdir = tempdir().unwrap(); + let store = Arc::new(ImmutableAddressStore::new(tmpdir.path())?); + let config = test_config(); + let mut state = State::new(config.clone()); + state.volatile.epoch_start_block = 1; + Ok((store, state)) + } + + fn delta(addr: &Address, utxo: &UTxOIdentifier, lovelace: i64) -> AddressDelta { + AddressDelta { + address: addr.clone(), + utxo: utxo.clone(), + value: ValueDelta { + lovelace, + assets: Vec::new(), + }, + } + } + + #[tokio::test] + async fn test_persist_all_and_read_back() -> Result<()> { + let _ = tracing_subscriber::fmt::try_init(); + + let (store, mut state) = setup_state_and_store().await?; + let config = test_config(); + + let addr = dummy_address(); + let utxo = UTxOIdentifier::new(0, 0, 0); + let deltas = vec![delta(&addr, &utxo, 1)]; + + // Apply deltas + state.apply_address_deltas(&deltas)?; + + // Persist everything + state.volatile.persist_all(store.as_ref(), &config).await?; + + // Verify persisted UTxOs + let utxos = store.get_utxos(&addr).await?; + assert!(utxos.is_some()); + assert_eq!(utxos.as_ref().unwrap().len(), 1); + assert_eq!(utxos.as_ref().unwrap()[0], UTxOIdentifier::new(0, 0, 0)); + + // Totals should exist + let totals = store.get_totals(&addr).await?; + assert!(totals.is_some()); + + // Epoch marker advanced + let last_epoch = store.get_last_epoch_stored().await?; + assert_eq!(last_epoch, Some(0)); + + Ok(()) + } + + #[tokio::test] + async fn test_utxo_removed_when_spent() -> Result<()> { + let _ = tracing_subscriber::fmt::try_init(); + + let (store, mut state) = setup_state_and_store().await?; + let config = test_config(); + + let addr = dummy_address(); + let utxo = UTxOIdentifier::new(0, 0, 0); + + // Before processing + assert!( + state.get_address_utxos(store.as_ref(), &addr).await?.is_none(), + "Expected no UTxOs before creation" + ); + + let created = vec![delta(&addr, &utxo, 1)]; + + state.apply_address_deltas(&created)?; + + // After processing creation + let after_create = state.get_address_utxos(store.as_ref(), &addr).await?; + assert_eq!(after_create.as_ref().unwrap(), &[utxo]); + + state.volatile.persist_all(store.as_ref(), &config).await?; + + // After persisting creation + let after_persist = state.get_address_utxos(store.as_ref(), &addr).await?; + assert_eq!(after_persist.as_ref().unwrap(), &[utxo]); + + state.volatile.next_block(); + state.volatile.epoch_start_block = 2; + state.apply_address_deltas(&[delta(&addr, &utxo, -1)])?; + + // After processing spend + let after_spend_volatile = state.get_address_utxos(store.as_ref(), &addr).await?; + assert!(after_spend_volatile.as_ref().map_or(true, |u| u.is_empty())); + + state.volatile.persist_all(store.as_ref(), &config).await?; + + // After persisting spend + let after_spend_disk = state.get_address_utxos(store.as_ref(), &addr).await?; + assert!(after_spend_disk.as_ref().map_or(true, |u| u.is_empty())); + + Ok(()) + } + + #[tokio::test] + async fn test_utxo_spent_and_created_across_blocks_in_volatile() -> Result<()> { + let _ = tracing_subscriber::fmt::try_init(); + + let (store, mut state) = setup_state_and_store().await?; + let config = test_config(); + + let addr = dummy_address(); + let utxo_old = UTxOIdentifier::new(0, 0, 0); + let utxo_new = UTxOIdentifier::new(0, 1, 0); + + state.volatile.epoch_start_block = 1; + + state.apply_address_deltas(&[delta(&addr, &utxo_old, 1)])?; + state.volatile.next_block(); + state.apply_address_deltas(&[delta(&addr, &utxo_old, -1), delta(&addr, &utxo_new, 1)])?; + + // Create and spend both in volatile is not included in address utxos + let volatile = state.get_address_utxos(store.as_ref(), &addr).await?; + assert!( + volatile.as_ref().is_some_and(|u| u.contains(&utxo_new) && !u.contains(&utxo_old)), + "Expected only new UTxO {:?} in volatile view, got {:?}", + utxo_new, + volatile + ); + + state.volatile.persist_all(store.as_ref(), &config).await?; + + // UTxO not persisted to disk if created and spent in pruned volatile window + let persisted_view = state.get_address_utxos(store.as_ref(), &addr).await?; + assert!( + persisted_view + .as_ref() + .is_some_and(|u| u.contains(&utxo_new) && !u.contains(&utxo_old)), + "Expected only new UTxO {:?} after persistence, got {:?}", + utxo_new, + persisted_view + ); + + Ok(()) + } +} diff --git a/modules/address_state/src/volatile_addresses.rs b/modules/address_state/src/volatile_addresses.rs index 82d14def..a5c9aa46 100644 --- a/modules/address_state/src/volatile_addresses.rs +++ b/modules/address_state/src/volatile_addresses.rs @@ -52,7 +52,7 @@ impl VolatileAddresses { pub fn rollback_before(&mut self, block: u64) -> Vec<(Address, AddressEntry)> { let mut out = Vec::new(); - while self.start_block + self.window.len() as u64 > block { + while self.start_block + self.window.len() as u64 >= block { if let Some(map) = self.window.pop_back() { out.extend(map.into_iter()); } else { From a7654751058c11198475d310f3513717da19f4b2 Mon Sep 17 00:00:00 2001 From: William Hankins Date: Thu, 9 Oct 2025 18:52:51 +0000 Subject: [PATCH 12/18] refactor: prevent blocking run loop when persisting to disk (WIP) Signed-off-by: William Hankins --- common/src/address.rs | 2 +- modules/address_state/.gitignore | 1 + modules/address_state/src/address_state.rs | 134 ++++++------- .../src/immutable_address_store.rs | 178 +++++++++--------- modules/address_state/src/state.rs | 102 +++++++--- .../address_state/src/volatile_addresses.rs | 17 +- modules/utxo_state/src/state.rs | 2 +- processes/omnibus/omnibus.toml | 4 +- 8 files changed, 231 insertions(+), 209 deletions(-) create mode 100644 modules/address_state/.gitignore diff --git a/common/src/address.rs b/common/src/address.rs index 053ee9ff..3a2f8c82 100644 --- a/common/src/address.rs +++ b/common/src/address.rs @@ -531,7 +531,7 @@ mod tests { let payload = vec![42]; let address = Address::Byron(ByronAddress { payload }); let text = address.to_string().unwrap(); - assert_eq!(text, "j"); + assert_eq!(text, "8MMy4x9jE734Gz"); let unpacked = Address::from_string(&text).unwrap(); assert_eq!(address, unpacked); diff --git a/modules/address_state/.gitignore b/modules/address_state/.gitignore new file mode 100644 index 00000000..9f4c740d --- /dev/null +++ b/modules/address_state/.gitignore @@ -0,0 +1 @@ +db/ \ No newline at end of file diff --git a/modules/address_state/src/address_state.rs b/modules/address_state/src/address_state.rs index e8cc1cf6..d298516b 100644 --- a/modules/address_state/src/address_state.rs +++ b/modules/address_state/src/address_state.rs @@ -17,10 +17,7 @@ use config::Config; use tokio::sync::Mutex; use tracing::{error, info}; -use crate::{ - immutable_address_store::ImmutableAddressStore, - state::{AddressStorageConfig, State}, -}; +use crate::state::{AddressStorageConfig, State}; mod immutable_address_store; mod state; mod volatile_addresses; @@ -32,6 +29,7 @@ const DEFAULT_PARAMETERS_SUBSCRIBE_TOPIC: (&str, &str) = ("parameters-subscribe-topic", "cardano.protocol.parameters"); // Configuration defaults +const DEFAULT_ADDRESS_DB_PATH: (&str, &str) = ("db-path", "./db"); const DEFAULT_STORE_INFO: (&str, bool) = ("store-info", false); const DEFAULT_STORE_TOTALS: (&str, bool) = ("store-totals", false); const DEFAULT_STORE_TRANSACTIONS: (&str, bool) = ("store-transactions", false); @@ -49,8 +47,6 @@ impl AddressState { state_mutex: Arc>, mut address_deltas_subscription: Box>, mut params_subscription: Box>, - persist_after: Option, - store: Arc, ) -> Result<()> { let _ = params_subscription.read().await?; info!("Consumed initial genesis params from params_subscription"); @@ -92,27 +88,47 @@ impl AddressState { ref block_info, CardanoMessage::AddressDeltas(address_deltas_msg), )) => { - let mut state = state_mutex.lock().await; - // Skip processing for epochs already stored to DB - if let Some(min_epoch) = persist_after { - if block_info.epoch <= min_epoch { - state.volatile.next_block(); - continue; + let (should_prune, store, config, epoch); + { + let mut state = state_mutex.lock().await; + // Skip processing for epochs already stored to DB + if let Some(min_epoch) = state.config.skip_until { + if block_info.epoch <= min_epoch { + state.volatile.next_block(); + continue; + } } - } - // Add deltas to volatile - if let Err(e) = state.apply_address_deltas(&address_deltas_msg.deltas) { - error!("address deltas handling error: {e:#}"); + // Add deltas to volatile + if let Err(e) = state.apply_address_deltas(&address_deltas_msg.deltas) { + error!("address deltas handling error: {e:#}"); + } + + store = state.immutable.clone(); + config = state.config.clone(); + epoch = block_info.epoch; + + // Move volatile deltas for an epoch to ImmutableAddressStore if out of rollback window + should_prune = state.ready_to_prune(¤t_block); + if should_prune { + state.prune_volatile().await; + } } - // Persist full epoch to disk if ready - if state.ready_to_prune(¤t_block) { - let config = state.config.clone(); - state.volatile.persist_all(store.as_ref(), &config).await?; + if should_prune { + tokio::spawn(async move { + if let Err(e) = store.persist_epoch(epoch, &config).await { + error!("failed to persist epoch {}: {}", epoch, e); + } else { + info!("persisted address state for epoch {}", epoch); + } + }); } - state.volatile.next_block(); + { + let mut state = state_mutex.lock().await; + state.volatile.next_block(); + } } other => error!("Unexpected message on address-deltas subscription: {other:?}"), } @@ -148,6 +164,8 @@ impl AddressState { // Get configuration flags and query topic let storage_config = AddressStorageConfig { + db_path: get_string_flag(&config, DEFAULT_ADDRESS_DB_PATH), + skip_until: None, store_info: get_bool_flag(&config, DEFAULT_STORE_INFO), store_totals: get_bool_flag(&config, DEFAULT_STORE_TOTALS), store_transactions: get_bool_flag(&config, DEFAULT_STORE_TRANSACTIONS), @@ -156,27 +174,14 @@ impl AddressState { let address_query_topic = get_string_flag(&config, DEFAULT_ADDRESS_QUERY_TOPIC); info!("Creating asset query handler on '{address_query_topic}'"); - // Initialize state history - let state = Arc::new(Mutex::new(State::new(storage_config))); - let state_run = state.clone(); - let state_query = state.clone(); - - // Initialize Fjall store - let store = if storage_config.any_enabled() { - let path = config - .get_string("address_state.path") - .unwrap_or_else(|_| "./data/address_state".to_string()); - let store = ImmutableAddressStore::new(path)?; - Some(Arc::new(store)) - } else { - None - }; - let query_store = store.clone(); + // Initialize state + let state = State::new(&storage_config).await?; + let state_mutex = Arc::new(Mutex::new(state)); + let state_run = state_mutex.clone(); // Query handler context.handle(&address_query_topic, move |message| { - let state_mutex = state_query.clone(); - let store = query_store.clone(); + let state_mutex = state_mutex.clone(); async move { let Message::StateQuery(StateQuery::Addresses(query)) = message.as_ref() else { return Arc::new(Message::StateQueryResponse(StateQueryResponse::Addresses( @@ -189,37 +194,23 @@ impl AddressState { let state = state_mutex.lock().await; let response = match query { AddressStateQuery::GetAddressUTxOs { address } => { - if let Some(ref s) = store { - match state.get_address_utxos(s, &address).await { - Ok(Some(utxos)) => AddressStateQueryResponse::AddressUTxOs(utxos), - Ok(None) => AddressStateQueryResponse::NotFound, - Err(e) => AddressStateQueryResponse::Error(e.to_string()), - } - } else { - AddressStateQueryResponse::Error("Address store not initialized".into()) + match state.get_address_utxos(&address).await { + Ok(Some(utxos)) => AddressStateQueryResponse::AddressUTxOs(utxos), + Ok(None) => AddressStateQueryResponse::NotFound, + Err(e) => AddressStateQueryResponse::Error(e.to_string()), } } AddressStateQuery::GetAddressTransactions { address } => { - if let Some(ref s) = store { - match state.get_address_transactions(s, &address).await { - Ok(Some(txs)) => { - AddressStateQueryResponse::AddressTransactions(txs) - } - Ok(None) => AddressStateQueryResponse::NotFound, - Err(e) => AddressStateQueryResponse::Error(e.to_string()), - } - } else { - AddressStateQueryResponse::Error("Address store not initialized".into()) + match state.get_address_transactions(&address).await { + Ok(Some(txs)) => AddressStateQueryResponse::AddressTransactions(txs), + Ok(None) => AddressStateQueryResponse::NotFound, + Err(e) => AddressStateQueryResponse::Error(e.to_string()), } } AddressStateQuery::GetAddressTotals { address } => { - if let Some(ref s) = store { - match state.get_address_totals(s.as_ref(), &address).await { - Ok(totals) => AddressStateQueryResponse::AddressTotals(totals), - Err(e) => AddressStateQueryResponse::Error(e.to_string()), - } - } else { - AddressStateQueryResponse::Error("Address store not initialized".into()) + match state.get_address_totals(&address).await { + Ok(totals) => AddressStateQueryResponse::AddressTotals(totals), + Err(e) => AddressStateQueryResponse::Error(e.to_string()), } } }; @@ -229,7 +220,7 @@ impl AddressState { } }); - if let Some(store) = store { + if storage_config.any_enabled() { // Get subscribe topics let address_deltas_subscribe_topic = get_string_flag(&config, DEFAULT_ADDRESS_DELTAS_SUBSCRIBE_TOPIC); @@ -242,18 +233,11 @@ impl AddressState { let address_deltas_sub = context.subscribe(&address_deltas_subscribe_topic).await?; let params_sub = context.subscribe(¶ms_subscribe_topic).await?; - let persist_after = store.get_last_epoch_stored().await?; // Start run task context.run(async move { - Self::run( - state_run, - address_deltas_sub, - params_sub, - persist_after, - store, - ) - .await - .unwrap_or_else(|e| error!("Failed: {e}")); + Self::run(state_run, address_deltas_sub, params_sub) + .await + .unwrap_or_else(|e| error!("Failed: {e}")); }); } diff --git a/modules/address_state/src/immutable_address_store.rs b/modules/address_state/src/immutable_address_store.rs index 64de6289..a9379499 100644 --- a/modules/address_state/src/immutable_address_store.rs +++ b/modules/address_state/src/immutable_address_store.rs @@ -8,7 +8,7 @@ use acropolis_common::{Address, AddressTotals, TxIdentifier, UTxOIdentifier}; use anyhow::Result; use fjall::{Keyspace, Partition, PartitionCreateOptions}; use minicbor::{decode, to_vec}; -use tokio::task; +use tokio::{sync::Mutex, task}; use tracing::{debug, error, info}; // Metadata keys which store the last epoch saved in each partition @@ -21,6 +21,7 @@ pub struct ImmutableAddressStore { txs: Partition, totals: Partition, keyspace: Keyspace, + pub pending: Mutex>>, } impl ImmutableAddressStore { @@ -38,18 +39,14 @@ impl ImmutableAddressStore { txs, totals, keyspace, + pending: Mutex::new(Vec::new()), }) } /// Persists volatile UTxOs, transactions, and totals into their respective Fjall partitions for an entire epoch. /// Skips any partitions that have already stored the given epoch. /// All writes are batched and committed atomically, preventing on-disk corruption in case of failure. - pub async fn persist_epoch( - &self, - epoch: u64, - drained_blocks: Vec>, - config: &AddressStorageConfig, - ) -> Result<()> { + pub async fn persist_epoch(&self, epoch: u64, config: &AddressStorageConfig) -> Result<()> { let persist_utxos = config.store_info && !self.epoch_exists(self.utxos.clone(), ADDRESS_UTXOS_EPOCH_COUNTER, epoch).await?; let persist_txs = config.store_transactions @@ -62,104 +59,117 @@ impl ImmutableAddressStore { return Ok(()); } - let keyspace = self.keyspace.clone(); - let utxos = self.utxos.clone(); - let txs = self.txs.clone(); - let totals = self.totals.clone(); + info!("Pending has {}", self.pending.lock().await.len()); - task::spawn_blocking(move || -> Result<()> { - let mut batch = keyspace.batch(); - let mut change_count = 0; + let drained_blocks = { + let mut pending = self.pending.lock().await; + std::mem::take(&mut *pending) + }; - for block_map in drained_blocks.into_iter() { - if block_map.is_empty() { - continue; - } + let mut batch = self.keyspace.batch(); + let mut change_count = 0; - for (addr, entry) in block_map { - change_count += 1; - let addr_key = addr.to_bytes_key()?; - - if persist_utxos { - let mut live: HashSet = utxos - .get(&addr_key)? - .map(|bytes| decode(&bytes)) - .transpose()? - .unwrap_or_default(); - - if let Some(deltas) = &entry.utxos { - for delta in deltas { - match delta { - UtxoDelta::Created(u) => { - live.insert(*u); - } - UtxoDelta::Spent(u) => { - live.remove(u); - } + for block_map in drained_blocks.into_iter() { + if block_map.is_empty() { + continue; + } + + for (addr, entry) in block_map { + change_count += 1; + let addr_key = addr.to_bytes_key()?; + + if persist_utxos { + let mut live: HashSet = self + .utxos + .get(&addr_key)? + .map(|bytes| decode(&bytes)) + .transpose()? + .unwrap_or_default(); + + if let Some(deltas) = &entry.utxos { + for delta in deltas { + match delta { + UtxoDelta::Created(u) => { + live.insert(*u); + } + UtxoDelta::Spent(u) => { + live.remove(u); } } } - - batch.insert(&utxos, &addr_key, to_vec(&live)?); } - if persist_txs { - let mut live: Vec = txs - .get(&addr_key)? - .map(|bytes| decode(&bytes)) - .transpose()? - .unwrap_or_default(); + batch.insert(&self.utxos, &addr_key, to_vec(&live)?); + } - if let Some(txs_deltas) = &entry.transactions { - live.extend(txs_deltas.iter().cloned()); - } + if persist_txs { + let mut live: Vec = self + .txs + .get(&addr_key)? + .map(|bytes| decode(&bytes)) + .transpose()? + .unwrap_or_default(); - batch.insert(&txs, &addr_key, to_vec(&live)?); + if let Some(txs_deltas) = &entry.transactions { + live.extend(txs_deltas.iter().cloned()); } - if persist_totals { - let mut live: AddressTotals = totals - .get(&addr_key)? - .map(|bytes| decode(&bytes)) - .transpose()? - .unwrap_or_default(); + batch.insert(&self.txs, &addr_key, to_vec(&live)?); + } - if let Some(deltas) = &entry.totals { - for delta in deltas { - live.apply_delta(delta); - } + if persist_totals { + let mut live: AddressTotals = self + .totals + .get(&addr_key)? + .map(|bytes| decode(&bytes)) + .transpose()? + .unwrap_or_default(); + + if let Some(deltas) = &entry.totals { + for delta in deltas { + live.apply_delta(delta); } - - batch.insert(&totals, &addr_key, to_vec(&live)?); } + + batch.insert(&self.totals, &addr_key, to_vec(&live)?); } } + } - // Metadata markers - if persist_utxos { - batch.insert(&utxos, ADDRESS_UTXOS_EPOCH_COUNTER, &epoch.to_le_bytes()); - } - if persist_txs { - batch.insert(&txs, ADDRESS_TXS_EPOCH_COUNTER, &epoch.to_le_bytes()); - } - if persist_totals { - batch.insert(&totals, ADDRESS_TOTALS_EPOCH_COUNTER, &epoch.to_le_bytes()); - } + // Metadata markers + if persist_utxos { + batch.insert( + &self.utxos, + ADDRESS_UTXOS_EPOCH_COUNTER, + &epoch.to_le_bytes(), + ); + } + if persist_txs { + batch.insert(&self.txs, ADDRESS_TXS_EPOCH_COUNTER, &epoch.to_le_bytes()); + } + if persist_totals { + batch.insert( + &self.totals, + ADDRESS_TOTALS_EPOCH_COUNTER, + &epoch.to_le_bytes(), + ); + } - match batch.commit() { - Ok(_) => { - info!("committed {change_count} address changes for epoch {epoch}"); - Ok(()) - } - Err(e) => { - error!("batch commit failed for epoch {epoch}: {e}"); - Err(e.into()) - } + match batch.commit() { + Ok(_) => { + info!("committed {change_count} address changes for epoch {epoch}"); + Ok(()) } - }) - .await??; + Err(e) => { + error!("batch commit failed for epoch {epoch}: {e}"); + Err(e.into()) + } + } + } - Ok(()) + pub async fn update_immutable(&self, drained: Vec>) { + let mut pending = self.pending.lock().await; + pending.extend(drained); } pub async fn get_utxos(&self, address: &Address) -> Result>> { diff --git a/modules/address_state/src/state.rs b/modules/address_state/src/state.rs index 4c82b115..5c1a3594 100644 --- a/modules/address_state/src/state.rs +++ b/modules/address_state/src/state.rs @@ -1,4 +1,8 @@ -use std::collections::HashSet; +use std::{ + collections::HashSet, + path::{Path, PathBuf}, + sync::Arc, +}; use acropolis_common::{ Address, AddressDelta, AddressTotals, BlockInfo, TxIdentifier, UTxOIdentifier, ValueDelta, @@ -9,8 +13,11 @@ use crate::{ immutable_address_store::ImmutableAddressStore, volatile_addresses::VolatileAddresses, }; -#[derive(Debug, Default, Clone, Copy)] +#[derive(Debug, Default, Clone)] pub struct AddressStorageConfig { + pub db_path: String, + pub skip_until: Option, + pub store_info: bool, pub store_totals: bool, pub store_transactions: bool, @@ -37,29 +44,42 @@ pub struct AddressEntry { pub totals: Option>, } -#[derive(Debug, Default, Clone)] +#[derive(Clone)] pub struct State { pub config: AddressStorageConfig, pub volatile: VolatileAddresses, + pub immutable: Arc, } impl State { - pub fn new(config: AddressStorageConfig) -> Self { - Self { + pub async fn new(config: &AddressStorageConfig) -> Result { + let db_path = if Path::new(&config.db_path).is_relative() { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(&config.db_path) + } else { + PathBuf::from(&config.db_path) + }; + + let store = Arc::new(ImmutableAddressStore::new(&db_path)?); + + let mut config = config.clone(); + config.skip_until = store.get_last_epoch_stored().await?; + + Ok(Self { config, volatile: VolatileAddresses::default(), - } + immutable: store, + }) } pub async fn get_address_utxos( &self, - store: &ImmutableAddressStore, address: &Address, ) -> Result>> { if !self.config.store_info { return Err(anyhow::anyhow!("address info storage disabled in config")); } + let store = self.immutable.clone(); let mut combined: HashSet = match store.get_utxos(address).await? { Some(db) => db.into_iter().collect(), None => HashSet::new(), @@ -91,7 +111,6 @@ impl State { pub async fn get_address_transactions( &self, - store: &ImmutableAddressStore, address: &Address, ) -> Result>> { if !self.config.store_transactions { @@ -100,6 +119,8 @@ impl State { )); } + let store = self.immutable.clone(); + let mut combined: Vec = match store.get_txs(address).await? { Some(db) => db, None => Vec::new(), @@ -120,15 +141,13 @@ impl State { } } - pub async fn get_address_totals( - &self, - store: &ImmutableAddressStore, - address: &Address, - ) -> Result { + pub async fn get_address_totals(&self, address: &Address) -> Result { if !self.config.store_totals { anyhow::bail!("address totals storage disabled in config"); } + let store = self.immutable.clone(); + let mut totals = match store.get_totals(address).await? { Some(db) => db, None => AddressTotals::default(), @@ -147,6 +166,11 @@ impl State { Ok(totals) } + pub async fn prune_volatile(&mut self) { + let drained = self.volatile.prune_volatile(); + self.immutable.update_immutable(drained).await; + } + pub fn ready_to_prune(&self, block_info: &BlockInfo) -> bool { block_info.epoch > 0 && Some(block_info.epoch) != self.volatile.last_persisted_epoch @@ -196,7 +220,10 @@ mod tests { } fn test_config() -> AddressStorageConfig { + let dir = tempdir().unwrap(); AddressStorageConfig { + db_path: dir.path().to_string_lossy().into_owned(), + skip_until: None, store_info: true, store_transactions: true, store_totals: true, @@ -207,7 +234,7 @@ mod tests { let tmpdir = tempdir().unwrap(); let store = Arc::new(ImmutableAddressStore::new(tmpdir.path())?); let config = test_config(); - let mut state = State::new(config.clone()); + let mut state = State::new(&config.clone()).await?; state.volatile.epoch_start_block = 1; Ok((store, state)) } @@ -228,7 +255,6 @@ mod tests { let _ = tracing_subscriber::fmt::try_init(); let (store, mut state) = setup_state_and_store().await?; - let config = test_config(); let addr = dummy_address(); let utxo = UTxOIdentifier::new(0, 0, 0); @@ -237,11 +263,15 @@ mod tests { // Apply deltas state.apply_address_deltas(&deltas)?; - // Persist everything - state.volatile.persist_all(store.as_ref(), &config).await?; + // Drain volatile to immutable + state.volatile.epoch_start_block = 1; + state.prune_volatile().await; + + // Perisist immutable to disk + store.persist_epoch(0, &state.config).await?; // Verify persisted UTxOs - let utxos = store.get_utxos(&addr).await?; + let utxos = state.get_address_utxos(&addr).await?; assert!(utxos.is_some()); assert_eq!(utxos.as_ref().unwrap().len(), 1); assert_eq!(utxos.as_ref().unwrap()[0], UTxOIdentifier::new(0, 0, 0)); @@ -262,14 +292,13 @@ mod tests { let _ = tracing_subscriber::fmt::try_init(); let (store, mut state) = setup_state_and_store().await?; - let config = test_config(); let addr = dummy_address(); let utxo = UTxOIdentifier::new(0, 0, 0); // Before processing assert!( - state.get_address_utxos(store.as_ref(), &addr).await?.is_none(), + state.get_address_utxos(&addr).await?.is_none(), "Expected no UTxOs before creation" ); @@ -278,27 +307,35 @@ mod tests { state.apply_address_deltas(&created)?; // After processing creation - let after_create = state.get_address_utxos(store.as_ref(), &addr).await?; + let after_create = state.get_address_utxos(&addr).await?; assert_eq!(after_create.as_ref().unwrap(), &[utxo]); - state.volatile.persist_all(store.as_ref(), &config).await?; + // Drain volatile to immutable + state.volatile.epoch_start_block = 1; + state.prune_volatile().await; + + // Perisist immutable to disk + store.persist_epoch(0, &state.config).await?; // After persisting creation - let after_persist = state.get_address_utxos(store.as_ref(), &addr).await?; + let after_persist = state.get_address_utxos(&addr).await?; assert_eq!(after_persist.as_ref().unwrap(), &[utxo]); state.volatile.next_block(); - state.volatile.epoch_start_block = 2; state.apply_address_deltas(&[delta(&addr, &utxo, -1)])?; // After processing spend - let after_spend_volatile = state.get_address_utxos(store.as_ref(), &addr).await?; + let after_spend_volatile = state.get_address_utxos(&addr).await?; assert!(after_spend_volatile.as_ref().map_or(true, |u| u.is_empty())); - state.volatile.persist_all(store.as_ref(), &config).await?; + // Drain volatile to immutable + state.prune_volatile().await; + + // Perisist immutable to disk + store.persist_epoch(2, &state.config).await?; // After persisting spend - let after_spend_disk = state.get_address_utxos(store.as_ref(), &addr).await?; + let after_spend_disk = state.get_address_utxos(&addr).await?; assert!(after_spend_disk.as_ref().map_or(true, |u| u.is_empty())); Ok(()) @@ -309,7 +346,6 @@ mod tests { let _ = tracing_subscriber::fmt::try_init(); let (store, mut state) = setup_state_and_store().await?; - let config = test_config(); let addr = dummy_address(); let utxo_old = UTxOIdentifier::new(0, 0, 0); @@ -322,7 +358,7 @@ mod tests { state.apply_address_deltas(&[delta(&addr, &utxo_old, -1), delta(&addr, &utxo_new, 1)])?; // Create and spend both in volatile is not included in address utxos - let volatile = state.get_address_utxos(store.as_ref(), &addr).await?; + let volatile = state.get_address_utxos(&addr).await?; assert!( volatile.as_ref().is_some_and(|u| u.contains(&utxo_new) && !u.contains(&utxo_old)), "Expected only new UTxO {:?} in volatile view, got {:?}", @@ -330,10 +366,14 @@ mod tests { volatile ); - state.volatile.persist_all(store.as_ref(), &config).await?; + // Drain volatile to immutable + state.prune_volatile().await; + + // Perisist immutable to disk + store.persist_epoch(0, &state.config).await?; // UTxO not persisted to disk if created and spent in pruned volatile window - let persisted_view = state.get_address_utxos(store.as_ref(), &addr).await?; + let persisted_view = state.get_address_utxos(&addr).await?; assert!( persisted_view .as_ref() diff --git a/modules/address_state/src/volatile_addresses.rs b/modules/address_state/src/volatile_addresses.rs index a5c9aa46..f8d4d429 100644 --- a/modules/address_state/src/volatile_addresses.rs +++ b/modules/address_state/src/volatile_addresses.rs @@ -1,12 +1,8 @@ use std::collections::{HashMap, VecDeque}; use acropolis_common::Address; -use anyhow::Result; -use crate::{ - immutable_address_store::ImmutableAddressStore, - state::{AddressEntry, AddressStorageConfig}, -}; +use crate::state::AddressEntry; #[derive(Debug, Clone)] pub struct VolatileAddresses { @@ -62,20 +58,13 @@ impl VolatileAddresses { out } - pub async fn persist_all( - &mut self, - store: &ImmutableAddressStore, - config: &AddressStorageConfig, - ) -> Result<()> { + pub fn prune_volatile(&mut self) -> Vec> { let epoch = self.last_persisted_epoch.map(|e| e + 1).unwrap_or(0); let blocks_to_drain = (self.epoch_start_block - self.start_block) as usize; - let drained: Vec<_> = self.window.drain(..blocks_to_drain).collect(); - store.persist_epoch(epoch, drained, config).await?; - self.start_block += blocks_to_drain as u64; self.last_persisted_epoch = Some(epoch); - Ok(()) + self.window.drain(..blocks_to_drain).collect() } } diff --git a/modules/utxo_state/src/state.rs b/modules/utxo_state/src/state.rs index e2692709..e722a6d1 100644 --- a/modules/utxo_state/src/state.rs +++ b/modules/utxo_state/src/state.rs @@ -150,7 +150,7 @@ impl State { .observe_delta(&AddressDelta { address: utxo.address.clone(), utxo: key.clone(), - value: ValueDelta::from(&utxo.value), + value: -ValueDelta::from(&utxo.value), }) .await; } diff --git a/processes/omnibus/omnibus.toml b/processes/omnibus/omnibus.toml index 35b64185..43689100 100644 --- a/processes/omnibus/omnibus.toml +++ b/processes/omnibus/omnibus.toml @@ -99,14 +99,12 @@ index-by-policy = false [module.address-state] # Enables /addresses/{address}, /addresses/{address}/extended, -# and /addresses/{address}/utxos endpoints +# /addresses/{address}/utxos/{asset}, and /addresses/{address}/utxos endpoints store-info = true # Enables /addresses/{address}/totals endpoint store-totals = true # Enables /addresses/{address}/transactions endpoint store-transactions = true -# Enables /addresses/{address}/utxos/{asset} endpoint -index-utxos-by-asset = false [module.clock] From 0effd41aa2be2598801ada4c04dc8f212956070c Mon Sep 17 00:00:00 2001 From: William Hankins Date: Mon, 13 Oct 2025 17:41:41 +0000 Subject: [PATCH 13/18] fix: use correct immutable store in address tests Signed-off-by: William Hankins --- .../src/immutable_address_store.rs | 2 -- modules/address_state/src/state.rs | 26 ++++++++----------- 2 files changed, 11 insertions(+), 17 deletions(-) diff --git a/modules/address_state/src/immutable_address_store.rs b/modules/address_state/src/immutable_address_store.rs index a9379499..a17ecbaf 100644 --- a/modules/address_state/src/immutable_address_store.rs +++ b/modules/address_state/src/immutable_address_store.rs @@ -59,8 +59,6 @@ impl ImmutableAddressStore { return Ok(()); } - info!("Pending has {}", self.pending.lock().await.len()); - let drained_blocks = { let mut pending = self.pending.lock().await; std::mem::take(&mut *pending) diff --git a/modules/address_state/src/state.rs b/modules/address_state/src/state.rs index 5c1a3594..75f48cf1 100644 --- a/modules/address_state/src/state.rs +++ b/modules/address_state/src/state.rs @@ -209,8 +209,6 @@ impl State { #[cfg(test)] mod tests { - use std::sync::Arc; - use super::*; use acropolis_common::{Address, AddressDelta, UTxOIdentifier, ValueDelta}; use tempfile::tempdir; @@ -230,13 +228,11 @@ mod tests { } } - async fn setup_state_and_store() -> Result<(Arc, State)> { - let tmpdir = tempdir().unwrap(); - let store = Arc::new(ImmutableAddressStore::new(tmpdir.path())?); + async fn setup_state_and_store() -> Result { let config = test_config(); let mut state = State::new(&config.clone()).await?; state.volatile.epoch_start_block = 1; - Ok((store, state)) + Ok(state) } fn delta(addr: &Address, utxo: &UTxOIdentifier, lovelace: i64) -> AddressDelta { @@ -254,7 +250,7 @@ mod tests { async fn test_persist_all_and_read_back() -> Result<()> { let _ = tracing_subscriber::fmt::try_init(); - let (store, mut state) = setup_state_and_store().await?; + let mut state = setup_state_and_store().await?; let addr = dummy_address(); let utxo = UTxOIdentifier::new(0, 0, 0); @@ -268,7 +264,7 @@ mod tests { state.prune_volatile().await; // Perisist immutable to disk - store.persist_epoch(0, &state.config).await?; + state.immutable.persist_epoch(0, &state.config).await?; // Verify persisted UTxOs let utxos = state.get_address_utxos(&addr).await?; @@ -277,11 +273,11 @@ mod tests { assert_eq!(utxos.as_ref().unwrap()[0], UTxOIdentifier::new(0, 0, 0)); // Totals should exist - let totals = store.get_totals(&addr).await?; + let totals = state.immutable.get_totals(&addr).await?; assert!(totals.is_some()); // Epoch marker advanced - let last_epoch = store.get_last_epoch_stored().await?; + let last_epoch = state.immutable.get_last_epoch_stored().await?; assert_eq!(last_epoch, Some(0)); Ok(()) @@ -291,7 +287,7 @@ mod tests { async fn test_utxo_removed_when_spent() -> Result<()> { let _ = tracing_subscriber::fmt::try_init(); - let (store, mut state) = setup_state_and_store().await?; + let mut state = setup_state_and_store().await?; let addr = dummy_address(); let utxo = UTxOIdentifier::new(0, 0, 0); @@ -315,7 +311,7 @@ mod tests { state.prune_volatile().await; // Perisist immutable to disk - store.persist_epoch(0, &state.config).await?; + state.immutable.persist_epoch(0, &state.config).await?; // After persisting creation let after_persist = state.get_address_utxos(&addr).await?; @@ -332,7 +328,7 @@ mod tests { state.prune_volatile().await; // Perisist immutable to disk - store.persist_epoch(2, &state.config).await?; + state.immutable.persist_epoch(2, &state.config).await?; // After persisting spend let after_spend_disk = state.get_address_utxos(&addr).await?; @@ -345,7 +341,7 @@ mod tests { async fn test_utxo_spent_and_created_across_blocks_in_volatile() -> Result<()> { let _ = tracing_subscriber::fmt::try_init(); - let (store, mut state) = setup_state_and_store().await?; + let mut state = setup_state_and_store().await?; let addr = dummy_address(); let utxo_old = UTxOIdentifier::new(0, 0, 0); @@ -370,7 +366,7 @@ mod tests { state.prune_volatile().await; // Perisist immutable to disk - store.persist_epoch(0, &state.config).await?; + state.immutable.persist_epoch(0, &state.config).await?; // UTxO not persisted to disk if created and spent in pruned volatile window let persisted_view = state.get_address_utxos(&addr).await?; From fcf1ca346679ed7cf2d039f2ce6d322edcdeb5bc Mon Sep 17 00:00:00 2001 From: William Hankins Date: Mon, 13 Oct 2025 21:35:19 +0000 Subject: [PATCH 14/18] fix: match address info REST handler with BF schema Signed-off-by: William Hankins --- .../rest_blockfrost/src/handlers/addresses.rs | 2 +- modules/rest_blockfrost/src/types.rs | 39 ++++++++++++++++++- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/modules/rest_blockfrost/src/handlers/addresses.rs b/modules/rest_blockfrost/src/handlers/addresses.rs index 52ea13db..68da79b6 100644 --- a/modules/rest_blockfrost/src/handlers/addresses.rs +++ b/modules/rest_blockfrost/src/handlers/addresses.rs @@ -111,7 +111,7 @@ pub async fn handle_address_single_blockfrost( let rest_response = AddressInfoREST { address: address_str.to_string(), - amount: address_balance, + amount: address_balance.into(), stake_address, address_type, script: is_script, diff --git a/modules/rest_blockfrost/src/types.rs b/modules/rest_blockfrost/src/types.rs index 754961f9..542a5026 100644 --- a/modules/rest_blockfrost/src/types.rs +++ b/modules/rest_blockfrost/src/types.rs @@ -785,12 +785,47 @@ impl TryFrom<&AssetAddressEntry> for AssetAddressRest { } } -#[derive(Debug, Serialize)] +#[derive(Serialize)] pub struct AddressInfoREST { pub address: String, - pub amount: acropolis_common::Value, + pub amount: AmountList, pub stake_address: Option, #[serde(rename = "type")] pub address_type: String, pub script: bool, } + +#[derive(Serialize)] +pub struct AmountEntry { + unit: String, + quantity: String, +} + +#[derive(Serialize)] +pub struct AmountList(pub Vec); + +impl From for AmountList { + fn from(value: acropolis_common::Value) -> Self { + let mut out = Vec::new(); + + out.push(AmountEntry { + unit: "lovelace".to_string(), + quantity: value.coin().to_string(), + }); + + for (policy_id, assets) in value.assets { + for asset in assets { + out.push(AmountEntry { + unit: format!( + "{}{}", + hex::encode(&policy_id), + hex::encode(&asset.name.as_slice()) + ), + quantity: asset.amount.to_string(), + }); + } + } + + Self(out) + } +} From 0ac0b67b9bd1f95e259d0942be2fca9cf7a10125 Mon Sep 17 00:00:00 2001 From: William Hankins Date: Mon, 13 Oct 2025 21:56:53 +0000 Subject: [PATCH 15/18] fix: enforce sequential epoch persistence and merge pending state in getters Signed-off-by: William Hankins --- modules/address_state/src/address_state.rs | 33 +++++-- .../src/immutable_address_store.rs | 86 +++++++++++++------ modules/address_state/src/state.rs | 49 ++++++----- 3 files changed, 110 insertions(+), 58 deletions(-) diff --git a/modules/address_state/src/address_state.rs b/modules/address_state/src/address_state.rs index d298516b..1215bf87 100644 --- a/modules/address_state/src/address_state.rs +++ b/modules/address_state/src/address_state.rs @@ -14,10 +14,13 @@ use acropolis_common::{ use anyhow::Result; use caryatid_sdk::{module, Context, Module, Subscription}; use config::Config; -use tokio::sync::Mutex; +use tokio::sync::{mpsc, Mutex}; use tracing::{error, info}; -use crate::state::{AddressStorageConfig, State}; +use crate::{ + immutable_address_store::ImmutableAddressStore, + state::{AddressStorageConfig, State}, +}; mod immutable_address_store; mod state; mod volatile_addresses; @@ -51,6 +54,20 @@ impl AddressState { let _ = params_subscription.read().await?; info!("Consumed initial genesis params from params_subscription"); + // Background task to persist epochs sequentialy + const MAX_PENDING_PERSISTS: usize = 1; + let (persist_tx, mut persist_rx) = + mpsc::channel::<(u64, Arc, AddressStorageConfig)>( + MAX_PENDING_PERSISTS, + ); + tokio::spawn(async move { + while let Some((epoch, store, config)) = persist_rx.recv().await { + if let Err(e) = store.persist_epoch(epoch, &config).await { + error!("failed to persist epoch {epoch}: {e}"); + } + } + }); + // Main loop of synchronised messages loop { // Address deltas are the synchroniser @@ -116,13 +133,11 @@ impl AddressState { } if should_prune { - tokio::spawn(async move { - if let Err(e) = store.persist_epoch(epoch, &config).await { - error!("failed to persist epoch {}: {}", epoch, e); - } else { - info!("persisted address state for epoch {}", epoch); - } - }); + if let Err(e) = + persist_tx.send((epoch, store.clone(), config.clone())).await + { + panic!("persistence worker crashed: {e}"); + } } { diff --git a/modules/address_state/src/immutable_address_store.rs b/modules/address_state/src/immutable_address_store.rs index a17ecbaf..391f2716 100644 --- a/modules/address_state/src/immutable_address_store.rs +++ b/modules/address_state/src/immutable_address_store.rs @@ -172,41 +172,79 @@ impl ImmutableAddressStore { pub async fn get_utxos(&self, address: &Address) -> Result>> { let key = address.to_bytes_key()?; - let partition = self.utxos.clone(); - task::spawn_blocking(move || match partition.get(key)? { - Some(bytes) => { - let decoded: Vec = decode(&bytes)?; - Ok(Some(decoded)) + + let mut live: HashSet = + self.utxos.get(&key)?.map(|bytes| decode(&bytes)).transpose()?.unwrap_or_default(); + + let pending = self.pending.lock().await; + for block_map in pending.iter() { + if let Some(entry) = block_map.get(address) { + if let Some(deltas) = &entry.utxos { + for delta in deltas { + match delta { + UtxoDelta::Created(u) => { + live.insert(*u); + } + UtxoDelta::Spent(u) => { + live.remove(u); + } + } + } + } } - None => Ok(None), - }) - .await? + } + + if live.is_empty() { + Ok(None) + } else { + let vec: Vec<_> = live.into_iter().collect(); + Ok(Some(vec)) + } } pub async fn get_txs(&self, address: &Address) -> Result>> { let key = address.to_bytes_key()?; - let partition = self.txs.clone(); - task::spawn_blocking(move || match partition.get(key)? { - Some(bytes) => { - let decoded: Vec = decode(&bytes)?; - Ok(Some(decoded)) + let mut live: Vec = + self.txs.get(&key)?.map(|bytes| decode(&bytes)).transpose()?.unwrap_or_default(); + + let pending = self.pending.lock().await; + for block_map in pending.iter() { + if let Some(entry) = block_map.get(address) { + if let Some(txs) = &entry.transactions { + live.extend(txs.iter().cloned()); + } } - None => Ok(None), - }) - .await? + } + + if live.is_empty() { + Ok(None) + } else { + Ok(Some(live)) + } } pub async fn get_totals(&self, address: &Address) -> Result> { let key = address.to_bytes_key()?; - let partition = self.totals.clone(); - task::spawn_blocking(move || match partition.get(key)? { - Some(bytes) => { - let decoded: AddressTotals = decode(&bytes)?; - Ok(Some(decoded)) + + let mut live: AddressTotals = + self.totals.get(&key)?.map(|bytes| decode(&bytes)).transpose()?.unwrap_or_default(); + + let pending = self.pending.lock().await; + for block_map in pending.iter() { + if let Some(entry) = block_map.get(address) { + if let Some(deltas) = &entry.totals { + for delta in deltas { + live.apply_delta(delta); + } + } } - None => Ok(None), - }) - .await? + } + + if live.tx_count == 0 { + Ok(None) + } else { + Ok(Some(live)) + } } pub async fn get_last_epoch_stored(&self) -> Result> { diff --git a/modules/address_state/src/state.rs b/modules/address_state/src/state.rs index 75f48cf1..d54d9265 100644 --- a/modules/address_state/src/state.rs +++ b/modules/address_state/src/state.rs @@ -247,7 +247,7 @@ mod tests { } #[tokio::test] - async fn test_persist_all_and_read_back() -> Result<()> { + async fn test_utxo_storage_lifecycle() -> Result<()> { let _ = tracing_subscriber::fmt::try_init(); let mut state = setup_state_and_store().await?; @@ -259,27 +259,31 @@ mod tests { // Apply deltas state.apply_address_deltas(&deltas)?; + // Verify UTxO is retrievable when in volatile + let utxos = state.get_address_utxos(&addr).await?; + assert!(utxos.is_some()); + assert_eq!(utxos.as_ref().unwrap().len(), 1); + assert_eq!(utxos.as_ref().unwrap()[0], UTxOIdentifier::new(0, 0, 0)); + // Drain volatile to immutable state.volatile.epoch_start_block = 1; state.prune_volatile().await; + // Verify UTxO is retrievable when in immutable pending + let utxos = state.get_address_utxos(&addr).await?; + assert!(utxos.is_some()); + assert_eq!(utxos.as_ref().unwrap().len(), 1); + assert_eq!(utxos.as_ref().unwrap()[0], UTxOIdentifier::new(0, 0, 0)); + // Perisist immutable to disk state.immutable.persist_epoch(0, &state.config).await?; - // Verify persisted UTxOs + // Verify UTxO is retrievable after persisted to disk let utxos = state.get_address_utxos(&addr).await?; assert!(utxos.is_some()); assert_eq!(utxos.as_ref().unwrap().len(), 1); assert_eq!(utxos.as_ref().unwrap()[0], UTxOIdentifier::new(0, 0, 0)); - // Totals should exist - let totals = state.immutable.get_totals(&addr).await?; - assert!(totals.is_some()); - - // Epoch marker advanced - let last_epoch = state.immutable.get_last_epoch_stored().await?; - assert_eq!(last_epoch, Some(0)); - Ok(()) } @@ -292,45 +296,40 @@ mod tests { let addr = dummy_address(); let utxo = UTxOIdentifier::new(0, 0, 0); - // Before processing - assert!( - state.get_address_utxos(&addr).await?.is_none(), - "Expected no UTxOs before creation" - ); - let created = vec![delta(&addr, &utxo, 1)]; + // Apply delta to volatile state.apply_address_deltas(&created)?; - // After processing creation - let after_create = state.get_address_utxos(&addr).await?; - assert_eq!(after_create.as_ref().unwrap(), &[utxo]); - - // Drain volatile to immutable + // Drain volatile to immutable pending state.volatile.epoch_start_block = 1; state.prune_volatile().await; // Perisist immutable to disk state.immutable.persist_epoch(0, &state.config).await?; - // After persisting creation + // Verify UTxO was persisted let after_persist = state.get_address_utxos(&addr).await?; assert_eq!(after_persist.as_ref().unwrap(), &[utxo]); state.volatile.next_block(); state.apply_address_deltas(&[delta(&addr, &utxo, -1)])?; - // After processing spend + // Verify UTxO was removed while in volatile let after_spend_volatile = state.get_address_utxos(&addr).await?; assert!(after_spend_volatile.as_ref().map_or(true, |u| u.is_empty())); // Drain volatile to immutable state.prune_volatile().await; + // Verify UTxO was removed while in pending immutable + let after_spend_pending = state.get_address_utxos(&addr).await?; + assert!(after_spend_pending.as_ref().map_or(true, |u| u.is_empty())); + // Perisist immutable to disk state.immutable.persist_epoch(2, &state.config).await?; - // After persisting spend + // Verify UTxO was removed after persisting spend to disk let after_spend_disk = state.get_address_utxos(&addr).await?; assert!(after_spend_disk.as_ref().map_or(true, |u| u.is_empty())); @@ -353,7 +352,7 @@ mod tests { state.volatile.next_block(); state.apply_address_deltas(&[delta(&addr, &utxo_old, -1), delta(&addr, &utxo_new, 1)])?; - // Create and spend both in volatile is not included in address utxos + // Verify Create and spend both in volatile is not included in address utxos let volatile = state.get_address_utxos(&addr).await?; assert!( volatile.as_ref().is_some_and(|u| u.contains(&utxo_new) && !u.contains(&utxo_old)), From b016518cc7c6fe9626d6e658f80c98ea44980e22 Mon Sep 17 00:00:00 2001 From: William Hankins Date: Mon, 13 Oct 2025 22:17:15 +0000 Subject: [PATCH 16/18] fix address info response format for addresses with no UTxOs Signed-off-by: William Hankins --- .../rest_blockfrost/src/handlers/addresses.rs | 36 +++++++++++++++---- processes/omnibus/omnibus.toml | 6 ++-- 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/modules/rest_blockfrost/src/handlers/addresses.rs b/modules/rest_blockfrost/src/handlers/addresses.rs index 68da79b6..6bb16c03 100644 --- a/modules/rest_blockfrost/src/handlers/addresses.rs +++ b/modules/rest_blockfrost/src/handlers/addresses.rs @@ -8,7 +8,7 @@ use acropolis_common::{ utils::query_state, utxos::{UTxOStateQuery, UTxOStateQueryResponse}, }, - Address, + Address, Value, }; use caryatid_sdk::Context; @@ -59,26 +59,48 @@ pub async fn handle_address_single_blockfrost( AddressStateQuery::GetAddressUTxOs { address }, ))); - let utxo_identifiers = match query_state( + let utxo_query_result = query_state( &context, &handlers_config.addresses_query_topic, address_query_msg, |message| match message { Message::StateQueryResponse(StateQueryResponse::Addresses( AddressStateQueryResponse::AddressUTxOs(utxo_identifiers), - )) => Ok(utxo_identifiers), + )) => Ok(Some(utxo_identifiers)), + Message::StateQueryResponse(StateQueryResponse::Addresses( AddressStateQueryResponse::NotFound, - )) => Err(anyhow::anyhow!("Address not found")), + )) => Ok(None), + Message::StateQueryResponse(StateQueryResponse::Addresses( AddressStateQueryResponse::Error(_), )) => Err(anyhow::anyhow!("Address info storage disabled")), + _ => Err(anyhow::anyhow!("Unexpected response")), }, ) - .await - { - Ok(utxo_identifiers) => utxo_identifiers, + .await; + + let utxo_identifiers = match utxo_query_result { + Ok(Some(utxo_identifiers)) => utxo_identifiers, + Ok(None) => { + let rest_response = AddressInfoREST { + address: address_str.to_string(), + amount: Value { + lovelace: 0, + assets: Vec::new(), + } + .into(), + stake_address, + address_type, + script: is_script, + }; + + let json = serde_json::to_string_pretty(&rest_response) + .map_err(|e| anyhow::anyhow!("JSON serialization error: {e}"))?; + + return Ok(RESTResponse::with_json(200, &json)); + } Err(e) => return Ok(RESTResponse::with_text(500, &format!("Query failed: {e}"))), }; diff --git a/processes/omnibus/omnibus.toml b/processes/omnibus/omnibus.toml index ee86e8c5..258fe3ab 100644 --- a/processes/omnibus/omnibus.toml +++ b/processes/omnibus/omnibus.toml @@ -101,11 +101,11 @@ index-by-policy = false [module.address-state] # Enables /addresses/{address}, /addresses/{address}/extended, # /addresses/{address}/utxos/{asset}, and /addresses/{address}/utxos endpoints -store-info = true +store-info = false # Enables /addresses/{address}/totals endpoint -store-totals = true +store-totals = false # Enables /addresses/{address}/transactions endpoint -store-transactions = true +store-transactions = false [module.clock] From a1767e66e4ff2872f66612863923f73a9e82a9c4 Mon Sep 17 00:00:00 2001 From: William Hankins Date: Tue, 14 Oct 2025 19:12:26 +0000 Subject: [PATCH 17/18] test: add coverage forShelleyAddress stake_address_string method Signed-off-by: William Hankins --- common/src/address.rs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/common/src/address.rs b/common/src/address.rs index 3a2f8c82..f31dc302 100644 --- a/common/src/address.rs +++ b/common/src/address.rs @@ -747,6 +747,30 @@ mod tests { assert_eq!(address, unpacked); } + #[test] + fn shelley_to_stake_address_string_mainnet() { + let normal_address = ShelleyAddress::from_string("addr1q82peck5fynytkgjsp9vnpul59zswsd4jqnzafd0mfzykma625r684xsx574ltpznecr9cnc7n9e2hfq9lyart3h5hpszffds5").expect("valid normal address"); + let script_address = ShelleyAddress::from_string("addr1zx0whlxaw4ksygvuljw8jxqlw906tlql06ern0gtvvzhh0c6409492020k6xml8uvwn34wrexagjh5fsk5xk96jyxk2qhlj6gf").expect("valid script address"); + + let normal_stake_address = normal_address + .stake_address_string() + .expect("stake_address_string should not fail") + .expect("normal address should have stake credential"); + let script_stake_address = script_address + .stake_address_string() + .expect("stake_address_string should not fail") + .expect("script address should have stake credential"); + + assert_eq!( + normal_stake_address, + "stake1uxa92par6ngr202l4s3fuupjufu0fju4t5szljw34cm6tscq40449" + ); + assert_eq!( + script_stake_address, + "stake1uyd2hj6j4848mdrdln7x8fc6hpunw5ft6yct2rtzafzrt9qh0m28h" + ); + } + #[test] fn stake_address_from_binary_mainnet_stake() { // First withdrawal on Mainnet From 3dfa776926cc99530b7b4bc6661d6bb58031e1aa Mon Sep 17 00:00:00 2001 From: William Hankins Date: Tue, 14 Oct 2025 19:17:06 +0000 Subject: [PATCH 18/18] fix: add comments to explain which endpoints a REST handler covers Signed-off-by: William Hankins --- modules/rest_blockfrost/src/handlers/addresses.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/modules/rest_blockfrost/src/handlers/addresses.rs b/modules/rest_blockfrost/src/handlers/addresses.rs index 6bb16c03..8a7741bd 100644 --- a/modules/rest_blockfrost/src/handlers/addresses.rs +++ b/modules/rest_blockfrost/src/handlers/addresses.rs @@ -14,6 +14,7 @@ use caryatid_sdk::Context; use crate::{handlers_config::HandlersConfig, types::AddressInfoREST}; +/// Handle `/addresses/{address}` Blockfrost-compatible endpoint pub async fn handle_address_single_blockfrost( context: Arc>, params: Vec, @@ -148,6 +149,7 @@ pub async fn handle_address_single_blockfrost( } } +/// Handle `/addresses/{address}/extended` Blockfrost-compatible endpoint pub async fn handle_address_extended_blockfrost( _context: Arc>, _params: Vec, @@ -156,6 +158,7 @@ pub async fn handle_address_extended_blockfrost( Ok(RESTResponse::with_text(501, "Not implemented")) } +/// Handle `/addresses/{address}/totals` Blockfrost-compatible endpoint pub async fn handle_address_totals_blockfrost( _context: Arc>, _params: Vec, @@ -164,6 +167,7 @@ pub async fn handle_address_totals_blockfrost( Ok(RESTResponse::with_text(501, "Not implemented")) } +/// Handle `/addresses/{address}/utxos` Blockfrost-compatible endpoint pub async fn handle_address_utxos_blockfrost( _context: Arc>, _params: Vec, @@ -172,6 +176,7 @@ pub async fn handle_address_utxos_blockfrost( Ok(RESTResponse::with_text(501, "Not implemented")) } +/// Handle `/addresses/{address}/utxos/{asset}` Blockfrost-compatible endpoint pub async fn handle_address_asset_utxos_blockfrost( _context: Arc>, _params: Vec, @@ -180,6 +185,7 @@ pub async fn handle_address_asset_utxos_blockfrost( Ok(RESTResponse::with_text(501, "Not implemented")) } +/// Handle `/addresses/{address}/transactions` Blockfrost-compatible endpoint pub async fn handle_address_transactions_blockfrost( _context: Arc>, _params: Vec,