commit ed217e0c26cd53c77e190780551efc250e800dfc
parent ffd05f802b88146b73bbca37f3c178dc002112b9
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Fri, 7 Aug 2026 13:30:11 +0200
Fix metrics accounting and desktop sleep
Diffstat:
3 files changed, 500 insertions(+), 67 deletions(-)
diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs
@@ -8,8 +8,10 @@ use tauri::WindowEvent;
use tauri_plugin_shell::{ShellExt, process::CommandChild};
struct IunaSidecar(Mutex<Option<CommandChild>>);
+struct IunaSleepInhibitor(Mutex<Option<SleepInhibitor>>);
static SIDECAR: OnceLock<IunaSidecar> = OnceLock::new();
+static SLEEP_INHIBITOR: OnceLock<IunaSleepInhibitor> = OnceLock::new();
fn main() {
tauri::Builder::default()
@@ -22,6 +24,8 @@ fn main() {
.spawn()
.map_err(setup_error)?;
+ keep_system_awake_while_node_runs();
+
tauri::async_runtime::spawn(async move { while events.recv().await.is_some() {} });
let sidecar = SIDECAR.get_or_init(|| IunaSidecar(Mutex::new(None)));
@@ -46,14 +50,238 @@ fn main() {
}
fn stop_sidecar() {
- let Some(sidecar) = SIDECAR.get() else {
- return;
- };
- if let Some(child) = sidecar.0.lock().expect("sidecar mutex poisoned").take() {
- let _ = child.kill();
+ if let Some(sidecar) = SIDECAR.get() {
+ if let Some(child) = sidecar.0.lock().expect("sidecar mutex poisoned").take() {
+ let _ = child.kill();
+ }
}
+ release_system_awake_inhibitor();
}
fn setup_error(error: impl Error + Send + Sync + 'static) -> Box<dyn Error> {
Box::new(io::Error::other(error))
}
+
+fn keep_system_awake_while_node_runs() {
+ match SleepInhibitor::acquire() {
+ Ok(inhibitor) => {
+ let store = SLEEP_INHIBITOR.get_or_init(|| IunaSleepInhibitor(Mutex::new(None)));
+ *store.0.lock().expect("sleep inhibitor mutex poisoned") = Some(inhibitor);
+ }
+ Err(error) => {
+ eprintln!("iuna desktop could not prevent system sleep: {error}");
+ }
+ }
+}
+
+fn release_system_awake_inhibitor() {
+ let Some(store) = SLEEP_INHIBITOR.get() else {
+ return;
+ };
+ let _ = store
+ .0
+ .lock()
+ .expect("sleep inhibitor mutex poisoned")
+ .take();
+}
+
+#[cfg(target_os = "macos")]
+struct SleepInhibitor {
+ assertion_id: u32,
+}
+
+#[cfg(target_os = "macos")]
+impl SleepInhibitor {
+ fn acquire() -> io::Result<Self> {
+ macos_sleep::prevent_idle_system_sleep("iuna node is running")
+ .map(|assertion_id| Self { assertion_id })
+ }
+}
+
+#[cfg(target_os = "macos")]
+impl Drop for SleepInhibitor {
+ fn drop(&mut self) {
+ let _ = macos_sleep::release_assertion(self.assertion_id);
+ }
+}
+
+#[cfg(target_os = "macos")]
+mod macos_sleep {
+ use std::{
+ ffi::{CString, c_char, c_void},
+ io, ptr,
+ };
+
+ type CFStringRef = *const c_void;
+ type IOReturn = i32;
+
+ const K_CF_STRING_ENCODING_UTF8: u32 = 0x0800_0100;
+ const K_IOPM_ASSERTION_LEVEL_ON: u32 = 255;
+ const K_IO_RETURN_SUCCESS: IOReturn = 0;
+
+ #[link(name = "CoreFoundation", kind = "framework")]
+ unsafe extern "C" {
+ fn CFStringCreateWithCString(
+ alloc: *const c_void,
+ c_str: *const c_char,
+ encoding: u32,
+ ) -> CFStringRef;
+ fn CFRelease(cf: *const c_void);
+ }
+
+ #[link(name = "IOKit", kind = "framework")]
+ unsafe extern "C" {
+ fn IOPMAssertionCreateWithName(
+ assertion_type: CFStringRef,
+ assertion_level: u32,
+ assertion_name: CFStringRef,
+ assertion_id: *mut u32,
+ ) -> IOReturn;
+ fn IOPMAssertionRelease(assertion_id: u32) -> IOReturn;
+ }
+
+ pub fn prevent_idle_system_sleep(reason: &str) -> io::Result<u32> {
+ let assertion_type = cf_string("PreventUserIdleSystemSleep")?;
+ let assertion_name = cf_string(reason)?;
+ let mut assertion_id = 0_u32;
+ let result = unsafe {
+ IOPMAssertionCreateWithName(
+ assertion_type,
+ K_IOPM_ASSERTION_LEVEL_ON,
+ assertion_name,
+ &mut assertion_id,
+ )
+ };
+ unsafe {
+ CFRelease(assertion_type);
+ CFRelease(assertion_name);
+ }
+ if result == K_IO_RETURN_SUCCESS {
+ Ok(assertion_id)
+ } else {
+ Err(io::Error::other(format!(
+ "IOPMAssertionCreateWithName failed with status {result}"
+ )))
+ }
+ }
+
+ pub fn release_assertion(assertion_id: u32) -> io::Result<()> {
+ let result = unsafe { IOPMAssertionRelease(assertion_id) };
+ if result == K_IO_RETURN_SUCCESS {
+ Ok(())
+ } else {
+ Err(io::Error::other(format!(
+ "IOPMAssertionRelease failed with status {result}"
+ )))
+ }
+ }
+
+ fn cf_string(value: &str) -> io::Result<CFStringRef> {
+ let value = CString::new(value)
+ .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
+ let cf_string = unsafe {
+ CFStringCreateWithCString(ptr::null(), value.as_ptr(), K_CF_STRING_ENCODING_UTF8)
+ };
+ if cf_string.is_null() {
+ Err(io::Error::other("CFStringCreateWithCString returned null"))
+ } else {
+ Ok(cf_string)
+ }
+ }
+}
+
+#[cfg(target_os = "windows")]
+struct SleepInhibitor;
+
+#[cfg(target_os = "windows")]
+impl SleepInhibitor {
+ fn acquire() -> io::Result<Self> {
+ windows_sleep::prevent_idle_system_sleep()?;
+ Ok(Self)
+ }
+}
+
+#[cfg(target_os = "windows")]
+impl Drop for SleepInhibitor {
+ fn drop(&mut self) {
+ let _ = windows_sleep::clear_sleep_requirement();
+ }
+}
+
+#[cfg(target_os = "windows")]
+mod windows_sleep {
+ use std::io;
+
+ const ES_CONTINUOUS: u32 = 0x8000_0000;
+ const ES_SYSTEM_REQUIRED: u32 = 0x0000_0001;
+
+ #[link(name = "kernel32")]
+ unsafe extern "system" {
+ fn SetThreadExecutionState(es_flags: u32) -> u32;
+ }
+
+ pub fn prevent_idle_system_sleep() -> io::Result<()> {
+ set_execution_state(ES_CONTINUOUS | ES_SYSTEM_REQUIRED)
+ }
+
+ pub fn clear_sleep_requirement() -> io::Result<()> {
+ set_execution_state(ES_CONTINUOUS)
+ }
+
+ fn set_execution_state(flags: u32) -> io::Result<()> {
+ let previous = unsafe { SetThreadExecutionState(flags) };
+ if previous == 0 {
+ Err(io::Error::last_os_error())
+ } else {
+ Ok(())
+ }
+ }
+}
+
+#[cfg(target_os = "linux")]
+struct SleepInhibitor {
+ child: Option<std::process::Child>,
+}
+
+#[cfg(target_os = "linux")]
+impl SleepInhibitor {
+ fn acquire() -> io::Result<Self> {
+ let parent_pid = std::process::id().to_string();
+ let child = std::process::Command::new("systemd-inhibit")
+ .args([
+ "--what=sleep",
+ "--mode=block",
+ "--why=iuna node is running",
+ "sh",
+ "-c",
+ "trap 'exit 0' TERM; while kill -0 \"$1\" 2>/dev/null; do sleep 30 & wait $!; done",
+ "iuna-sleep-inhibitor",
+ &parent_pid,
+ ])
+ .stdin(std::process::Stdio::null())
+ .stdout(std::process::Stdio::null())
+ .stderr(std::process::Stdio::null())
+ .spawn()?;
+ Ok(Self { child: Some(child) })
+ }
+}
+
+#[cfg(target_os = "linux")]
+impl Drop for SleepInhibitor {
+ fn drop(&mut self) {
+ if let Some(mut child) = self.child.take() {
+ let _ = child.kill();
+ let _ = child.wait();
+ }
+ }
+}
+
+#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
+struct SleepInhibitor;
+
+#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
+impl SleepInhibitor {
+ fn acquire() -> io::Result<Self> {
+ Ok(Self)
+ }
+}
diff --git a/src/adapters/chain_store.rs b/src/adapters/chain_store.rs
@@ -1,4 +1,5 @@
use std::{
+ collections::BTreeSet,
fs,
path::{Path, PathBuf},
time::{SystemTime, UNIX_EPOCH},
@@ -12,7 +13,7 @@ use serde::Serialize;
use crate::domain::{
Amount, BLINDED_COMMITTER_FEE_BPS, BLINDED_FEE_BPS_DENOMINATOR,
BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS, BlindedReveal, BlindedTransaction, Block, ChainSnapshot,
- FinalizerMode, LaunchProfile, LeaderProof, Ledger, MINE_REWARD, MaskedBlindedReveal, OutPoint,
+ FinalizerMode, LaunchProfile, LeaderProof, Ledger, MaskedBlindedReveal, OutPoint,
REVEAL_COMMITTEE_SIZE, RevealBundleSection, RevealBundleSignature, Transaction, TxInput,
TxOutput, blinded_reveal_finalizer_fee, revealed_blinded_transactions,
};
@@ -33,6 +34,7 @@ CREATE TABLE IF NOT EXISTS block_metrics (
block_time_ms INTEGER,
mine_difficulty_bits INTEGER NOT NULL,
circulating_supply INTEGER NOT NULL,
+ known_wallet_addresses INTEGER NOT NULL DEFAULT 0,
transaction_count INTEGER NOT NULL,
transfer_count INTEGER NOT NULL,
burn_count INTEGER NOT NULL,
@@ -55,6 +57,7 @@ pub struct BlockMetricRow {
pub block_time_ms: Option<u64>,
pub mine_difficulty_bits: u32,
pub circulating_supply: Amount,
+ pub known_wallet_addresses: u64,
pub transaction_count: u64,
pub transfer_count: u64,
pub burn_count: u64,
@@ -88,7 +91,13 @@ impl SqliteChainStore {
store.with_connection(|connection| {
connection
.execute_batch(SCHEMA)
- .context("failed to initialize chain database schema")
+ .context("failed to initialize chain database schema")?;
+ ensure_block_metrics_column(
+ connection,
+ "known_wallet_addresses",
+ "INTEGER NOT NULL DEFAULT 0",
+ )?;
+ Ok(())
})?;
Ok(store)
}
@@ -190,7 +199,7 @@ ON CONFLICT(id) DO UPDATE SET
.prepare(
r#"
SELECT height, block_hash, timestamp_ms, block_time_ms, mine_difficulty_bits,
- circulating_supply, transaction_count, transfer_count, burn_count,
+ circulating_supply, known_wallet_addresses, transaction_count, transfer_count, burn_count,
mine_count, burned_amount, total_burned_amount, fees_amount, reward_amount,
vdf_rounds, finalizer_rank
FROM block_metrics
@@ -207,16 +216,17 @@ ORDER BY height ASC
block_time_ms: row.get(3)?,
mine_difficulty_bits: row.get(4)?,
circulating_supply: row.get(5)?,
- transaction_count: row.get(6)?,
- transfer_count: row.get(7)?,
- burn_count: row.get(8)?,
- mine_count: row.get(9)?,
- burned_amount: row.get(10)?,
- total_burned_amount: row.get(11)?,
- fees_amount: row.get(12)?,
- reward_amount: row.get(13)?,
- vdf_rounds: row.get(14)?,
- finalizer_rank: row.get(15)?,
+ known_wallet_addresses: row.get(6)?,
+ transaction_count: row.get(7)?,
+ transfer_count: row.get(8)?,
+ burn_count: row.get(9)?,
+ mine_count: row.get(10)?,
+ burned_amount: row.get(11)?,
+ total_burned_amount: row.get(12)?,
+ fees_amount: row.get(13)?,
+ reward_amount: row.get(14)?,
+ vdf_rounds: row.get(15)?,
+ finalizer_rank: row.get(16)?,
})
})
.context("failed to load block metrics")?;
@@ -265,9 +275,10 @@ fn replace_metrics(
r#"
INSERT INTO block_metrics (
height, block_hash, timestamp_ms, block_time_ms, mine_difficulty_bits,
- circulating_supply, transaction_count, transfer_count, burn_count, mine_count,
- burned_amount, total_burned_amount, fees_amount, reward_amount, vdf_rounds, finalizer_rank
-) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)
+ circulating_supply, known_wallet_addresses, transaction_count, transfer_count, burn_count,
+ mine_count, burned_amount, total_burned_amount, fees_amount, reward_amount, vdf_rounds,
+ finalizer_rank
+) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)
"#,
params![
metric.height,
@@ -276,6 +287,7 @@ INSERT INTO block_metrics (
metric.block_time_ms,
metric.mine_difficulty_bits,
metric.circulating_supply,
+ metric.known_wallet_addresses,
metric.transaction_count,
metric.transfer_count,
metric.burn_count,
@@ -293,6 +305,31 @@ INSERT INTO block_metrics (
Ok(())
}
+fn ensure_block_metrics_column(
+ connection: &Connection,
+ name: &str,
+ definition: &str,
+) -> Result<()> {
+ let mut statement = connection
+ .prepare("PRAGMA table_info(block_metrics)")
+ .context("failed to inspect block_metrics schema")?;
+ let columns = statement
+ .query_map([], |row| row.get::<_, String>(1))
+ .context("failed to query block_metrics columns")?
+ .collect::<std::result::Result<Vec<_>, _>>()
+ .context("failed to read block_metrics columns")?;
+ if columns.iter().any(|column| column == name) {
+ return Ok(());
+ }
+ connection
+ .execute(
+ &format!("ALTER TABLE block_metrics ADD COLUMN {name} {definition}"),
+ [],
+ )
+ .with_context(|| format!("failed to add block_metrics.{name} column"))?;
+ Ok(())
+}
+
fn clear_metrics_in_transaction(transaction: &rusqlite::Transaction<'_>) -> Result<()> {
transaction
.execute("DELETE FROM block_metrics", [])
@@ -886,6 +923,18 @@ fn hex_encode(bytes: impl AsRef<[u8]>) -> String {
fn metrics_from_snapshot(snapshot: &ChainSnapshot) -> Result<Vec<BlockMetricRow>> {
let ledger = Ledger::from_persisted_snapshot(snapshot.clone())
.context("failed to rebuild ledger for metrics")?;
+ let genesis = snapshot
+ .blocks
+ .first()
+ .cloned()
+ .context("cannot compute metrics for empty chain snapshot")?;
+ let mut running_ledger = Ledger::from_persisted_snapshot(ChainSnapshot {
+ genesis_allocations: snapshot.genesis_allocations.clone(),
+ vdf_rounds: snapshot.vdf_rounds,
+ launch_profile: snapshot.launch_profile.clone(),
+ blocks: vec![genesis],
+ })
+ .context("failed to rebuild genesis ledger for metrics")?;
let revealed = revealed_blinded_transactions(snapshot)?.into_iter().fold(
std::collections::BTreeMap::<u64, Vec<crate::domain::RevealedBlindedTransaction>>::new(),
|mut by_height, revealed| {
@@ -893,15 +942,11 @@ fn metrics_from_snapshot(snapshot: &ChainSnapshot) -> Result<Vec<BlockMetricRow>
by_height
},
);
- let mut circulating_supply =
- snapshot
- .genesis_allocations
- .values()
- .try_fold(0_u64, |total, amount| {
- total
- .checked_add(*amount)
- .context("genesis allocation total overflows")
- })?;
+ let mut known_wallet_addresses = snapshot
+ .genesis_allocations
+ .keys()
+ .cloned()
+ .collect::<BTreeSet<_>>();
let mut total_burned_amount = 0_u64;
let mut rows = Vec::with_capacity(snapshot.blocks.len());
let mut previous_timestamp_ms = None;
@@ -914,10 +959,14 @@ fn metrics_from_snapshot(snapshot: &ChainSnapshot) -> Result<Vec<BlockMetricRow>
let mut mine_count = 0_u64;
let mut burned_amount = 0_u64;
let mut burned_fee_amount = 0_u64;
- let mut mine_issued_amount = 0_u64;
let mut fees_amount = 0_u64;
+ known_wallet_addresses.insert(block.miner.clone());
+ for signature in &block.reveal_bundle_section.signatures {
+ known_wallet_addresses.insert(signature.member.clone());
+ }
for transaction in &block.transactions {
+ collect_transaction_addresses(transaction, &mut known_wallet_addresses);
fees_amount = fees_amount
.checked_add(transaction.fee())
.context("block metric fees overflow")?;
@@ -931,14 +980,13 @@ fn metrics_from_snapshot(snapshot: &ChainSnapshot) -> Result<Vec<BlockMetricRow>
}
Transaction::Mine { .. } => {
mine_count += 1;
- mine_issued_amount = mine_issued_amount
- .checked_add(MINE_REWARD)
- .context("block metric mine issuance overflow")?;
}
}
}
for revealed in &revealed_transactions {
let transaction = &revealed.transaction;
+ known_wallet_addresses.insert(revealed.included_by.clone());
+ collect_transaction_addresses(transaction, &mut known_wallet_addresses);
fees_amount = fees_amount
.checked_add(transaction.fee())
.context("block metric fees overflow")?;
@@ -972,10 +1020,6 @@ fn metrics_from_snapshot(snapshot: &ChainSnapshot) -> Result<Vec<BlockMetricRow>
}
Transaction::Mine { .. } => {
mine_count += 1;
- mine_issued_amount = mine_issued_amount
- .checked_add(MINE_REWARD)
- .and_then(|amount| amount.checked_add(transaction.fee()))
- .context("block metric mine issuance overflow")?;
}
}
}
@@ -1012,6 +1056,9 @@ fn metrics_from_snapshot(snapshot: &ChainSnapshot) -> Result<Vec<BlockMetricRow>
.checked_add(expired_blinded_fees)
.context("block metric expired burned fees overflow")?;
for transaction in &block.blinded_transactions {
+ for input in &transaction.inputs {
+ known_wallet_addresses.insert(input.owner.clone());
+ }
active_blinded.insert(transaction.commitment.clone(), transaction.clone());
}
total_burned_amount = total_burned_amount
@@ -1019,27 +1066,12 @@ fn metrics_from_snapshot(snapshot: &ChainSnapshot) -> Result<Vec<BlockMetricRow>
.and_then(|amount| amount.checked_add(burned_fee_amount))
.context("total burned metric overflows")?;
- let issued_amount = block_issued_amount(block.height, block.reward, mine_issued_amount)
- .with_context(|| {
- format!("block metric issuance overflows at block {}", block.height)
- })?;
- circulating_supply = circulating_supply
- .checked_add(issued_amount)
- .with_context(|| {
- format!(
- "circulating supply metric overflows while adding issuance at block {}",
- block.height
- )
- })?;
- circulating_supply = circulating_supply
- .checked_sub(burned_amount)
- .and_then(|amount| amount.checked_sub(burned_fee_amount))
- .with_context(|| {
- format!(
- "circulating supply metric underflows while subtracting burns at block {}",
- block.height
- )
- })?;
+ if block.height > 0 {
+ running_ledger
+ .apply_preverified_block_at(block.clone(), u64::MAX)
+ .with_context(|| format!("failed to replay block {} for metrics", block.height))?;
+ }
+ let circulating_supply = ledger_circulating_supply(&running_ledger)?;
let block_time_ms =
previous_timestamp_ms.map(|previous| block.timestamp_ms.saturating_sub(previous));
previous_timestamp_ms = Some(block.timestamp_ms);
@@ -1050,6 +1082,7 @@ fn metrics_from_snapshot(snapshot: &ChainSnapshot) -> Result<Vec<BlockMetricRow>
block_time_ms,
mine_difficulty_bits: ledger.mine_difficulty_bits_at_height(block.height),
circulating_supply,
+ known_wallet_addresses: known_wallet_addresses.len() as u64,
transaction_count: (block.transactions.len() + revealed_transactions.len()) as u64,
transfer_count,
burn_count,
@@ -1065,13 +1098,46 @@ fn metrics_from_snapshot(snapshot: &ChainSnapshot) -> Result<Vec<BlockMetricRow>
Ok(rows)
}
-fn block_issued_amount(
- height: u64,
- reward_amount: Amount,
- mine_issued_amount: Amount,
-) -> Option<Amount> {
- let genesis_reward = if height == 0 { reward_amount } else { 0 };
- mine_issued_amount.checked_add(genesis_reward)
+fn ledger_circulating_supply(ledger: &Ledger) -> Result<Amount> {
+ ledger
+ .status()
+ .balances
+ .values()
+ .try_fold(0_u64, |total, amount| {
+ total
+ .checked_add(*amount)
+ .context("circulating supply metric overflows")
+ })
+}
+
+fn collect_transaction_addresses(transaction: &Transaction, addresses: &mut BTreeSet<String>) {
+ match transaction {
+ Transaction::Transfer {
+ inputs, outputs, ..
+ } => {
+ collect_input_addresses(inputs, addresses);
+ collect_output_addresses(outputs, addresses);
+ }
+ Transaction::Burn { inputs, change, .. } => {
+ collect_input_addresses(inputs, addresses);
+ collect_output_addresses(change, addresses);
+ }
+ Transaction::Mine { recipient, .. } => {
+ addresses.insert(recipient.clone());
+ }
+ }
+}
+
+fn collect_input_addresses(inputs: &[TxInput], addresses: &mut BTreeSet<String>) {
+ for input in inputs {
+ addresses.insert(input.owner.clone());
+ }
+}
+
+fn collect_output_addresses(outputs: &[TxOutput], addresses: &mut BTreeSet<String>) {
+ for output in outputs {
+ addresses.insert(output.address.clone());
+ }
}
fn snapshot_tip(snapshot: &ChainSnapshot) -> Option<(u64, String)> {
@@ -1092,6 +1158,7 @@ fn unix_ms() -> u64 {
mod tests {
use std::collections::BTreeMap;
+ use rusqlite::Connection;
use tempfile::tempdir;
use crate::domain::{BLOCK_REWARD, GenesisBurn, Ledger, Wallet, run_vdf};
@@ -1252,13 +1319,123 @@ mod tests {
assert_eq!(metrics.last().unwrap().burn_count, 1);
assert_eq!(metrics.last().unwrap().burned_amount, 2);
assert_eq!(metrics.last().unwrap().fees_amount, 1);
- assert_eq!(metrics.last().unwrap().circulating_supply, BLOCK_REWARD + 7);
+ assert_eq!(
+ metrics.last().unwrap().circulating_supply,
+ ledger.status().balances.values().copied().sum::<u64>()
+ );
+ assert_eq!(metrics.last().unwrap().known_wallet_addresses, 1);
store.clear_metrics().unwrap();
assert!(store.load_metrics().unwrap().is_empty());
}
#[test]
+ fn sqlite_chain_store_migrates_known_wallet_address_metrics_column() {
+ let dir = tempdir().unwrap();
+ let path = dir.path().join("chain.sqlite3");
+ let connection = Connection::open(&path).unwrap();
+ connection
+ .execute_batch(
+ r#"
+CREATE TABLE block_metrics (
+ height INTEGER PRIMARY KEY,
+ block_hash TEXT NOT NULL,
+ timestamp_ms INTEGER NOT NULL,
+ block_time_ms INTEGER,
+ mine_difficulty_bits INTEGER NOT NULL,
+ circulating_supply INTEGER NOT NULL,
+ transaction_count INTEGER NOT NULL,
+ transfer_count INTEGER NOT NULL,
+ burn_count INTEGER NOT NULL,
+ mine_count INTEGER NOT NULL,
+ burned_amount INTEGER NOT NULL,
+ total_burned_amount INTEGER NOT NULL,
+ fees_amount INTEGER NOT NULL,
+ reward_amount INTEGER NOT NULL,
+ vdf_rounds INTEGER NOT NULL,
+ finalizer_rank INTEGER NOT NULL
+);
+"#,
+ )
+ .unwrap();
+ drop(connection);
+
+ let store = SqliteChainStore::open(&path).unwrap();
+
+ store
+ .with_connection(|connection| {
+ let count = connection
+ .query_row(
+ "SELECT COUNT(*) FROM pragma_table_info('block_metrics') WHERE name = 'known_wallet_addresses'",
+ [],
+ |row| row.get::<_, u64>(0),
+ )
+ .unwrap();
+ assert_eq!(count, 1);
+ Ok(())
+ })
+ .unwrap();
+ }
+
+ #[test]
+ fn sqlite_chain_store_metrics_supply_matches_wallet_balances_after_block_rewards() {
+ let dir = tempdir().unwrap();
+ let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap();
+ let alice = Wallet::from_seed("metrics-supply-alice");
+ let mut genesis = BTreeMap::new();
+ genesis.insert(alice.address().to_string(), 100);
+ let mut ledger =
+ Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(alice.address(), 1)], 1)
+ .unwrap();
+
+ for timestamp_ms in [1_000, 2_000, 3_000] {
+ let burn = ledger.build_burn(&alice, 1, 1).unwrap();
+ ledger.submit_transaction(burn).unwrap();
+ let block = ledger.mine_next_block(&alice, timestamp_ms).unwrap();
+ ledger.apply_locally_mined_block(block).unwrap();
+ }
+
+ store.save_with_metrics(&ledger.snapshot(), true).unwrap();
+ let metrics = store.load_metrics().unwrap();
+ let supply_from_balances = ledger.status().balances.values().copied().sum::<u64>();
+
+ assert_eq!(metrics.last().unwrap().height, 3);
+ assert_eq!(
+ metrics.last().unwrap().circulating_supply,
+ supply_from_balances
+ );
+ }
+
+ #[test]
+ fn sqlite_chain_store_metrics_count_known_wallet_addresses_seen_on_chain() {
+ let dir = tempdir().unwrap();
+ let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap();
+ let alice = Wallet::from_seed("metrics-address-alice");
+ let bob = Wallet::from_seed("metrics-address-bob");
+ let carol = Wallet::from_seed("metrics-address-carol");
+ let mut genesis = BTreeMap::new();
+ genesis.insert(alice.address().to_string(), 100);
+ let mut ledger =
+ Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(alice.address(), 1)], 1)
+ .unwrap();
+
+ let burn = ledger.build_burn(&alice, 1, 1).unwrap();
+ ledger.submit_transaction(burn).unwrap();
+ let transfer = ledger.build_transfer(&alice, bob.address(), 10, 1).unwrap();
+ ledger.submit_transaction(transfer).unwrap();
+ let mine = ledger.build_mine(carol.address()).unwrap();
+ ledger.submit_transaction(mine).unwrap();
+ let block = ledger.mine_next_block(&alice, 1_000).unwrap();
+ ledger.apply_locally_mined_block(block).unwrap();
+
+ store.save_with_metrics(&ledger.snapshot(), true).unwrap();
+ let metrics = store.load_metrics().unwrap();
+
+ assert_eq!(metrics[0].known_wallet_addresses, 1);
+ assert_eq!(metrics.last().unwrap().known_wallet_addresses, 3);
+ }
+
+ #[test]
fn sqlite_chain_store_metrics_include_revealed_blinded_burns() {
let dir = tempdir().unwrap();
let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap();
@@ -1331,6 +1508,7 @@ mod tests {
assert_eq!(last.burned_amount, 4);
assert_eq!(last.fees_amount, 7);
assert_eq!(last.circulating_supply, supply_from_balances);
+ assert_eq!(last.known_wallet_addresses, 3);
}
#[test]
@@ -1351,6 +1529,7 @@ mod tests {
block_time_ms: Some(415_000),
mine_difficulty_bits: 12,
circulating_supply: 100,
+ known_wallet_addresses: 1,
transaction_count: 0,
transfer_count: 0,
burn_count: 0,
diff --git a/src/adapters/http.rs b/src/adapters/http.rs
@@ -1368,6 +1368,14 @@ fn metrics_response(enabled: bool, rows: Vec<BlockMetricRow>) -> MetricsResponse
|row| Some(micro_iuna_as_iuna(row.circulating_supply)),
),
metrics_chart(
+ "known-wallet-addresses",
+ "Known wallet addresses",
+ "addresses",
+ MetricsValueKind::Number,
+ &rows,
+ |row| Some(row.known_wallet_addresses as f64),
+ ),
+ metrics_chart(
"transactions",
"Transactions",
"tx",
@@ -3815,6 +3823,7 @@ const INDEX_HTML: &str = r#"<!doctype html>
<div class="metrics-summary">
<div class="metric"><div class="label">Latest block</div><div class="value" x-text="metricsLatest().height ?? '-'"></div></div>
<div class="metric"><div class="label">Supply</div><div class="value" x-text="metricAmountLabel(metricsLatest().circulatingSupply)"></div></div>
+ <div class="metric"><div class="label">Known addresses</div><div class="value" x-text="metricsLatest().knownWalletAddresses ?? '-'"></div></div>
<div class="metric"><div class="label">Total burned</div><div class="value" x-text="metricAmountLabel(metricsLatest().totalBurnedAmount)"></div></div>
<div class="metric"><div class="label">Difficulty</div><div class="value" x-text="metricsLatest().mineDifficultyBits ?? '-'"></div></div>
</div>
@@ -5405,6 +5414,7 @@ mod tests {
block_time_ms,
mine_difficulty_bits: 12,
circulating_supply: 100,
+ known_wallet_addresses: 1,
transaction_count: 0,
transfer_count: 0,
burn_count: 0,
@@ -5457,6 +5467,20 @@ mod tests {
.collect::<Vec<_>>(),
vec![(2, 120.0), (3, 130.0)]
);
+
+ let known_wallet_addresses = response
+ .charts
+ .iter()
+ .find(|chart| chart.id == "known-wallet-addresses")
+ .expect("known wallet addresses chart should exist");
+ assert_eq!(
+ known_wallet_addresses
+ .points
+ .iter()
+ .map(|point| (point.height, point.value))
+ .collect::<Vec<_>>(),
+ vec![(0, 1.0), (1, 1.0), (2, 1.0), (3, 1.0)]
+ );
}
#[test]
@@ -5466,6 +5490,8 @@ mod tests {
assert!(super::INDEX_HTML.contains("setMetricsRange(100)"));
assert!(super::INDEX_HTML.contains("setMetricsRange(1000)"));
assert!(super::INDEX_HTML.contains("setMetricsRange('all')"));
+ assert!(super::INDEX_HTML.contains("Known addresses"));
+ assert!(super::INDEX_HTML.contains("knownWalletAddresses"));
}
#[test]