diff --git a/src/analytics/ledger/active_addresses.rs b/src/analytics/ledger/active_addresses.rs
index c16eb2247..94d6c3e10 100644
--- a/src/analytics/ledger/active_addresses.rs
+++ b/src/analytics/ledger/active_addresses.rs
@@ -1,4 +1,4 @@
-// Copyright 2023 IOTA Stiftung
+// Copyright 2024 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0
use std::collections::HashSet;
@@ -43,7 +43,7 @@ impl IntervalAnalytics for AddressActivityMeasurement {
impl Analytics for AddressActivityAnalytics {
type Measurement = AddressActivityMeasurement;
- fn handle_transaction(&mut self, consumed: &[LedgerSpent], created: &[LedgerOutput], ctx: &dyn AnalyticsContext) {
+ fn handle_transaction(&mut self, consumed: &[LedgerSpent], created: &[LedgerOutput], _ctx: &dyn AnalyticsContext) {
for output in consumed {
if let Some(a) = output.owning_address() {
self.addresses.insert(*a);
@@ -51,7 +51,7 @@ impl Analytics for AddressActivityAnalytics {
}
for output in created {
- if let Some(a) = output.output.owning_address(ctx.at().milestone_timestamp) {
+ if let Some(a) = output.owning_address() {
self.addresses.insert(*a);
}
}
diff --git a/src/analytics/ledger/address_balance.rs b/src/analytics/ledger/address_balance.rs
index a80ec4218..239677ac9 100644
--- a/src/analytics/ledger/address_balance.rs
+++ b/src/analytics/ledger/address_balance.rs
@@ -1,11 +1,11 @@
-// Copyright 2023 IOTA Stiftung
+// Copyright 2024 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0
-use std::collections::HashMap;
+use std::collections::{BTreeMap, HashMap};
use super::*;
use crate::model::{
- payload::milestone::MilestoneTimestamp,
+ payload::{milestone::MilestoneTimestamp, transaction::output::OutputId},
utxo::{Address, TokenAmount},
};
@@ -24,10 +24,12 @@ pub(crate) struct DistributionStat {
pub(crate) total_amount: TokenAmount,
}
-/// Computes the number of addresses the currently hold a balance.
-#[derive(Serialize, Deserialize)]
+/// Computes the number of addresses that currently hold a balance.
+#[derive(Serialize, Deserialize, Default)]
pub(crate) struct AddressBalancesAnalytics {
balances: HashMap
,
+ expiring: BTreeMap<(MilestoneTimestamp, OutputId), (Address, Address, TokenAmount)>,
+ locked: BTreeMap<(MilestoneTimestamp, OutputId), (Address, TokenAmount)>,
}
impl AddressBalancesAnalytics {
@@ -36,13 +38,89 @@ impl AddressBalancesAnalytics {
unspent_outputs: impl IntoIterator- ,
milestone_timestamp: MilestoneTimestamp,
) -> Self {
- let mut balances = HashMap::new();
- for output in unspent_outputs {
- if let Some(&a) = output.output.owning_address(milestone_timestamp) {
- *balances.entry(a).or_default() += output.amount();
+ let mut res = AddressBalancesAnalytics::default();
+ for created in unspent_outputs {
+ res.handle_created(created, milestone_timestamp);
+ }
+ res
+ }
+
+ fn handle_created(&mut self, created: &LedgerOutput, milestone_timestamp: MilestoneTimestamp) {
+ if let Some(&owning_address) = created.owning_address() {
+ if let Some(expiration) = created.output.expiration() {
+ // If the output is expired already, add the value to the return address
+ if milestone_timestamp >= expiration.timestamp {
+ *self.balances.entry(expiration.return_address).or_default() += created.amount();
+ } else {
+ // Otherwise, add it to the set of expiring values to be handled later
+ *self.balances.entry(owning_address).or_default() += created.amount();
+ self.expiring.insert(
+ (expiration.timestamp, created.output_id),
+ (owning_address, expiration.return_address, created.amount()),
+ );
+ }
+ } else if let Some(timelock) = created.output.timelock() {
+ // If the output is unlocked, add the value to the address
+ if milestone_timestamp >= timelock.timestamp {
+ *self.balances.entry(owning_address).or_default() += created.amount();
+ } else {
+ // Otherwise, add it to the set of locked values to be handled later
+ self.locked.insert(
+ (timelock.timestamp, created.output_id),
+ (owning_address, created.amount()),
+ );
+ }
+ } else {
+ *self.balances.entry(owning_address).or_default() += created.amount();
+ }
+ }
+ }
+
+ fn handle_consumed(&mut self, consumed: &LedgerSpent, milestone_timestamp: MilestoneTimestamp) {
+ if let Some(&owning_address) = consumed.output.owning_address() {
+ if let Some(expiration) = consumed.output.output.expiration() {
+ // No longer need to handle the expiration
+ self.expiring.remove(&(expiration.timestamp, consumed.output_id()));
+ // If the output is past the expiration time, remove the value from the return address
+ if milestone_timestamp >= expiration.timestamp {
+ *self.balances.entry(expiration.return_address).or_default() -= consumed.amount();
+ // Otherwise, remove it from the original address
+ } else {
+ *self.balances.entry(owning_address).or_default() -= consumed.amount();
+ }
+ } else if let Some(timelock) = consumed.output.output.timelock() {
+ // No longer need to handle the lock
+ self.locked.remove(&(timelock.timestamp, consumed.output_id()));
+ *self.balances.entry(owning_address).or_default() -= consumed.amount();
+ } else {
+ *self.balances.entry(owning_address).or_default() -= consumed.amount();
+ }
+ }
+ }
+
+ fn handle_expired(&mut self, milestone_timestamp: MilestoneTimestamp) {
+ while let Some((address, return_address, amount)) = self.expiring.first_entry().and_then(|entry| {
+ if milestone_timestamp >= entry.key().0 {
+ Some(entry.remove())
+ } else {
+ None
}
+ }) {
+ *self.balances.entry(address).or_default() -= amount;
+ *self.balances.entry(return_address).or_default() += amount;
+ }
+ }
+
+ fn handle_locked(&mut self, milestone_timestamp: MilestoneTimestamp) {
+ while let Some((address, amount)) = self.locked.first_entry().and_then(|entry| {
+ if milestone_timestamp >= entry.key().0 {
+ Some(entry.remove())
+ } else {
+ None
+ }
+ }) {
+ *self.balances.entry(address).or_default() += amount;
}
- Self { balances }
}
}
@@ -50,23 +128,16 @@ impl Analytics for AddressBalancesAnalytics {
type Measurement = AddressBalanceMeasurement;
fn handle_transaction(&mut self, consumed: &[LedgerSpent], created: &[LedgerOutput], ctx: &dyn AnalyticsContext) {
- for output in consumed {
- if let Some(a) = output.owning_address() {
- // All inputs should be present in `addresses`. If not, we skip it's value.
- if let Some(amount) = self.balances.get_mut(a) {
- *amount -= output.amount();
- if amount.0 == 0 {
- self.balances.remove(a);
- }
- }
- }
+ // Handle consumed outputs first, as they can remove entries from expiration/locked
+ for consumed in consumed {
+ self.handle_consumed(consumed, ctx.at().milestone_timestamp);
}
-
- for output in created {
- if let Some(&a) = output.output.owning_address(ctx.at().milestone_timestamp) {
- // All inputs should be present in `addresses`. If not, we skip it's value.
- *self.balances.entry(a).or_default() += output.amount();
- }
+ // Handle any expiring or unlocking outputs for this milestone
+ self.handle_expired(ctx.at().milestone_timestamp);
+ self.handle_locked(ctx.at().milestone_timestamp);
+ // Finally, handle the created transactions, which can insert new expiration/locked records for the future
+ for created in created {
+ self.handle_created(created, ctx.at().milestone_timestamp);
}
}
@@ -74,11 +145,11 @@ impl Analytics for AddressBalancesAnalytics {
let bucket_max = ctx.protocol_params().token_supply.ilog10() as usize + 1;
let mut token_distribution = vec![DistributionStat::default(); bucket_max];
- for amount in self.balances.values() {
+ for balance in self.balances.values() {
// Balances are partitioned into ranges defined by: [10^index..10^(index+1)).
- let index = amount.0.ilog10() as usize;
+ let index = balance.ilog10() as usize;
token_distribution[index].address_count += 1;
- token_distribution[index].total_amount += *amount;
+ token_distribution[index].total_amount += *balance;
}
AddressBalanceMeasurement {
address_with_balance_count: self.balances.len(),
diff --git a/src/bin/inx-chronicle/cli/analytics.rs b/src/bin/inx-chronicle/cli/analytics.rs
index b2bf03d04..4d0e5e018 100644
--- a/src/bin/inx-chronicle/cli/analytics.rs
+++ b/src/bin/inx-chronicle/cli/analytics.rs
@@ -1,4 +1,4 @@
-// Copyright 2023 IOTA Stiftung
+// Copyright 2024 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0
use std::collections::HashSet;
@@ -23,7 +23,9 @@ use tracing::{debug, info};
use crate::config::ChronicleConfig;
-/// This command accepts both milestone index and date ranges. The following rules apply:
+/// This command accepts both milestone index and date ranges.
+///
+/// The following rules apply:
///
/// - If both milestone and date are specified, the date will be used for interval analytics
/// while the milestone will be used for per-milestone analytics.
diff --git a/src/model/block/payload/transaction/mod.rs b/src/model/block/payload/transaction/mod.rs
index eee9ba756..e32f7b040 100644
--- a/src/model/block/payload/transaction/mod.rs
+++ b/src/model/block/payload/transaction/mod.rs
@@ -1,4 +1,4 @@
-// Copyright 2022 IOTA Stiftung
+// Copyright 2024 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0
//! Module containing types related to transactions.
@@ -17,7 +17,7 @@ pub mod output;
pub mod unlock;
/// Uniquely identifies a transaction.
-#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Hash)]
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Hash, PartialOrd, Ord)]
#[serde(transparent)]
pub struct TransactionId(#[serde(with = "bytify")] pub [u8; Self::LENGTH]);
diff --git a/src/model/block/payload/transaction/output/mod.rs b/src/model/block/payload/transaction/output/mod.rs
index 7cfa3c03d..b6d0c860d 100644
--- a/src/model/block/payload/transaction/output/mod.rs
+++ b/src/model/block/payload/transaction/output/mod.rs
@@ -1,4 +1,4 @@
-// Copyright 2022 IOTA Stiftung
+// Copyright 2024 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0
//! Module containing the [`Output`] types.
@@ -23,6 +23,7 @@ use mongodb::bson::{doc, Bson};
use packable::PackableExt;
use serde::{Deserialize, Serialize};
+use self::unlock_condition::{ExpirationUnlockCondition, TimelockUnlockCondition};
pub use self::{
address::{Address, AliasAddress, Ed25519Address, NftAddress},
alias::{AliasId, AliasOutput},
@@ -54,7 +55,9 @@ use crate::model::{
derive_more::AddAssign,
derive_more::SubAssign,
derive_more::Sum,
+ derive_more::Deref,
)]
+#[repr(transparent)]
pub struct TokenAmount(#[serde(with = "stringify")] pub u64);
/// The index of an output within a transaction.
@@ -62,7 +65,7 @@ pub type OutputIndex = u16;
/// An id which uniquely identifies an output. It is computed from the corresponding [`TransactionId`], as well as the
/// [`OutputIndex`].
-#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Hash)]
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Hash, PartialOrd, Ord)]
pub struct OutputId {
/// The transaction id part of the [`OutputId`].
pub transaction_id: TransactionId,
@@ -182,6 +185,36 @@ impl Output {
})
}
+ /// Returns the expiration unlock condition, if there is one.
+ pub fn expiration(&self) -> Option<&ExpirationUnlockCondition> {
+ match self {
+ Self::Basic(BasicOutput {
+ expiration_unlock_condition,
+ ..
+ })
+ | Self::Nft(NftOutput {
+ expiration_unlock_condition,
+ ..
+ }) => expiration_unlock_condition.as_ref(),
+ _ => None,
+ }
+ }
+
+ /// Returns the timelock unlock condition, if there is one.
+ pub fn timelock(&self) -> Option<&TimelockUnlockCondition> {
+ match self {
+ Self::Basic(BasicOutput {
+ timelock_unlock_condition,
+ ..
+ })
+ | Self::Nft(NftOutput {
+ timelock_unlock_condition,
+ ..
+ }) => timelock_unlock_condition.as_ref(),
+ _ => None,
+ }
+ }
+
/// Returns the amount associated with an output.
pub fn amount(&self) -> TokenAmount {
match self {
diff --git a/src/model/block/payload/transaction/output/unlock_condition/timelock.rs b/src/model/block/payload/transaction/output/unlock_condition/timelock.rs
index e93e51abd..353285aa4 100644
--- a/src/model/block/payload/transaction/output/unlock_condition/timelock.rs
+++ b/src/model/block/payload/transaction/output/unlock_condition/timelock.rs
@@ -1,4 +1,4 @@
-// Copyright 2022 IOTA Stiftung
+// Copyright 2024 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0
#![allow(missing_docs)]
@@ -13,7 +13,7 @@ use crate::model::tangle::MilestoneTimestamp;
/// Defines a unix timestamp until which the output can not be unlocked.
#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TimelockUnlockCondition {
- timestamp: MilestoneTimestamp,
+ pub(crate) timestamp: MilestoneTimestamp,
}
impl> From for TimelockUnlockCondition {