Repository: iotaledger/iota
Pull request #6123: feat(iota-indexer): Implement circulating supply endpoint
Author: login: samuel-rufi
Labels: name: sc-platform
description: Issues related to the Smart Contract Platform group.
name: infrastructure
description: Issues related to the Infrastructure Team
name: ci
description: Issues related to our CI pipeline
Created: 2025-03-26T22:31:22Z | Merged: 2025-03-31T14:25:05Z

# Description of change

Adds an endpoint to fetch the current circulating supply following a similar format as https://sui-circulation.suiexplorer.com/api/current_month_sui_circulation

## Links to any relevant issues

Fixes https://github.com/iotaledger/iota/issues/5927

## Type of change

- Enhancement (a non-breaking change which adds functionality)

## How the change has been tested

Start the test cluster:

```
 RUST_LOG="off,iota_indexer=info" cargo run --features indexer --bin iota start --with-indexer --pg-port 5432 --pg-db-name iota_indexer --with-graphql=0.0.0.0:8000 --with-faucet --force-regenesis
```

Fetch the circulating supply endpoint using:

```
curl --location --request POST 'http://localhost:9124' \
--header 'Content-Type: application/json' \
--data-raw '{
 "jsonrpc": "2.0",
 "id": 1,
 "method": "iotax_getCirculatingSupply",
 "params":[]
}'
```

On `localnet`, if the node is configured with `Chain::Mainnet` it will return:

```
{"jsonrpc":"2.0","id":1,"result":{"value":3126767000000000000,"circulatingSupplyPercentage":1.0,"atCheckpoint":376}}
```

As we only have mainnet unlock data, for all other chains the `circulating_supply` is equal to `total_supply`.
## Change checklist

- [X] I have followed the contribution guidelines for this project
- [X] I have performed a self-review of my own code
- [X] I have commented my code, particularly in hard-to-understand areas
- [X] I have made corresponding changes to the documentation
- [X] I have added tests that prove my fix is effective or that my feature works
- [X] I have checked that new and existing unit tests pass locally with my changes



Diff:
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 2b7ae4080df..c68b80e05ed 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -38,6 +38,7 @@
 /crates/iota-keys/ @iotaledger/dev-tools
 /crates/iota-light-client/ @iotaledger/dev-tools
 /crates/iota-macros/ @iotaledger/dev-tools
+/crates/iota-mainnet-unlocks/ @iotaledger/infrastructure
 /crates/iota-metric-checker/ @iotaledger/node
 /crates/iota-metrics/ @iotaledger/core-protocol
 /crates/iota-move/ @iotaledger/vm-language
diff --git a/.github/crates-filters.yml b/.github/crates-filters.yml
index 3c4c6a9034c..f9d14376c1c 100644
--- a/.github/crates-filters.yml
+++ b/.github/crates-filters.yml
@@ -82,6 +82,8 @@ iota-light-client:
   - "crates/iota-light-client/**"
 iota-macros:
   - "crates/iota-macros/**"
+iota-mainnet-unlocks:
+  - "crates/iota-mainnet-unlocks/**"
 iota-metric-checker:
   - "crates/iota-metric-checker/**"
 iota-metrics:
diff --git a/Cargo.lock b/Cargo.lock
index 31b2372e61b..c1fac03f2d5 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3145,9 +3145,9 @@ dependencies = [
 
 [[package]]
 name = "csv"
-version = "1.3.0"
+version = "1.3.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ac574ff4d437a7b5ad237ef331c17ccca63c46479e5b5453eb8e10bb99a759fe"
+checksum = "acdc4883a9c96732e4733212c01447ebd805833b7275a73ca3ee080fd77afdaf"
 dependencies = [
  "csv-core",
  "itoa",
@@ -7038,6 +7038,7 @@ dependencies = [
  "iota-json-rpc-api",
  "iota-json-rpc-types",
  "iota-keys",
+ "iota-mainnet-unlocks",
  "iota-metrics",
  "iota-move-build",
  "iota-open-rpc",
@@ -7119,6 +7120,7 @@ dependencies = [
  "backoff",
  "bcs",
  "cached",
+ "chrono",
  "expect-test",
  "eyre",
  "fastcrypto",
@@ -7131,6 +7133,7 @@ dependencies = [
  "iota-json",
  "iota-json-rpc-api",
  "iota-json-rpc-types",
+ "iota-mainnet-unlocks",
  "iota-metrics",
  "iota-open-rpc",
  "iota-open-rpc-macros",
@@ -7226,6 +7229,7 @@ version = "0.11.0-alpha"
 dependencies = [
  "anyhow",
  "bcs",
+ "chrono",
  "colored",
  "enum_dispatch",
  "fastcrypto",
@@ -7303,6 +7307,18 @@ dependencies = [
  "tracing",
 ]
 
+[[package]]
+name = "iota-mainnet-unlocks"
+version = "0.11.0-alpha"
+dependencies = [
+ "anyhow",
+ "chrono",
+ "csv",
+ "regex",
+ "serde",
+ "tempfile",
+]
+
 [[package]]
 name = "iota-metric-checker"
 version = "0.11.0-alpha"
diff --git a/Cargo.toml b/Cargo.toml
index cc45c87056d..5e357c4fcb8 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -104,6 +104,7 @@ members = [
   "crates/iota-keys",
   "crates/iota-light-client",
   "crates/iota-macros",
+  "crates/iota-mainnet-unlocks",
   "crates/iota-metric-checker",
   "crates/iota-metrics",
   "crates/iota-move",
@@ -395,6 +396,7 @@ iota-json-rpc-api = { path = "crates/iota-json-rpc-api" }
 iota-json-rpc-types = { path = "crates/iota-json-rpc-types" }
 iota-keys = { path = "crates/iota-keys" }
 iota-macros = { path = "crates/iota-macros" }
+iota-mainnet-unlocks = { path = "crates/iota-mainnet-unlocks" }
 iota-metric-checker = { path = "crates/iota-metric-checker" }
 iota-metrics = { path = "crates/iota-metrics" }
 iota-move = { path = "crates/iota-move" }
diff --git a/crates/iota-indexer/Cargo.toml b/crates/iota-indexer/Cargo.toml
index b4a90af2e83..31dca501197 100644
--- a/crates/iota-indexer/Cargo.toml
+++ b/crates/iota-indexer/Cargo.toml
@@ -44,6 +44,7 @@ iota-json.workspace = true
 iota-json-rpc.workspace = true
 iota-json-rpc-api.workspace = true
 iota-json-rpc-types.workspace = true
+iota-mainnet-unlocks.workspace = true
 iota-metrics.workspace = true
 iota-open-rpc.workspace = true
 iota-package-resolver.workspace = true
diff --git a/crates/iota-indexer/src/apis/coin_api.rs b/crates/iota-indexer/src/apis/coin_api.rs
index 782262f2fc5..4f4528f6349 100644
--- a/crates/iota-indexer/src/apis/coin_api.rs
+++ b/crates/iota-indexer/src/apis/coin_api.rs
@@ -2,14 +2,18 @@
 // Modifications Copyright (c) 2024 IOTA Stiftung
 // SPDX-License-Identifier: Apache-2.0
 
+use anyhow::Result;
 use async_trait::async_trait;
+use chrono::DateTime;
 use iota_json_rpc::{
     IotaRpcModule,
     coin_api::{parse_to_struct_tag, parse_to_type_tag},
 };
 use iota_json_rpc_api::{CoinReadApiServer, cap_page_limit};
-use iota_json_rpc_types::{Balance, CoinPage, IotaCoinMetadata, Page};
+use iota_json_rpc_types::{Balance, CoinPage, IotaCirculatingSupply, IotaCoinMetadata, Page};
+use iota_mainnet_unlocks::MainnetUnlocksStore;
 use iota_open_rpc::Module;
+use iota_protocol_config::Chain;
 use iota_types::{
     balance::Supply,
     base_types::{IotaAddress, ObjectID},
@@ -17,15 +21,23 @@ use iota_types::{
 };
 use jsonrpsee::{RpcModule, core::RpcResult};
 
-use crate::{indexer_reader::IndexerReader, types::IotaSystemStateSummaryView};
+use crate::{
+    errors::IndexerError::{DateTimeParsing, InvalidArgument},
+    indexer_reader::IndexerReader,
+    types::IotaSystemStateSummaryView,
+};
 
 pub(crate) struct CoinReadApi {
     inner: IndexerReader,
+    unlocks_store: MainnetUnlocksStore,
 }
 
 impl CoinReadApi {
-    pub fn new(inner: IndexerReader) -> Self {
-        Self { inner }
+    pub fn new(inner: IndexerReader) -> Result<Self> {
+        Ok(Self {
+            inner,
+            unlocks_store: MainnetUnlocksStore::new()?,
+        })
     }
 }
 
@@ -151,6 +163,48 @@ impl CoinReadApiServer for CoinReadApi {
                 .map_err(Into::into)
         }
     }
+
+    async fn get_circulating_supply(&self) -> RpcResult<IotaCirculatingSupply> {
+        let latest_cp = self
+            .inner
+            .spawn_blocking(|this| this.get_latest_checkpoint())
+            .await?;
+        let cp_timestamp_ms = latest_cp.timestamp_ms;
+
+        let total_supply = self
+            .inner
+            .spawn_blocking(|this| this.get_latest_iota_system_state())
+            .await?
+            .iota_total_supply();
+
+        let date_time =
+            DateTime::from_timestamp_millis(cp_timestamp_ms.try_into().map_err(|_| {
+                InvalidArgument(format!("failed to convert timestamp: {cp_timestamp_ms}"))
+            })?)
+            .ok_or(DateTimeParsing(format!(
+                "failed to parse timestamp: {cp_timestamp_ms}"
+            )))?;
+
+        let chain = self
+            .inner
+            .get_chain_identifier_in_blocking_task()
+            .await?
+            .chain();
+
+        let locked_supply = match chain {
+            Chain::Mainnet => self.unlocks_store.still_locked_tokens(date_time),
+            _ => 0,
+        };
+
+        let circulating_supply = total_supply - locked_supply;
+        let circulating_supply_percentage = circulating_supply as f64 / total_supply as f64;
+
+        Ok(IotaCirculatingSupply {
+            value: circulating_supply,
+            circulating_supply_percentage,
+            at_checkpoint: latest_cp.sequence_number,
+        })
+    }
 }
 
 impl IotaRpcModule for CoinReadApi {
diff --git a/crates/iota-indexer/src/errors.rs b/crates/iota-indexer/src/errors.rs
index 1a7649f4379..8558d5875d4 100644
--- a/crates/iota-indexer/src/errors.rs
+++ b/crates/iota-indexer/src/errors.rs
@@ -34,7 +34,7 @@ pub enum IndexerError {
     #[error("Stream closed unexpectedly with error: `{0}`")]
     ChannelClosed(String),
 
-    #[error("Indexer failed to convert timestamp to NaiveDateTime with error: `{0}`")]
+    #[error("Indexer failed to convert timestamp to DateTime with error: `{0}`")]
     DateTimeParsing(String),
 
     #[error("Indexer failed to deserialize event from events table with error: `{0}`")]
diff --git a/crates/iota-indexer/src/indexer_reader.rs b/crates/iota-indexer/src/indexer_reader.rs
index 99460ea49c3..0dfcca6c654 100644
--- a/crates/iota-indexer/src/indexer_reader.rs
+++ b/crates/iota-indexer/src/indexer_reader.rs
@@ -31,7 +31,7 @@ use iota_types::{
     base_types::{IotaAddress, ObjectID, SequenceNumber, VersionNumber},
     coin::{CoinMetadata, TreasuryCap},
     committee::EpochId,
-    digests::TransactionDigest,
+    digests::{ChainIdentifier, TransactionDigest},
     dynamic_field::{DynamicFieldInfo, DynamicFieldName, visitor as DFV},
     effects::TransactionEvents,
     event::EventID,
@@ -40,6 +40,7 @@ use iota_types::{
         iota_system_state_summary::{IotaSystemStateSummary, IotaValidatorSummary},
     },
     is_system_package,
+    messages_checkpoint::CheckpointDigest,
     object::{Object, ObjectRead, PastObjectRead, bounded_visitor::BoundedVisitor},
 };
 use itertools::Itertools;
@@ -51,7 +52,7 @@ use crate::{
     errors::IndexerError,
     models::{
         address_metrics::StoredAddressMetrics,
-        checkpoints::StoredCheckpoint,
+        checkpoints::{StoredChainIdentifier, StoredCheckpoint},
         display::StoredDisplay,
         epoch::StoredEpochInfo,
         events::StoredEvent,
@@ -66,8 +67,9 @@ use crate::{
         tx_indices::TxSequenceNumber,
     },
     schema::{
-        address_metrics, addresses, checkpoints, display, epochs, events, objects, objects_history,
-        objects_snapshot, objects_version, packages, pruner_cp_watermark, transactions, tx_digests,
+        address_metrics, addresses, chain_identifier, checkpoints, display, epochs, events,
+        objects, objects_history, objects_snapshot, objects_version, packages, pruner_cp_watermark,
+        transactions, tx_digests,
     },
     store::{diesel_macro::*, package_resolver::IndexerStorePackageResolver},
     types::{IndexerResult, OwnerType},
@@ -470,6 +472,33 @@ impl IndexerReader {
         Ok(system_state)
     }
 
+    pub async fn get_chain_identifier_in_blocking_task(
+        &self,
+    ) -> Result<ChainIdentifier, IndexerError> {
+        self.spawn_blocking(|this| this.get_chain_identifier())
+            .await
+    }
+
+    pub fn get_chain_identifier(&self) -> Result<ChainIdentifier, IndexerError> {
+        let stored_chain_identifier = run_query!(&self.pool, |conn| {
+            chain_identifier::dsl::chain_identifier
+                .first::<StoredChainIdentifier>(conn)
+                .optional()
+        })?
+        .ok_or(IndexerError::PostgresRead(
+            "chain identifier not found".to_string(),
+        ))?;
+
+        let checkpoint_digest =
+            CheckpointDigest::try_from(stored_chain_identifier.checkpoint_digest).map_err(|e| {
+                IndexerError::PersistentStorageDataCorruption(format!(
+                    "failed to decode chain identifier with err: {e:?}"
+                ))
+            })?;
+
+        Ok(checkpoint_digest.into())
+    }
+
     pub fn get_checkpoint_from_db(
         &self,
         checkpoint_id: CheckpointId,
diff --git a/crates/iota-indexer/src/lib.rs b/crates/iota-indexer/src/lib.rs
index 8cba4abda0d..e37479b1676 100644
--- a/crates/iota-indexer/src/lib.rs
+++ b/crates/iota-indexer/src/lib.rs
@@ -177,7 +177,7 @@ pub async fn build_json_rpc_server(
     builder.register_module(MoveUtilsApi::new(reader.clone()))?;
     builder.register_module(GovernanceReadApi::new(reader.clone()))?;
     builder.register_module(ReadApi::new(reader.clone()))?;
-    builder.register_module(CoinReadApi::new(reader.clone()))?;
+    builder.register_module(CoinReadApi::new(reader.clone())?)?;
     builder.register_module(ExtendedApi::new(reader.clone()))?;
 
     let default_socket_addr: SocketAddr = SocketAddr::new(
diff --git a/crates/iota-json-rpc-api/src/coin.rs b/crates/iota-json-rpc-api/src/coin.rs
index 23d63eee1de..f5d86203dbc 100644
--- a/crates/iota-json-rpc-api/src/coin.rs
+++ b/crates/iota-json-rpc-api/src/coin.rs
@@ -2,7 +2,7 @@
 // Modifications Copyright (c) 2024 IOTA Stiftung
 // SPDX-License-Identifier: Apache-2.0
 
-use iota_json_rpc_types::{Balance, CoinPage, IotaCoinMetadata};
+use iota_json_rpc_types::{Balance, CoinPage, IotaCirculatingSupply, IotaCoinMetadata};
 use iota_open_rpc_macros::open_rpc;
 use iota_types::{
     balance::Supply,
@@ -80,4 +80,8 @@ pub trait CoinReadApi {
         /// type name for the coin (e.g., 0x168da5bf1f48dafc111b0a488fa454aca95e0b5e::usdc::USDC)
         coin_type: String,
     ) -> RpcResult<Supply>;
+
+    /// Return the circulating supply summary.
+    #[method(name = "getCirculatingSupply")]
+    async fn get_circulating_supply(&self) -> RpcResult<IotaCirculatingSupply>;
 }
diff --git a/crates/iota-json-rpc-types/Cargo.toml b/crates/iota-json-rpc-types/Cargo.toml
index 7ad4c93bddd..0b9c3b88b14 100644
--- a/crates/iota-json-rpc-types/Cargo.toml
+++ b/crates/iota-json-rpc-types/Cargo.toml
@@ -10,6 +10,7 @@ publish = false
 # external dependencies
 anyhow.workspace = true
 bcs.workspace = true
+chrono.workspace = true
 colored.workspace = true
 enum_dispatch.workspace = true
 fastcrypto.workspace = true
diff --git a/crates/iota-json-rpc-types/src/iota_coin.rs b/crates/iota-json-rpc-types/src/iota_coin.rs
index ecfc7cddb39..77e9479c0f4 100644
--- a/crates/iota-json-rpc-types/src/iota_coin.rs
+++ b/crates/iota-json-rpc-types/src/iota_coin.rs
@@ -7,6 +7,7 @@ use iota_types::{
     coin::CoinMetadata,
     error::IotaError,
     iota_serde::{BigInt, SequenceNumber as AsSequenceNumber},
+    messages_checkpoint::CheckpointSequenceNumber,
     object::Object,
 };
 use schemars::JsonSchema;
@@ -99,3 +100,17 @@ impl TryFrom<Object> for IotaCoinMetadata {
         })
     }
 }
+
+/// Provides a summary of the circulating IOTA supply.
+#[serde_as]
+#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct IotaCirculatingSupply {
+    /// Circulating supply in NANOS at the given timestamp.
+    pub value: u64,
+    /// Percentage of total supply that is currently circulating (range: 0.0 to
+    /// 1.0).
+    pub circulating_supply_percentage: f64,
+    /// Timestamp (UTC) when the circulating supply was calculated.
+    pub at_checkpoint: CheckpointSequenceNumber,
+}
diff --git a/crates/iota-json-rpc/Cargo.toml b/crates/iota-json-rpc/Cargo.toml
index c28e38c41cb..0b697c88621 100644
--- a/crates/iota-json-rpc/Cargo.toml
+++ b/crates/iota-json-rpc/Cargo.toml
@@ -15,6 +15,7 @@ axum.workspace = true
 backoff.workspace = true
 bcs.workspace = true
 cached.workspace = true
+chrono.workspace = true
 eyre.workspace = true
 fastcrypto.workspace = true
 futures.workspace = true
@@ -43,6 +44,7 @@ iota-core.workspace = true
 iota-json.workspace = true
 iota-json-rpc-api.workspace = true
 iota-json-rpc-types.workspace = true
+iota-mainnet-unlocks.workspace = true
 iota-metrics.workspace = true
 iota-open-rpc.workspace = true
 iota-open-rpc-macros.workspace = true
diff --git a/crates/iota-json-rpc/src/coin_api.rs b/crates/iota-json-rpc/src/coin_api.rs
index fcc708836fe..577071efdf7 100644
--- a/crates/iota-json-rpc/src/coin_api.rs
+++ b/crates/iota-json-rpc/src/coin_api.rs
@@ -4,13 +4,17 @@
 
 use std::{collections::HashMap, sync::Arc};
 
+use anyhow::Result;
 use async_trait::async_trait;
 use cached::{SizedCache, proc_macro::cached};
+use chrono::DateTime;
 use iota_core::authority::AuthorityState;
 use iota_json_rpc_api::{CoinReadApiOpenRpc, CoinReadApiServer, JsonRpcMetrics, cap_page_limit};
-use iota_json_rpc_types::{Balance, CoinPage, IotaCoinMetadata};
+use iota_json_rpc_types::{Balance, CoinPage, IotaCirculatingSupply, IotaCoinMetadata};
+use iota_mainnet_unlocks::MainnetUnlocksStore;
 use iota_metrics::spawn_monitored_task;
 use iota_open_rpc::Module;
+use iota_protocol_config::Chain;
 use iota_storage::{indexes::TotalBalance, key_value_store::TransactionKeyValueStore};
 use iota_types::{
     balance::Supply,
@@ -53,6 +57,7 @@ pub fn parse_to_type_tag(coin_type: Option<String>) -> Result<TypeTag, IotaRpcIn
 pub struct CoinReadApi {
     // Trait object w/ Box as we do not need to share this across multiple threads
     internal: Box<dyn CoinReadInternal + Send + Sync>,
+    unlocks_store: MainnetUnlocksStore,
 }
 
 impl CoinReadApi {
@@ -60,14 +65,15 @@ impl CoinReadApi {
         state: Arc<AuthorityState>,
         transaction_kv_store: Arc<TransactionKeyValueStore>,
         metrics: Arc<JsonRpcMetrics>,
-    ) -> Self {
-        Self {
+    ) -> Result<Self> {
+        Ok(Self {
             internal: Box::new(CoinReadInternalImpl::new(
                 state,
                 transaction_kv_store,
                 metrics,
             )),
-        }
+            unlocks_store: MainnetUnlocksStore::new()?,
+        })
     }
 }
 
@@ -253,6 +259,61 @@ impl CoinReadApiServer for CoinReadApi {
         .trace()
         .await
     }
+
+    #[instrument(skip(self))]
+    async fn get_circulating_supply(&self) -> RpcResult<IotaCirculatingSupply> {
+        let latest_cp_num = self
+            .internal
+            .get_state()
+            .get_latest_checkpoint_sequence_number()
+            .map_err(Error::from)?;
+        let latest_cp = self
+            .internal
+            .get_state()
+            .get_checkpoint_by_sequence_number(latest_cp_num)
+            .map_err(Error::from)?
+            .ok_or(Error::Unexpected("latest checkpoint not found".to_string()))?;
+        let cp_timestamp = latest_cp.timestamp_ms;
+
+        let system_state_summary = IotaSystemStateSummaryV2::try_from(
+            self.internal
+                .get_state()
+                .get_system_state()
+                .map_err(Error::from)?
+                .into_iota_system_state_summary(),
+        )
+        .map_err(Error::from)?;
+
+        let total_supply = system_state_summary.iota_total_supply;
+
+        let date_time = DateTime::from_timestamp_millis(
+            cp_timestamp
+                .try_into()
+                .map_err(|e| Error::Internal(anyhow::Error::from(e)))?,
+        )
+        .ok_or(Error::Unexpected(format!(
+            "failed to parse timestamp: {cp_timestamp}"
+        )))?;
+
+        let chain_identifier = self.internal.get_state().get_chain_identifier();
+        let chain = chain_identifier
+            .map(|c| c.chain())
+            .unwrap_or(Chain::Unknown);
+
+        let locked_supply = match chain {
+            Chain::Mainnet => self.unlocks_store.still_locked_tokens(date_time),
+            _ => 0,
+        };
+
+        let circulating_supply = total_supply - locked_supply;
+        let circulating_supply_percentage = circulating_supply as f64 / total_supply as f64;
+
+        Ok(IotaCirculatingSupply {
+            value: circulating_supply,
+            circulating_supply_percentage,
+            at_checkpoint: *latest_cp.sequence_number(),
+        })
+    }
 }
 
 #[cached(
@@ -503,6 +564,7 @@ mod tests {
             let kv_store = kv_store.unwrap_or_else(|| Arc::new(MockKeyValueStore::new()));
             Self {
                 internal: Box::new(CoinReadInternalImpl::new_for_tests(state, Some(kv_store))),
+                unlocks_store: MainnetUnlocksStore::new().unwrap(),
             }
         }
     }
@@ -1216,6 +1278,7 @@ mod tests {
 
             let coin_read_api = CoinReadApi {
                 internal: Box::new(mock_internal),
+                unlocks_store: MainnetUnlocksStore::new().unwrap(),
             };
 
             let response = coin_read_api.get_coin_metadata(coin_name.clone()).await;
@@ -1274,6 +1337,7 @@ mod tests {
 
             let coin_read_api = CoinReadApi {
                 internal: Box::new(mock_internal),
+                unlocks_store: MainnetUnlocksStore::new().unwrap(),
             };
 
             let response = coin_read_api.get_coin_metadata(coin_name.clone()).await;
@@ -1336,6 +1400,7 @@ mod tests {
                 });
             let coin_read_api = CoinReadApi {
                 internal: Box::new(mock_internal),
+                unlocks_store: MainnetUnlocksStore::new().unwrap(),
             };
 
             let response = coin_read_api.get_total_supply(coin_name.clone()).await;
@@ -1403,6 +1468,7 @@ mod tests {
 
             let coin_read_api = CoinReadApi {
                 internal: Box::new(mock_internal),
+                unlocks_store: MainnetUnlocksStore::new().unwrap(),
             };
 
             let response = coin_read_api.get_total_supply(coin_name.clone()).await;
diff --git a/crates/iota-mainnet-unlocks/Cargo.toml b/crates/iota-mainnet-unlocks/Cargo.toml
new file mode 100644
index 00000000000..bb0c63ca946
--- /dev/null
+++ b/crates/iota-mainnet-unlocks/Cargo.toml
@@ -0,0 +1,18 @@
+[package]
+name = "iota-mainnet-unlocks"
+version.workspace = true
+authors = ["IOTA Foundation <info@iota.org>"]
+edition = "2021"
+license = "Apache-2.0"
+publish = false
+
+[lints]
+workspace = true
+
+[dependencies]
+anyhow.workspace = true
+chrono.workspace = true
+csv = "1.3.1"
+regex.workspace = true
+serde.workspace = true
+tempfile.workspace = true
diff --git a/crates/iota-mainnet-unlocks/README.md b/crates/iota-mainnet-unlocks/README.md
new file mode 100644
index 00000000000..30036848e8d
--- /dev/null
+++ b/crates/iota-mainnet-unlocks/README.md
@@ -0,0 +1,21 @@
+# Mainnet Unlocks Store
+
+This repository contains a module that allows you to query Mainnet unlock data over time.
+It reads the aggregated unlock data from the crate root (`data/aggregated_mainnet_unlocks.csv`) and maintains an in-memory map of `DateTime` instances to the amount of tokens still locked at each point.
+
+## Regenerate Unlock Data
+
+The provided binary `bin/generate_aggregated_data.rs` can be used to regenerate the aggregated unlock data from the [IOTA Foundation's Mainnet Unlocks GitHub repository](https://github.com/iotaledger/new_supply.git).
+
+First, make sure you have `git` installed on your system.
+To regenerate the data, run the following command:
+
+```sh
+cargo run --bin generate_aggregated_data
+```
+
+## Running Tests
+
+```sh
+cargo test
+```
diff --git a/crates/iota-mainnet-unlocks/data/aggregated_mainnet_unlocks.csv b/crates/iota-mainnet-unlocks/data/aggregated_mainnet_unlocks.csv
new file mode 100644
index 00000000000..69c57d2a268
--- /dev/null
+++ b/crates/iota-mainnet-unlocks/data/aggregated_mainnet_unlocks.csv
@@ -0,0 +1,106 @@
+timestamp,amount_still_locked
+1970-01-01T00:00:00Z,1638422740898008000
+2023-10-18T00:00:00Z,1619285022277853000
+2023-11-01T00:00:00Z,1600147303657698000
+2023-11-15T00:00:00Z,1581009585037543000
+2023-11-29T00:00:00Z,1561871866417388000
+2023-12-13T00:00:00Z,1542734147797233000
+2023-12-27T00:00:00Z,1523596429177078000
+2024-01-10T00:00:00Z,1504458710556923000
+2024-01-24T00:00:00Z,1485320991936768000
+2024-02-07T00:00:00Z,1466183273316613000
+2024-02-21T00:00:00Z,1447045554696458000
+2024-03-06T00:00:00Z,1427907836076303000
+2024-03-20T00:00:00Z,1408770117456148000
+2024-04-03T00:00:00Z,1389632398835993000
+2024-04-17T00:00:00Z,1370494680215838000
+2024-05-01T00:00:00Z,1351356961595683000
+2024-05-15T00:00:00Z,1332219242975528000
+2024-05-29T00:00:00Z,1313081524355373000
+2024-06-12T00:00:00Z,1293943805735218000
+2024-06-26T00:00:00Z,1274806087115063000
+2024-07-10T00:00:00Z,1255668368494908000
+2024-07-24T00:00:00Z,1236530649874753000
+2024-08-07T00:00:00Z,1217392931254598000
+2024-08-21T00:00:00Z,1198255212634443000
+2024-09-04T00:00:00Z,1179117494014288000
+2024-09-18T00:00:00Z,1159979775394133000
+2024-10-02T00:00:00Z,1140842056773978000
+2024-10-16T00:00:00Z,1121704338153823000
+2024-10-30T00:00:00Z,1102566619533668000
+2024-11-13T00:00:00Z,1083428900913513000
+2024-11-27T00:00:00Z,1064291182293358000
+2024-12-11T00:00:00Z,1045153463673203000
+2024-12-25T00:00:00Z,1026015745053048000
+2025-01-08T00:00:00Z,1006878026432893000
+2025-01-22T00:00:00Z,987740307812738000
+2025-02-05T00:00:00Z,968602589192583000
+2025-02-19T00:00:00Z,949464870572428000
+2025-03-05T00:00:00Z,930327151952273000
+2025-03-19T00:00:00Z,911189433332118000
+2025-04-02T00:00:00Z,892051714711963000
+2025-04-16T00:00:00Z,872913996091808000
+2025-04-30T00:00:00Z,853776277471653000
+2025-05-14T00:00:00Z,834638558851498000
+2025-05-28T00:00:00Z,815500840231343000
+2025-06-11T00:00:00Z,796363121611188000
+2025-06-25T00:00:00Z,777225402991033000
+2025-07-09T00:00:00Z,758087684370878000
+2025-07-23T00:00:00Z,738949965750723000
+2025-08-06T00:00:00Z,719812247130568000
+2025-08-20T00:00:00Z,700674528510413000
+2025-09-03T00:00:00Z,681536809890258000
+2025-09-17T00:00:00Z,662399091270103000
+2025-10-01T00:00:00Z,643261372649948000
+2025-10-15T00:00:00Z,630890961637449000
+2025-10-29T00:00:00Z,618520550624950000
+2025-11-12T00:00:00Z,606150139612451000
+2025-11-26T00:00:00Z,593779728599952000
+2025-12-10T00:00:00Z,581409317587453000
+2025-12-24T00:00:00Z,569038906574954000
+2026-01-07T00:00:00Z,556668495562455000
+2026-01-21T00:00:00Z,544298084549956000
+2026-02-04T00:00:00Z,531927673537457000
+2026-02-18T00:00:00Z,519557262524958000
+2026-03-04T00:00:00Z,507186851512459000
+2026-03-18T00:00:00Z,494816440499960000
+2026-04-01T00:00:00Z,482446029487461000
+2026-04-15T00:00:00Z,470075618474962000
+2026-04-29T00:00:00Z,457705207462463000
+2026-05-13T00:00:00Z,445334796449964000
+2026-05-27T00:00:00Z,432964385437465000
+2026-06-10T00:00:00Z,420593974424966000
+2026-06-24T00:00:00Z,408223563412467000
+2026-07-08T00:00:00Z,395853152399968000
+2026-07-22T00:00:00Z,383482741387469000
+2026-08-05T00:00:00Z,371112330374970000
+2026-08-19T00:00:00Z,358741919362471000
+2026-09-02T00:00:00Z,346371508349972000
+2026-09-16T00:00:00Z,334001097337473000
+2026-09-30T00:00:00Z,321630686324974000
+2026-10-14T00:00:00Z,309260275312475000
+2026-10-28T00:00:00Z,296889864299976000
+2026-11-11T00:00:00Z,284519453287477000
+2026-11-25T00:00:00Z,272149042274978000
+2026-12-09T00:00:00Z,259778631262479000
+2026-12-23T00:00:00Z,247408220249980000
+2027-01-06T00:00:00Z,235037809237481000
+2027-01-20T00:00:00Z,222667398224982000
+2027-02-03T00:00:00Z,210296987212483000
+2027-02-17T00:00:00Z,197926576199984000
+2027-03-03T00:00:00Z,185556165187485000
+2027-03-17T00:00:00Z,173185754174986000
+2027-03-31T00:00:00Z,160815343162487000
+2027-04-14T00:00:00Z,148444932149988000
+2027-04-28T00:00:00Z,136074521137489000
+2027-05-12T00:00:00Z,123704110124990000
+2027-05-26T00:00:00Z,111333699112491000
+2027-06-09T00:00:00Z,98963288099992000
+2027-06-23T00:00:00Z,86592877087493000
+2027-07-07T00:00:00Z,74222466074994000
+2027-07-21T00:00:00Z,61852055062495000
+2027-08-04T00:00:00Z,49481644049996000
+2027-08-18T00:00:00Z,37111233037497000
+2027-09-01T00:00:00Z,24740822024998000
+2027-09-15T00:00:00Z,12370411012499000
+2027-09-29T00:00:00Z,0
diff --git a/crates/iota-mainnet-unlocks/src/bin/generate_aggregated_data.rs b/crates/iota-mainnet-unlocks/src/bin/generate_aggregated_data.rs
new file mode 100644
index 00000000000..3417e321f1b
--- /dev/null
+++ b/crates/iota-mainnet-unlocks/src/bin/generate_aggregated_data.rs
@@ -0,0 +1,161 @@
+// Copyright (c) 2025 IOTA Stiftung
+// SPDX-License-Identifier: Apache-2.0
+
+use std::{
+    collections::BTreeMap,
+    path::{Path, PathBuf},
+    process::Command,
+};
+
+use anyhow::{Context, Result};
+use chrono::{DateTime, Utc};
+use csv::{Reader, Writer};
+use iota_mainnet_unlocks::store::{INPUT_FILE as OUTPUT_FILE, StillLockedEntry};
+use regex::Regex;
+use tempfile::{TempDir, tempdir};
+
+// Folders in the raw data repository that contain the CSV files.
+const FOLDERS: &[&str] = &[
+    "Assembly_IF_Members",
+    "Assembly_Investors",
+    "IOTA_Airdrop",
+    "IOTA_Foundation",
+    "New_Investors",
+    "TEA",
+    "Treasury_DAO",
+    "UAE",
+];
+
+/// Clones the repository containing raw data into a temporary directory.
+fn clone_repo(tmp_dir: &TempDir) -> Result<PathBuf> {
+    let repo_path = tmp_dir.path().join("new_supply");
+
+    let status = Command::new("git")
+        .args([
+            "clone",
+            "--depth",
+            "1",
+            "https://github.com/iotaledger/new_supply.git",
+        ])
+        .arg(&repo_path)
+        .status()
+        .context("failed to execute `git clone`")?;
+
+    if !status.success() {
+        anyhow::bail!("`git clone` failed with exit status: {}", status);
+    }
+
+    Ok(repo_path)
+}
+
+/// Reads and aggregates the CSV unlock data from the cloned repository.
+/// Returns a BTreeMap keyed by unlock date (as a String) with the aggregated
+/// token amount (in nano-units).
+fn aggregate_unlocks(repo_path: &Path) -> Result<BTreeMap<String, u64>> {
+    let mut locked_by_date: BTreeMap<String, u64> = BTreeMap::new();
+
+    for folder in FOLDERS {
+        let csv_path = repo_path.join(folder).join("summary.csv");
+        println!("Processing file: {:?}", csv_path);
+
+        let mut rdr = Reader::from_path(&csv_path)
+            .with_context(|| format!("failed to open CSV file: {csv_path:?}"))?;
+
+        // Iterate over CSV records (header is skipped automatically).
+        for result in rdr.records() {
+            let record = result?;
+            if record.len() < 2 {
+                return Err(anyhow::anyhow!("invalid record: {record:?}"));
+            }
+
+            let tokens_str = record.get(0).unwrap().trim();
+            let unlock_date = record.get(1).unwrap().trim().to_string();
+
+            // Convert token amount to a u64 and then to nano-units.
+            let tokens: u64 = tokens_str
+                .parse()
+                .with_context(|| format!("invalid token amount: {tokens_str}"))?;
+            let nanos = tokens * 1000;
+
+            *locked_by_date.entry(unlock_date).or_insert(0) += nanos;
+        }
+    }
+    Ok(locked_by_date)
+}
+
+/// Converts a raw unlock date string into ISO 8601 format.
+/// It removes a trailing " ([+0-9]+ UTC)" suffix, replaces the first space with
+/// "T", and appends "Z".
+fn format_date(ts: &str, re: &Regex) -> Result<DateTime<Utc>> {
+    let cleaned = re.replace(ts, "");
+    let iso = if let Some(space_index) = cleaned.find(' ') {
+        let mut s = cleaned.to_string();
+        s.replace_range(space_index..=space_index, "T");
+        s.push('Z');
+        s
+    } else {
+        format!("{cleaned}Z")
+    };
+
+    Ok(DateTime::parse_from_rfc3339(&iso)
+        .context(format!("failed to parse timestamp: {iso}",))?
+        .with_timezone(&Utc))
+}
+
+/// Writes the aggregated unlock data into a CSV file.
+fn write_output_csv(output_file: &PathBuf, entries: &[StillLockedEntry]) -> Result<()> {
+    let mut wtr = Writer::from_path(output_file).with_context(|| {
+        format!(
+            "failed to create output CSV file: {}",
+            output_file.display()
+        )
+    })?;
+    for entry in entries {
+        wtr.serialize(entry)?;
+    }
+    wtr.flush()?;
+    Ok(())
+}
+
+fn main() -> Result<()> {
+    // Clone the repository containing raw data.
+    let tmp_dir = tempdir()?;
+    let repo_path = clone_repo(&tmp_dir)?;
+
+    let crate_dir = env!("CARGO_MANIFEST_DIR");
+    let output_file = PathBuf::from(crate_dir).join("data").join(OUTPUT_FILE);
+
+    // Aggregate unlock data from CSV files.
+    let locked_by_date = aggregate_unlocks(&repo_path)?;
+
+    if locked_by_date.is_empty() {
+        println!("No data found – writing empty CSV.");
+        write_output_csv(&output_file, &[])?;
+        return Ok(());
+    }
+
+    // Compute the total locked tokens.
+    let total_locked: u64 = locked_by_date.values().sum();
+
+    // Prepare to transform each entry into an output record.
+    let re = Regex::new(r" [\+0-9]+ UTC")?;
+    let mut cumulative_unlocked = 0;
+    let mut output_entries = Vec::new();
+
+    // Process unlock dates in order.
+    for (ts, &unlocked) in &locked_by_date {
+        cumulative_unlocked += unlocked;
+        let still_locked = total_locked - cumulative_unlocked;
+        let iso_ts = format_date(ts, &re)?;
+        output_entries.push(StillLockedEntry {
+            timestamp: iso_ts,
+            amount_still_locked: still_locked,
+        });
+    }
+
+    // Write the aggregated data to a CSV file.
+    write_output_csv(&output_file, &output_entries)?;
+    println!("Done: {}", output_file.display());
+
+    Ok(())
+}
diff --git a/crates/iota-mainnet-unlocks/src/lib.rs b/crates/iota-mainnet-unlocks/src/lib.rs
new file mode 100644
index 00000000000..d0767e90229
--- /dev/null
+++ b/crates/iota-mainnet-unlocks/src/lib.rs
@@ -0,0 +1,14 @@
+// Copyright (c) 2025 IOTA Stiftung
+// SPDX-License-Identifier: Apache-2.0
+
+/// Provides functionality for loading and querying token unlock data over time.
+///
+/// This module defines a `MainnetUnlocksStore` which maintains an in-memory
+/// mapping of timestamps to the amount of still-locked tokens at that point in
+/// time.
+///
+/// The unlock data is used to answer questions like:
+/// - "How many tokens are still locked at a specific timestamp?"
+pub mod store;
+
+pub use store::{MainnetUnlocksStore, StillLockedEntry};
diff --git a/crates/iota-mainnet-unlocks/src/store.rs b/crates/iota-mainnet-unlocks/src/store.rs
new file mode 100644
index 00000000000..aab0b9a8fcd
--- /dev/null
+++ b/crates/iota-mainnet-unlocks/src/store.rs
@@ -0,0 +1,232 @@
+// Copyright (c) 2025 IOTA Stiftung
+// SPDX-License-Identifier: Apache-2.0
+
+use std::{collections::BTreeMap, fs, path::PathBuf};
+
+use anyhow::{Context, Result};
+use chrono::{DateTime, Utc};
+use csv::ReaderBuilder;
+use serde::{Deserialize, Serialize};
+
+/// File name of the mainnet unlock data.
+pub const INPUT_FILE: &str = "aggregated_mainnet_unlocks.csv";
+
+/// Represents a single entry in the store.
+/// It defines how many tokens still remain locked at a specific point in time.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct StillLockedEntry {
+    /// UTC timestamp at which the tokens are still locked.
+    pub timestamp: DateTime<Utc>,
+    /// Total locked amount (nano-units) still locked at the timestamp.
+    pub amount_still_locked: u64,
+}
+
+/// In-memory store holding the aggregated token unlock data.
+#[derive(Debug, Clone)]
+pub struct MainnetUnlocksStore {
+    // Each entry represents the total number of tokens still locked at the specific point in time.
+    entries: BTreeMap<DateTime<Utc>, StillLockedEntry>,
+}
+
+impl MainnetUnlocksStore {
+    /// Creates a new store with the aggregated unlock data for mainnet.
+    /// Loads the aggregated token unlock data from the given JSON file at the
+    /// crate root.
+    pub fn new() -> Result<Self> {
+        let crate_dir = env!("CARGO_MANIFEST_DIR");
+        let path = PathBuf::from(crate_dir).join("data").join(INPUT_FILE);
+
+        let data = fs::read_to_string(&path)
+            .with_context(|| format!("could not read locked supply file: {:?}", path))?;
+
+        Self::from_csv_str(&data)
+    }
+
+    /// Parses the given CSV string into a `MainnetUnlocksStore`.
+    fn from_csv_str(csv_str: &str) -> Result<Self> {
+        let mut rdr = ReaderBuilder::new()
+            .has_headers(true)
+            .from_reader(csv_str.as_bytes());
+
+        let mut map = BTreeMap::new();
+
+        for result in rdr.deserialize() {
+            let entry: StillLockedEntry =
+                result.context("failed to deserialize CSV row into StillLockedEntry")?;
+
+            if let Some(old_entry) = map.insert(entry.timestamp, entry) {
+                return Err(anyhow::anyhow!(
+                    "duplicate entry found for timestamp: {}",
+                    old_entry.timestamp
+                ));
+            }
+        }
+
+        Ok(Self { entries: map })
+    }
+
+    /// Returns the total amount of tokens (in nano-units) that are still locked
+    /// at the given timestamp.
+    pub fn still_locked_tokens(&self, date_time: DateTime<Utc>) -> u64 {
+        self.entries
+            .range(..=date_time)
+            .next_back()
+            .map(|(_, entry)| entry.amount_still_locked)
+            .unwrap_or_else(|| {
+                // No earlier entries exist: use first available as retroactively valid
+                self.entries
+                    .iter()
+                    .next()
+                    .map(|(_, e)| e.amount_still_locked)
+                    .unwrap_or(0)
+            })
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use chrono::{TimeZone, Utc};
+
+    use super::*;
+
+    fn store(csv: &str) -> MainnetUnlocksStore {
+        MainnetUnlocksStore::from_csv_str(csv).unwrap()
+    }
+
+    #[test]
+    fn test_no_entries() {
+        let store = store("timestamp,amount_still_locked\n");
+        assert_eq!(store.still_locked_tokens(Utc::now()), 0);
+    }
+
+    #[test]
+    fn test_single_entry() {
+        let csv = r#"
+            timestamp,amount_still_locked
+            2000-01-01T00:00:00Z,999
+        "#;
+        let store = store(csv.trim());
+
+        let before = Utc.with_ymd_and_hms(1999, 12, 31, 23, 59, 59).unwrap();
+        let exact = Utc.with_ymd_and_hms(2000, 1, 1, 0, 0, 0).unwrap();
+        let after = Utc.with_ymd_and_hms(2001, 1, 1, 0, 0, 0).unwrap();
+
+        assert_eq!(store.still_locked_tokens(before), 999);
+        assert_eq!(store.still_locked_tokens(exact), 999);
+        assert_eq!(store.still_locked_tokens(after), 999);
+    }
+
+    #[test]
+    fn test_multiple_entries() {
+        let csv = r#"
+            timestamp,amount_still_locked
+            2023-01-01T00:00:00Z,300
+            2024-01-01T00:00:00Z,200
+            2025-01-01T00:00:00Z,100
+        "#;
+        let store = store(csv.trim());
+
+        let t0 = Utc.with_ymd_and_hms(2022, 12, 31, 0, 0, 0).unwrap();
+        let t0_between = Utc.with_ymd_and_hms(2023, 6, 1, 0, 0, 0).unwrap();
+        let t1 = Utc.with_ymd_and_hms(2023, 1, 1, 0, 0, 0).unwrap();
+        let t2 = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
+        let t3 = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
+        let t4 = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
+
+        assert_eq!(store.still_locked_tokens(t0), 300);
+        assert_eq!(store.still_locked_tokens(t1), 300);
+        assert_eq!(store.still_locked_tokens(t0_between), 300);
+        assert_eq!(store.still_locked_tokens(t2), 200);
+        assert_eq!(store.still_locked_tokens(t3), 100);
+        assert_eq!(store.still_locked_tokens(t4), 100);
+    }
+
+    #[test]
+    fn test_zero_at_latest_entry() {
+        let csv = r#"
+            timestamp,amount_still_locked
+            2023-01-01T00:00:00Z,1000
+            2025-01-01T00:00:00Z,0
+        "#;
+        let store = store(csv.trim());
+
+        let t1 = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
+        let t2 = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
+        let t3 = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
+
+        assert_eq!(store.still_locked_tokens(t1), 1000);
+        assert_eq!(store.still_locked_tokens(t2), 0);
+        assert_eq!(store.still_locked_tokens(t3), 0);
+    }
+
+    #[test]
+    fn test_gap_between_entries() {
+        let csv = r#"
+            timestamp,amount_still_locked
+            2020-01-01T00:00:00Z,1000
+            2030-01-01T00:00:00Z,100
+        "#;
+        let store = store(csv.trim());
+
+        let t_before = Utc.with_ymd_and_hms(2019, 1, 1, 0, 0, 0).unwrap();
+        let t_mid = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
+        let t_after = Utc.with_ymd_and_hms(2040, 1, 1, 0, 0, 0).unwrap();
+
+        assert_eq!(store.still_locked_tokens(t_before), 1000);
+        assert_eq!(store.still_locked_tokens(t_mid), 1000);
+        assert_eq!(store.still_locked_tokens(t_after), 100);
+    }
+
+    #[test]
+    fn test_dense_entry() {
+        let csv = r#"
+            timestamp,amount_still_locked
+            2023-10-01T00:00:00Z,300
+            2023-10-15T00:00:00Z,200
+            2023-11-01T00:00:00Z,100
+        "#;
+        let store = store(csv.trim());
+
+        let t_exact_mid = Utc.with_ymd_and_hms(2023, 10, 15, 0, 0, 0).unwrap();
+        let t_between = Utc.with_ymd_and_hms(2023, 10, 20, 0, 0, 0).unwrap();
+
+        assert_eq!(store.still_locked_tokens(t_exact_mid), 200);
+        assert_eq!(store.still_locked_tokens(t_between), 200);
+    }
+
+    #[test]
+    fn test_first_entry_is_retrospective() {
+        let csv = r#"
+            timestamp,amount_still_locked
+            2022-06-01T00:00:00Z,888
+        "#;
+        let store = store(csv.trim());
+
+        let far_before = Utc.with_ymd_and_hms(2000, 1, 1, 0, 0, 0).unwrap();
+        let just_before = Utc.with_ymd_and_hms(2022, 5, 31, 23, 59, 59).unwrap();
+        let exact = Utc.with_ymd_and_hms(2022, 6, 1, 0, 0, 0).unwrap();
+        let just_after = Utc.with_ymd_and_hms(2022, 6, 1, 0, 0, 1).unwrap();
+
+        assert_eq!(store.still_locked_tokens(far_before), 888);
+        assert_eq!(store.still_locked_tokens(just_before), 888);
+        assert_eq!(store.still_locked_tokens(exact), 888);
+        assert_eq!(store.still_locked_tokens(just_after), 888);
+    }
+
+    #[test]
+    fn test_unsorted_input() {
+        let csv = r#"
+            timestamp,amount_still_locked
+            2023-11-01T00:00:00Z,100
+            2023-01-01T00:00:00Z,300
+            2023-10-01T00:00:00Z,200
+        "#;
+        let store = store(csv.trim());
+
+        let query_before = Utc.with_ymd_and_hms(2023, 6, 1, 0, 0, 0).unwrap();
+        assert_eq!(store.still_locked_tokens(query_before), 300);
+
+        let query_exact = Utc.with_ymd_and_hms(2023, 10, 1, 0, 0, 0).unwrap();
+        assert_eq!(store.still_locked_tokens(query_exact), 200);
+    }
+}
diff --git a/crates/iota-node/src/lib.rs b/crates/iota-node/src/lib.rs
index d620a8ac7c1..e99cfe1652e 100644
--- a/crates/iota-node/src/lib.rs
+++ b/crates/iota-node/src/lib.rs
@@ -2029,7 +2029,7 @@ pub async fn build_http_server(
             state.clone(),
             kv_store.clone(),
             metrics.clone(),
-        ))?;
+        )?)?;
 
         // if run_with_range is enabled we want to prevent any transactions
         // run_with_range = None is normal operating conditions
diff --git a/crates/iota-open-rpc/spec/openrpc.json b/crates/iota-open-rpc/spec/openrpc.json
index 144999ed04f..8236f352884 100644
--- a/crates/iota-open-rpc/spec/openrpc.json
+++ b/crates/iota-open-rpc/spec/openrpc.json
@@ -3420,6 +3420,23 @@
         }
       }
     },
+    {
+      "name": "iotax_getCirculatingSupply",
+      "tags": [
+        {
+          "name": "Coin Query API"
+        }
+      ],
+      "description": "Return the circulating supply summary.",
+      "params": [],
+      "result": {
+        "name": "IotaCirculatingSupply",
+        "required": true,
+        "schema": {
+          "$ref": "#/components/schemas/IotaCirculatingSupply"
+        }
+      }
+    },
     {
       "name": "iotax_getCoinMetadata",
       "tags": [
@@ -7649,6 +7666,34 @@
           }
         }
       },
+      "IotaCirculatingSupply": {
+        "description": "Provides a summary of the circulating IOTA supply.",
+        "type": "object",
+        "required": [
+          "atCheckpoint",
+          "circulatingSupplyPercentage",
+          "value"
+        ],
+        "properties": {
+          "atCheckpoint": {
+            "description": "Timestamp (UTC) when the circulating supply was calculated.",
+            "type": "integer",
+            "format": "uint64",
+            "minimum": 0.0
+          },
+          "circulatingSupplyPercentage": {
+            "description": "Percentage of total supply that is currently circulating (range: 0.0 to 1.0).",
+            "type": "number",
+            "format": "double"
+          },
+          "value": {
+            "description": "Circulating supply in NANOS at the given timestamp.",
+            "type": "integer",
+            "format": "uint64",
+            "minimum": 0.0
+          }
+        }
+      },
       "IotaCoinMetadata": {
         "type": "object",
         "required": [


Reviews:
user: login: lzpap
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-27T09:29:32Z
user: login: lzpap
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-27T09:37:01Z
user: login: samuel-rufi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T13:41:51Z
user: login: lzpap
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-27T15:52:58Z
user: login: Thoralf-M
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-27T19:30:12Z
user: login: Thoralf-M
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-27T19:37:13Z
user: login: Thoralf-M
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-27T19:37:49Z
user: login: Thoralf-M
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-27T19:40:02Z
user: login: samuel-rufi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T10:47:03Z
user: login: samuel-rufi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T10:47:57Z
user: login: samuel-rufi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T10:50:21Z
user: login: Thoralf-M
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-28T11:02:48Z
user: login: muXxer
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T11:08:47Z
user: login: muXxer
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T11:10:27Z
user: login: samuel-rufi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T11:13:50Z
user: login: muXxer
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T12:26:27Z
user: login: samuel-rufi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T14:22:47Z
user: login: samuel-rufi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T14:43:09Z
user: login: samuel-rufi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T14:47:08Z
user: login: kodemartin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T15:30:53Z
user: login: kodemartin
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T16:07:02Z
user: login: lzpap
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-29T10:53:46Z
user: login: samuel-rufi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-30T01:17:17Z
user: login: samuel-rufi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-30T01:23:25Z
user: login: samuel-rufi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-30T01:23:51Z
user: login: samuel-rufi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-30T01:24:06Z
user: login: samuel-rufi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-30T01:26:28Z
user: login: samuel-rufi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-30T01:36:35Z
user: login: samuel-rufi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-30T10:11:48Z
user: login: Thoralf-M
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-30T11:14:10Z
user: login: samuel-rufi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-30T12:52:23Z
user: login: samuel-rufi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-30T12:53:02Z
user: login: samuel-rufi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-30T13:01:34Z
user: login: Thoralf-M
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-30T17:59:01Z
user: login: samuel-rufi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-30T18:03:07Z
user: login: samuel-rufi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-30T20:38:34Z
user: login: thibault-martinez
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-31T07:18:02Z
user: login: samuel-rufi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T07:32:48Z
user: login: thibault-martinez
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-31T07:51:15Z
user: login: kodemartin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T08:22:14Z
user: login: kodemartin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T08:23:18Z
user: login: kodemartin
body: lgtm
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T08:24:39Z
user: login: samuel-rufi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T08:38:23Z
user: login: Thoralf-M
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-31T09:19:52Z
user: login: samuel-rufi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T10:52:42Z
user: login: samuel-rufi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T11:20:14Z
user: login: thibault-martinez
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-31T11:29:46Z
user: login: thibault-martinez
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-31T11:35:57Z
user: login: thibault-martinez
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-31T11:48:26Z
user: login: samuel-rufi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T11:54:47Z
user: login: samuel-rufi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T11:56:17Z
user: login: samuel-rufi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T12:06:08Z
user: login: samuel-rufi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T12:15:46Z
user: login: samuel-rufi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T12:16:00Z
user: login: thibault-martinez
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-31T12:31:20Z
user: login: muXxer
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T12:51:20Z
user: login: lzpap
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-31T12:58:51Z
user: login: miker83z
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T13:56:10Z

Review comments:
path: crates/iota-indexer/token_unlocks_data/Unmigrated_Legacy_Tokens.csv
user: login: lzpap
body: this file is definitely redundant these funds are unlocked
created_at: 2025-03-27T09:29:32Z
author_association: MEMBER
path: crates/iota-indexer/token_unlocks_data/Assembly_IF_Members.csv
user: login: lzpap
body: I'm not sure that we need such detailed csv files containing all past unlocks. A simple table that summarizes the number of still locked tokens per two weeks would be enough.
 - You could add a script next to the file containing this table that actually fetches from the `new_supply` repo and prepares it, just for reference on how we calculated it.
 - Then when the query comes in you don't have to go through all rows of all entities to calculate still locked tokens but do a simple lookup.
 - Is there anything that speaks against adding this endpoint to the JSON RPC as well? So basically not making it part of the extended api but the coin/read api?
created_at: 2025-03-27T09:37:01Z
author_association: MEMBER
path: crates/iota-indexer/token_unlocks_data/Assembly_IF_Members.csv
user: login: samuel-rufi
body: Hey @lzpap makes sense, will simplify it. Just wanted to ensure it can be verified - which might even be easier with the script!

> Is there anything that speaks against adding this endpoint to the JSON RPC as well? So basically not making it part of the extended api but the coin/read api?

I think it makes total sense to expose the endpoint directly in the JSON-RPC under coin/read, rather than as part of the extended API. Then the question now is where do we store the data best considering both crates need access to the `TokenUnlocksStore`. 

Two ideas:
- New crate, e.g. iota-circulating-supply, which holds both logic and data.
- Add the store to iota-types and place the generated data in the crate or repo root.

A separate crate for this might make sense as I think iota-types doesn't fit that well. Also the crates iota-json-rpc-types or iota-json-rpc-api don't really fit. What do you think?
created_at: 2025-03-27T13:41:51Z
author_association: CONTRIBUTOR
path: crates/iota-indexer/token_unlocks_data/Assembly_IF_Members.csv
user: login: lzpap
body: A new crate sounds good, just name it something like `iota-mainnet-unlocks` so its purpose is clear
created_at: 2025-03-27T15:52:57Z
author_association: MEMBER
diff_hunk: @@ -223,3 +224,19 @@ pub struct AddressMetrics {
     /// The count of daily unique sender addresses.
     pub daily_active_addresses: u64,
 }
+
+/// Provides a summary of the circulating IOTA supply.
+#[serde_as]
+#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct IotaCirculatingSupplySummary {
+    /// Circulating supply in IOTA at the given timestamp.
+    pub value: f64,
path: crates/iota-json-rpc-types/src/iota_extended.rs
user: login: Thoralf-M
body: Why is this IOTA amount as f64 and not the nanos as u64?
created_at: 2025-03-27T19:30:12Z
author_association: MEMBER
diff_hunk: @@ -223,3 +224,19 @@ pub struct AddressMetrics {
     /// The count of daily unique sender addresses.
     pub daily_active_addresses: u64,
 }
+
+/// Provides a summary of the circulating IOTA supply.
+#[serde_as]
+#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct IotaCirculatingSupplySummary {
+    /// Circulating supply in IOTA at the given timestamp.
+    pub value: f64,
+    /// Percentage of total supply that is currently circulating (range: 0.0 to
+    /// 1.0).
+    pub circulating_supply_percentage: f64,
+    /// Timestamp (UTC) when the circulating supply was calculated.
+    #[serde(with = "chrono::serde::ts_seconds")]
+    #[schemars(with = "i64")]
+    pub timestamp: DateTime<Utc>,
path: crates/iota-json-rpc-types/src/iota_extended.rs
user: login: Thoralf-M
body: Do requests work if the indexer is not synced up to the latest checkpoint? If not, then this could end up being wrong
Don't we usually return the checkpoint instead? Or maybe both, timestamp and checkpoint
created_at: 2025-03-27T19:37:13Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,30 @@
+// Copyright (c) 2024 IOTA Stiftung
path: crates/iota-indexer/src/models/token_unlocks.rs
user: login: Thoralf-M
body: 2025 also in other files
created_at: 2025-03-27T19:37:49Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,155 @@
+// Copyright (c) 2024 IOTA Stiftung
+// SPDX-License-Identifier: Apache-2.0
+
+use std::{fs, path::PathBuf};
+
+use chrono::{DateTime, Utc};
+use csv::ReaderBuilder;
+
+use crate::models::token_unlocks::{Entity, TokenUnlock};
+
+/// In-memory store holding all token unlock schedules across entities.
+/// Allows querying how many tokens are currently locked.
+#[derive(Debug, Clone)]
+pub struct TokenUnlocksStore {
+    /// All known entities with their respective unlock schedules.
+    entities: Vec<Entity>,
+}
+
+impl TokenUnlocksStore {
+    /// Loads all token unlock data from CSV files located in the
+    /// `token_unlocks_data` directory at the crate root. Each CSV file
+    /// represents the unlock schedule for a single entity.
+    ///
+    /// Panics if the directory is missing or unreadable.
+    pub fn load() -> Self {
+        let crate_dir = env!("CARGO_MANIFEST_DIR");
+        let dir = PathBuf::from(crate_dir).join("token_unlocks_data");
+
+        let paths = fs::read_dir(&dir)
+            .unwrap_or_else(|_| panic!("Token Unlock CSV directory not found: {:?}", dir));
+
+        let entities = paths
+            .filter_map(|entry| entry.ok())
+            .filter(|e| e.path().extension().unwrap_or_default() == "csv")
+            .map(|entry| {
+                let path = entry.path();
+                let file_name = path.file_stem().unwrap().to_string_lossy().to_string();
+                let file = fs::File::open(&path)
+                    .unwrap_or_else(|_| panic!("Failed to open file {:?}", path));
path: crates/iota-indexer/src/store/token_unlocks.rs
user: login: Thoralf-M
body: You can do some cleanup like this in more places
```suggestion
            .unwrap_or_else(|_| panic!("Token Unlock CSV directory not found: {dir:?}"));

        let entities = paths
            .filter_map(|entry| entry.ok())
            .filter(|e| e.path().extension().unwrap_or_default() == "csv")
            .map(|entry| {
                let path = entry.path();
                let file_name = path.file_stem().unwrap().to_string_lossy().to_string();
                let file = fs::File::open(&path)
                    .unwrap_or_else(|_| panic!("Failed to open file {path:?}"));
```
created_at: 2025-03-27T19:40:02Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,155 @@
+// Copyright (c) 2024 IOTA Stiftung
+// SPDX-License-Identifier: Apache-2.0
+
+use std::{fs, path::PathBuf};
+
+use chrono::{DateTime, Utc};
+use csv::ReaderBuilder;
+
+use crate::models::token_unlocks::{Entity, TokenUnlock};
+
+/// In-memory store holding all token unlock schedules across entities.
+/// Allows querying how many tokens are currently locked.
+#[derive(Debug, Clone)]
+pub struct TokenUnlocksStore {
+    /// All known entities with their respective unlock schedules.
+    entities: Vec<Entity>,
+}
+
+impl TokenUnlocksStore {
+    /// Loads all token unlock data from CSV files located in the
+    /// `token_unlocks_data` directory at the crate root. Each CSV file
+    /// represents the unlock schedule for a single entity.
+    ///
+    /// Panics if the directory is missing or unreadable.
+    pub fn load() -> Self {
+        let crate_dir = env!("CARGO_MANIFEST_DIR");
+        let dir = PathBuf::from(crate_dir).join("token_unlocks_data");
+
+        let paths = fs::read_dir(&dir)
+            .unwrap_or_else(|_| panic!("Token Unlock CSV directory not found: {:?}", dir));
+
+        let entities = paths
+            .filter_map(|entry| entry.ok())
+            .filter(|e| e.path().extension().unwrap_or_default() == "csv")
+            .map(|entry| {
+                let path = entry.path();
+                let file_name = path.file_stem().unwrap().to_string_lossy().to_string();
+                let file = fs::File::open(&path)
+                    .unwrap_or_else(|_| panic!("Failed to open file {:?}", path));
path: crates/iota-indexer/src/store/token_unlocks.rs
user: login: samuel-rufi
body: This code has been changed with the latest commit, should be fine now
created_at: 2025-03-28T10:47:03Z
author_association: CONTRIBUTOR
path: crates/iota-indexer/token_unlocks_data/Assembly_IF_Members.csv
user: login: samuel-rufi
body: Hey @lzpap this has been added. The only thing missing is passing the Chain ID / handling that case.
created_at: 2025-03-28T10:47:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -223,3 +224,19 @@ pub struct AddressMetrics {
     /// The count of daily unique sender addresses.
     pub daily_active_addresses: u64,
 }
+
+/// Provides a summary of the circulating IOTA supply.
+#[serde_as]
+#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct IotaCirculatingSupplySummary {
+    /// Circulating supply in IOTA at the given timestamp.
+    pub value: f64,
path: crates/iota-json-rpc-types/src/iota_extended.rs
user: login: samuel-rufi
body: Because of the NANO to IOTA conversion

`let circulating_supply_value = circulating_supply as f64 / 1_000_000_000.0;`

See https://github.com/iotaledger/iota/blob/77a773392a41a9cd8153120dfcf5f03b36b8d51a/crates/iota-indexer/src/indexer_reader.rs#L1963

I guess we could round it down? 

Suiexplorer uses also float values https://sui-circulation.suiexplorer.com/api/current_month_sui_circulation
created_at: 2025-03-28T10:50:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -223,3 +224,19 @@ pub struct AddressMetrics {
     /// The count of daily unique sender addresses.
     pub daily_active_addresses: u64,
 }
+
+/// Provides a summary of the circulating IOTA supply.
+#[serde_as]
+#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct IotaCirculatingSupplySummary {
+    /// Circulating supply in IOTA at the given timestamp.
+    pub value: f64,
path: crates/iota-json-rpc-types/src/iota_extended.rs
user: login: Thoralf-M
body: > Because of the NANO to IOTA conversion

Sure, but why do this conversion in the first place and don't return the exact amount?
created_at: 2025-03-28T11:02:48Z
author_association: MEMBER
diff_hunk: @@ -223,3 +224,19 @@ pub struct AddressMetrics {
     /// The count of daily unique sender addresses.
     pub daily_active_addresses: u64,
 }
+
+/// Provides a summary of the circulating IOTA supply.
+#[serde_as]
+#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct IotaCirculatingSupplySummary {
+    /// Circulating supply in IOTA at the given timestamp.
+    pub value: f64,
path: crates/iota-json-rpc-types/src/iota_extended.rs
user: login: muXxer
body: using floats is a no go IMO.
created_at: 2025-03-28T11:08:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -223,3 +224,19 @@ pub struct AddressMetrics {
     /// The count of daily unique sender addresses.
     pub daily_active_addresses: u64,
 }
+
+/// Provides a summary of the circulating IOTA supply.
+#[serde_as]
+#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct IotaCirculatingSupplySummary {
+    /// Circulating supply in IOTA at the given timestamp.
+    pub value: f64,
path: crates/iota-json-rpc-types/src/iota_extended.rs
user: login: muXxer
body: I mean, maybe it's not THAT important if information is lost by rounding in this endpoint, because it doesn't affect a single users funds, but still I wonder if we can't simply return the nanos instead.
created_at: 2025-03-28T11:10:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -395,6 +396,7 @@ iota-json-rpc-api = { path = "crates/iota-json-rpc-api" }
 iota-json-rpc-types = { path = "crates/iota-json-rpc-types" }
 iota-keys = { path = "crates/iota-keys" }
 iota-macros = { path = "crates/iota-macros" }
+iota-mainnet-unlocks = { path = "crates/iota-mainnet-unlocks" }
path: Cargo.toml
user: login: muXxer
body: you need to add a new crate always to the `crates-filters.yml` and the `CODEOWNERS` file.
created_at: 2025-03-28T11:13:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -223,3 +224,19 @@ pub struct AddressMetrics {
     /// The count of daily unique sender addresses.
     pub daily_active_addresses: u64,
 }
+
+/// Provides a summary of the circulating IOTA supply.
+#[serde_as]
+#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct IotaCirculatingSupplySummary {
+    /// Circulating supply in IOTA at the given timestamp.
+    pub value: f64,
path: crates/iota-json-rpc-types/src/iota_extended.rs
user: login: samuel-rufi
body: The idea is that the value is easier to understand for the integrators without having to do another conversion. Do you have any preference here? @lzpap

The difference in return of total supply (nanos) vs circulating supply (iotas) might be however a bit problematic. Still it's a summary and could also be fine like this. But I will change it if desired.
created_at: 2025-03-28T11:13:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1953,6 +1959,31 @@ impl IndexerReader {
         Ok(TreasuryCap::try_from(treasury_cap_obj_object)?.total_supply)
     }
 
+    /// Get the circulating supply summary in a blocking task.
+    pub async fn get_circulating_supply_summary_in_blocking_task(
+        &self,
+    ) -> Result<IotaCirculatingSupplySummary, IndexerError> {
+        let total_supply = self
+            .spawn_blocking(|this| this.get_latest_iota_system_state())
+            .await?
+            .iota_total_supply();
+
+        let now = Utc::now();
path: crates/iota-indexer/src/indexer_reader.rs
user: login: muXxer
body: what information does this provide to the user?

shouldn't we rather return the checkpoint timestamp of the current checkpoint? I guess the info contained in your response can change with every checkpoint, so it would matter what checkpoint the node has synced right now. the current machines timestamp gives a wrong feeling of being up to date IMO.
created_at: 2025-03-28T11:36:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1953,6 +1959,31 @@ impl IndexerReader {
         Ok(TreasuryCap::try_from(treasury_cap_obj_object)?.total_supply)
     }
 
+    /// Get the circulating supply summary in a blocking task.
+    pub async fn get_circulating_supply_summary_in_blocking_task(
+        &self,
+    ) -> Result<IotaCirculatingSupplySummary, IndexerError> {
+        let total_supply = self
+            .spawn_blocking(|this| this.get_latest_iota_system_state())
+            .await?
+            .iota_total_supply();
+
+        let now = Utc::now();
+        let locked_supply = self.token_unlocks_store.still_locked_tokens(now);
+        let circulating_supply = total_supply - locked_supply;
+
+        // Convert NANOS to IOTA
+        let circulating_supply_value = circulating_supply as f64 / 1_000_000_000.0;
path: crates/iota-indexer/src/indexer_reader.rs
user: login: muXxer
body: I would return nanos tbh.
what do we normally return? I guess all responses are in nanos, right?
created_at: 2025-03-28T11:37:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -253,6 +258,35 @@ impl CoinReadApiServer for CoinReadApi {
         .trace()
         .await
     }
+
+    #[instrument(skip(self))]
+    async fn get_circulating_supply(&self) -> RpcResult<IotaCirculatingSupplySummary> {
+        let system_state_summary = IotaSystemStateSummaryV2::try_from(
+            self.internal
+                .get_state()
+                .get_system_state()
+                .map_err(Error::from)?
+                .into_iota_system_state_summary(),
+        )
+        .map_err(Error::from)?;
+
+        let total_supply = system_state_summary.iota_total_supply;
+
+        let now = Utc::now();
path: crates/iota-json-rpc/src/coin_api.rs
user: login: muXxer
body: Same as in the other comment, we should use checkpoint timestamp IMO.
created_at: 2025-03-28T11:38:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -253,6 +258,35 @@ impl CoinReadApiServer for CoinReadApi {
         .trace()
         .await
     }
+
+    #[instrument(skip(self))]
+    async fn get_circulating_supply(&self) -> RpcResult<IotaCirculatingSupplySummary> {
+        let system_state_summary = IotaSystemStateSummaryV2::try_from(
+            self.internal
+                .get_state()
+                .get_system_state()
+                .map_err(Error::from)?
+                .into_iota_system_state_summary(),
+        )
+        .map_err(Error::from)?;
+
+        let total_supply = system_state_summary.iota_total_supply;
+
+        let now = Utc::now();
+        let locked_supply = self.token_unlocks_store.still_locked_tokens(now);
+        let circulating_supply = total_supply - locked_supply;
+
+        // Convert NANOS to IOTA
+        let circulating_supply_value = circulating_supply as f64 / 1_000_000_000.0;
path: crates/iota-json-rpc/src/coin_api.rs
user: login: muXxer
body: Also nanos here.
created_at: 2025-03-28T11:38:32Z
author_association: CONTRIBUTOR
path: crates/iota-mainnet-unlocks/README.md
user: login: muXxer
body: empty file?
created_at: 2025-03-28T11:39:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1953,6 +1959,31 @@ impl IndexerReader {
         Ok(TreasuryCap::try_from(treasury_cap_obj_object)?.total_supply)
     }
 
+    /// Get the circulating supply summary in a blocking task.
+    pub async fn get_circulating_supply_summary_in_blocking_task(
+        &self,
+    ) -> Result<IotaCirculatingSupplySummary, IndexerError> {
+        let total_supply = self
+            .spawn_blocking(|this| this.get_latest_iota_system_state())
+            .await?
+            .iota_total_supply();
+
+        let now = Utc::now();
path: crates/iota-indexer/src/indexer_reader.rs
user: login: muXxer
body: Ah I see, you calculate the unlocked tokens based on current time. But the total doesn't always stay the same, so it would be important in which epoch we are at least, if you know what I mean.
created_at: 2025-03-28T11:42:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1953,6 +1959,31 @@ impl IndexerReader {
         Ok(TreasuryCap::try_from(treasury_cap_obj_object)?.total_supply)
     }
 
+    /// Get the circulating supply summary in a blocking task.
+    pub async fn get_circulating_supply_summary_in_blocking_task(
+        &self,
+    ) -> Result<IotaCirculatingSupplySummary, IndexerError> {
+        let total_supply = self
+            .spawn_blocking(|this| this.get_latest_iota_system_state())
+            .await?
+            .iota_total_supply();
+
+        let now = Utc::now();
path: crates/iota-indexer/src/indexer_reader.rs
user: login: samuel-rufi
body: Yes, this still needs to be changed as the logic changed in the underlying store. Previously, it was calculated for the current time. Will change this for sure
created_at: 2025-03-28T14:22:47Z
author_association: CONTRIBUTOR
path: crates/iota-indexer/token_unlocks_data/Assembly_IF_Members.csv
user: login: samuel-rufi
body: Regarding the chain id: not fully sure if we really should pass it during the Indexer configuration. I was able to integrate it as of the latest commit, however I think it would be better if the indexer relies on the node as to avoid any configuration differences between the two. I would look into directly integrating it, we could at least try fetch the checkpoint 0 digest and calculate the chain identifier from that. Does this sound good? @kodemartin @lzpap 
created_at: 2025-03-28T14:43:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -223,3 +224,19 @@ pub struct AddressMetrics {
     /// The count of daily unique sender addresses.
     pub daily_active_addresses: u64,
 }
+
+/// Provides a summary of the circulating IOTA supply.
+#[serde_as]
+#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct IotaCirculatingSupplySummary {
+    /// Circulating supply in IOTA at the given timestamp.
+    pub value: f64,
path: crates/iota-json-rpc-types/src/iota_extended.rs
user: login: samuel-rufi
body: Changed to NANOS
created_at: 2025-03-28T14:47:08Z
author_association: CONTRIBUTOR
path: crates/iota-indexer/token_unlocks_data/Assembly_IF_Members.csv
user: login: kodemartin
body: > we could at least try fetch the checkpoint 0 digest and calculate the chain identifier from that. Does this sound good?

There is already a method in the `ReadApi` to `get_chain_identifier` which does exactly that.
created_at: 2025-03-28T15:30:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -223,3 +224,19 @@ pub struct AddressMetrics {
     /// The count of daily unique sender addresses.
     pub daily_active_addresses: u64,
 }
+
+/// Provides a summary of the circulating IOTA supply.
+#[serde_as]
+#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct IotaCirculatingSupplySummary {
path: crates/iota-json-rpc-types/src/iota_extended.rs
user: login: kodemartin
body: It makes sense to add this to `iota_coin` since we are adding this to the `CoinApi`.
created_at: 2025-03-28T15:33:34Z
author_association: CONTRIBUTOR
path: crates/iota-mainnet-unlocks/data/create_aggregated_mainnet_unlocks.sh
user: login: kodemartin
body: Using a bash script here seems rather unconventional since it involves processing the csv file, producing the map, and performing aggregating operations on that map.

Using Rust directly (we have a dedicated crate afterall) could be more beneficial to add type safety in the map entries, handle natively the git repository (through `git2`), handle the csv processing with `csv` and thus lead to a more readable script where we can better prove correctness having the type system and the compiler watching our back, and widely common libraries to assist that with common operations like the ones required.


created_at: 2025-03-28T15:57:25Z
author_association: CONTRIBUTOR
path: crates/iota-mainnet-unlocks/data/create_aggregated_mainnet_unlocks.sh
user: login: kodemartin
body: Plus the output could be simplified to a simple `timestamp - value` map, no?
created_at: 2025-03-28T15:59:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,254 @@
+// Copyright (c) 2025 IOTA Stiftung
+// SPDX-License-Identifier: Apache-2.0
+
+use std::{collections::BTreeMap, fs, path::PathBuf};
+
+use anyhow::{Context, Result};
+use chrono::{DateTime, Utc};
+use iota_types::supported_protocol_versions::Chain;
+use serde::Deserialize;
+
+/// File name of the mainnet unlock data.
+const AGGREGATED_MAINNET_UNLOCKS_FILE: &str = "mainnet_unlocks_aggregated.json";
+
+/// Represents a single entry in the aggregated unlock data.
+/// It defines how many tokens still remain locked at a specific point in time.
+#[derive(Debug, Clone, Deserialize)]
+pub struct StillLockedEntry {
+    /// UTC timestamp at which the tokens are still locked.
+    pub timestamp: DateTime<Utc>,
+    /// Total locked amount (nano-units) still locked at the timestamp.
+    pub amount_still_locked: u64,
+}
+
+/// In-memory store holding the aggregated token unlock data.
+#[derive(Debug, Clone)]
+pub struct AggregatedUnlocksStore {
+    // Each entry represents the total number of tokens still locked at the specific point in time.
+    entries: BTreeMap<DateTime<Utc>, StillLockedEntry>,
+}
+
+impl AggregatedUnlocksStore {
+    /// Creates a new `AggregatedUnlocksStore` based on the provided `Chain`.
+    ///
+    /// For `Chain::Mainnet`, this method loads the precompiled unlock data from
+    /// file, since the unlock data is only valid for mainnet. For any other
+    /// chain (e.g. testnet, devnet), it returns an empty store, as no
+    /// precompiled unlock data is applicable.
+    pub fn from_chain(chain: Chain) -> Result<Self> {
+        match chain {
+            Chain::Mainnet => Self::load_mainnet_unlocks(),
+            // The precompiled unlock data is only valid for mainnet. For other chains, return an
+            // empty store.
+            _ => Ok(Self::default()),
+        }
+    }
+
+    /// Loads the aggregated token unlock data from the given JSON file at the
+    /// crate root.
+    fn load_mainnet_unlocks() -> Result<Self> {
+        let crate_dir = env!("CARGO_MANIFEST_DIR");
+        let path = PathBuf::from(crate_dir)
+            .join("data")
+            .join(AGGREGATED_MAINNET_UNLOCKS_FILE);
+
+        let data = fs::read_to_string(&path)
+            .with_context(|| format!("could not read locked supply file: {:?}", path))?;
+
+        Self::from_json(&data)
+    }
+
+    /// Parses the given JSON string into a `TokenUnlocksStore`.
+    fn from_json(json: &str) -> Result<Self> {
+        let parsed: Vec<StillLockedEntry> =
+            serde_json::from_str(json).context("invalid JSON format in unlock data")?;
+
+        let mut map = BTreeMap::new();
+        for entry in parsed {
+            if map.contains_key(&entry.timestamp) {
+                return Err(anyhow::anyhow!(
+                    "duplicate entry found for timestamp: {}",
+                    entry.timestamp
+                ));
+            }
+            map.insert(entry.timestamp, entry);
+        }
+
+        Ok(Self { entries: map })
+    }
+
+    /// Returns the total amount of tokens (in nano-units) that are still locked
+    /// at the given timestamp.
+    pub fn still_locked_tokens(&self, timestamp: DateTime<Utc>) -> u64 {
+        self.entries
+            .range(..=timestamp)
+            .next_back()
+            .map(|(_, entry)| entry.amount_still_locked)
+            .unwrap_or_else(|| {
+                // No earlier entries exist: use first available as retroactively valid
+                self.entries
+                    .iter()
+                    .next()
path: crates/iota-mainnet-unlocks/src/unlocks.rs
user: login: kodemartin
body: You can use [first_entry](https://doc.rust-lang.org/std/collections/struct.BTreeMap.html#method.first_entry)
created_at: 2025-03-28T16:05:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -223,3 +224,19 @@ pub struct AddressMetrics {
     /// The count of daily unique sender addresses.
     pub daily_active_addresses: u64,
 }
+
+/// Provides a summary of the circulating IOTA supply.
+#[serde_as]
+#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct IotaCirculatingSupplySummary {
+    /// Circulating supply in IOTA at the given timestamp.
+    pub value: f64,
path: crates/iota-json-rpc-types/src/iota_extended.rs
user: login: lzpap
body: I'm fine with that, but then be vocal about it in the API description, so consumers are aware that this is not in IOTAs.
created_at: 2025-03-29T10:53:46Z
author_association: MEMBER
diff_hunk: @@ -223,3 +224,19 @@ pub struct AddressMetrics {
     /// The count of daily unique sender addresses.
     pub daily_active_addresses: u64,
 }
+
+/// Provides a summary of the circulating IOTA supply.
+#[serde_as]
+#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct IotaCirculatingSupplySummary {
+    /// Circulating supply in IOTA at the given timestamp.
+    pub value: f64,
+    /// Percentage of total supply that is currently circulating (range: 0.0 to
+    /// 1.0).
+    pub circulating_supply_percentage: f64,
+    /// Timestamp (UTC) when the circulating supply was calculated.
+    #[serde(with = "chrono::serde::ts_seconds")]
+    #[schemars(with = "i64")]
+    pub timestamp: DateTime<Utc>,
path: crates/iota-json-rpc-types/src/iota_extended.rs
user: login: samuel-rufi
body: I think it's crucial to have the checkpoint in there, to know at which it was calculated. Changed it!
created_at: 2025-03-30T01:17:17Z
author_association: CONTRIBUTOR
path: crates/iota-mainnet-unlocks/data/create_aggregated_mainnet_unlocks.sh
user: login: samuel-rufi
body: Hey @kodemartin, I agree handling the .csv files in bash is not ideal. I will change it to Rust including the simplification of the output.
created_at: 2025-03-30T01:23:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -253,6 +258,35 @@ impl CoinReadApiServer for CoinReadApi {
         .trace()
         .await
     }
+
+    #[instrument(skip(self))]
+    async fn get_circulating_supply(&self) -> RpcResult<IotaCirculatingSupplySummary> {
+        let system_state_summary = IotaSystemStateSummaryV2::try_from(
+            self.internal
+                .get_state()
+                .get_system_state()
+                .map_err(Error::from)?
+                .into_iota_system_state_summary(),
+        )
+        .map_err(Error::from)?;
+
+        let total_supply = system_state_summary.iota_total_supply;
+
+        let now = Utc::now();
path: crates/iota-json-rpc/src/coin_api.rs
user: login: samuel-rufi
body: Fixed
created_at: 2025-03-30T01:23:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1953,6 +1959,31 @@ impl IndexerReader {
         Ok(TreasuryCap::try_from(treasury_cap_obj_object)?.total_supply)
     }
 
+    /// Get the circulating supply summary in a blocking task.
+    pub async fn get_circulating_supply_summary_in_blocking_task(
+        &self,
+    ) -> Result<IotaCirculatingSupplySummary, IndexerError> {
+        let total_supply = self
+            .spawn_blocking(|this| this.get_latest_iota_system_state())
+            .await?
+            .iota_total_supply();
+
+        let now = Utc::now();
path: crates/iota-indexer/src/indexer_reader.rs
user: login: samuel-rufi
body: Fixed
created_at: 2025-03-30T01:24:06Z
author_association: CONTRIBUTOR
path: crates/iota-indexer/token_unlocks_data/Assembly_IF_Members.csv
user: login: samuel-rufi
body: Yes, but unfortunately not exposed in the `IndexerReader`. I created a function in the reader that properly gets the chain identifier from the database which should also work better with pruning. In a separate task we should then adjust the `ReadApi` to make use of it.
created_at: 2025-03-30T01:26:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -223,3 +224,19 @@ pub struct AddressMetrics {
     /// The count of daily unique sender addresses.
     pub daily_active_addresses: u64,
 }
+
+/// Provides a summary of the circulating IOTA supply.
+#[serde_as]
+#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct IotaCirculatingSupplySummary {
path: crates/iota-json-rpc-types/src/iota_extended.rs
user: login: samuel-rufi
body: Fixed in https://github.com/iotaledger/iota/pull/6123/commits/4f5227a8b03b6c393d86a58dc0aa1faa2b074426
created_at: 2025-03-30T01:36:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,254 @@
+// Copyright (c) 2025 IOTA Stiftung
+// SPDX-License-Identifier: Apache-2.0
+
+use std::{collections::BTreeMap, fs, path::PathBuf};
+
+use anyhow::{Context, Result};
+use chrono::{DateTime, Utc};
+use iota_types::supported_protocol_versions::Chain;
+use serde::Deserialize;
+
+/// File name of the mainnet unlock data.
+const AGGREGATED_MAINNET_UNLOCKS_FILE: &str = "mainnet_unlocks_aggregated.json";
+
+/// Represents a single entry in the aggregated unlock data.
+/// It defines how many tokens still remain locked at a specific point in time.
+#[derive(Debug, Clone, Deserialize)]
+pub struct StillLockedEntry {
+    /// UTC timestamp at which the tokens are still locked.
+    pub timestamp: DateTime<Utc>,
+    /// Total locked amount (nano-units) still locked at the timestamp.
+    pub amount_still_locked: u64,
+}
+
+/// In-memory store holding the aggregated token unlock data.
+#[derive(Debug, Clone)]
+pub struct AggregatedUnlocksStore {
+    // Each entry represents the total number of tokens still locked at the specific point in time.
+    entries: BTreeMap<DateTime<Utc>, StillLockedEntry>,
+}
+
+impl AggregatedUnlocksStore {
+    /// Creates a new `AggregatedUnlocksStore` based on the provided `Chain`.
+    ///
+    /// For `Chain::Mainnet`, this method loads the precompiled unlock data from
+    /// file, since the unlock data is only valid for mainnet. For any other
+    /// chain (e.g. testnet, devnet), it returns an empty store, as no
+    /// precompiled unlock data is applicable.
+    pub fn from_chain(chain: Chain) -> Result<Self> {
+        match chain {
+            Chain::Mainnet => Self::load_mainnet_unlocks(),
+            // The precompiled unlock data is only valid for mainnet. For other chains, return an
+            // empty store.
+            _ => Ok(Self::default()),
+        }
+    }
+
+    /// Loads the aggregated token unlock data from the given JSON file at the
+    /// crate root.
+    fn load_mainnet_unlocks() -> Result<Self> {
+        let crate_dir = env!("CARGO_MANIFEST_DIR");
+        let path = PathBuf::from(crate_dir)
+            .join("data")
+            .join(AGGREGATED_MAINNET_UNLOCKS_FILE);
+
+        let data = fs::read_to_string(&path)
+            .with_context(|| format!("could not read locked supply file: {:?}", path))?;
+
+        Self::from_json(&data)
+    }
+
+    /// Parses the given JSON string into a `TokenUnlocksStore`.
+    fn from_json(json: &str) -> Result<Self> {
+        let parsed: Vec<StillLockedEntry> =
+            serde_json::from_str(json).context("invalid JSON format in unlock data")?;
+
+        let mut map = BTreeMap::new();
+        for entry in parsed {
+            if map.contains_key(&entry.timestamp) {
+                return Err(anyhow::anyhow!(
+                    "duplicate entry found for timestamp: {}",
+                    entry.timestamp
+                ));
+            }
+            map.insert(entry.timestamp, entry);
+        }
+
+        Ok(Self { entries: map })
+    }
+
+    /// Returns the total amount of tokens (in nano-units) that are still locked
+    /// at the given timestamp.
+    pub fn still_locked_tokens(&self, timestamp: DateTime<Utc>) -> u64 {
+        self.entries
+            .range(..=timestamp)
+            .next_back()
+            .map(|(_, entry)| entry.amount_still_locked)
+            .unwrap_or_else(|| {
+                // No earlier entries exist: use first available as retroactively valid
+                self.entries
+                    .iter()
+                    .next()
path: crates/iota-mainnet-unlocks/src/unlocks.rs
user: login: samuel-rufi
body: It requires the map to be mutable. I think we should better keep it like it is. But will consider it in the future 👍 
created_at: 2025-03-30T10:11:48Z
author_association: CONTRIBUTOR
path: crates/iota-mainnet-unlocks/README.md
user: login: Thoralf-M
body: @samuel-rufi not sure why it says outdated, but there is still the empty README.md
created_at: 2025-03-30T11:14:10Z
author_association: MEMBER
path: crates/iota-mainnet-unlocks/README.md
user: login: samuel-rufi
body: Fixed with latest commit
created_at: 2025-03-30T12:52:22Z
author_association: CONTRIBUTOR
path: crates/iota-mainnet-unlocks/data/create_aggregated_mainnet_unlocks.sh
user: login: samuel-rufi
body: Fixed with https://github.com/iotaledger/iota/pull/6123/commits/e4c0afaaabdacaced362571fab39a1999fe213d1
created_at: 2025-03-30T12:53:02Z
author_association: CONTRIBUTOR
path: crates/iota-mainnet-unlocks/data/create_aggregated_mainnet_unlocks.sh
user: login: samuel-rufi
body: Regarding git2, replaced it with `std::process::Command` due to recurring auth issues.
Since we only need to perform one simple git clone, using the Git CLI should fit well and avoids problems with SSH host verification and credentials.
created_at: 2025-03-30T13:01:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,162 @@
+// Copyright (c) 2025 IOTA Stiftung
+// SPDX-License-Identifier: Apache-2.0
+
+use std::{
+    collections::BTreeMap,
+    path::{Path, PathBuf},
+    process::Command,
+};
+
+use anyhow::{Context, Result};
+use chrono::{DateTime, Utc};
+use csv::{Reader, Writer};
+use iota_mainnet_unlocks::store::{INPUT_FILE as OUTPUT_FILE, StillLockedEntry};
+use regex::Regex;
+use tempfile::{TempDir, tempdir};
+
+// Folders in the raw data repository that contain the CSV files.
+const FOLDERS: &[&str] = &[
+    "Assembly_IF_Members",
+    "Assembly_Investors",
+    "IOTA_Airdrop",
+    "IOTA_Foundation",
+    "New_Investors",
+    "TEA",
+    "Treasury_DAO",
+    "UAE",
+];
+
+/// Clones the repository containing raw data into a temporary directory.
+fn clone_repo(tmp_dir: &TempDir) -> Result<PathBuf> {
+    let repo_path = tmp_dir.path().join("new_supply");
+
+    let status = Command::new("git")
+        .args([
+            "clone",
+            "--depth",
+            "1",
+            "https://github.com/iotaledger/new_supply.git",
+        ])
+        .arg(&repo_path)
+        .status()
+        .context("failed to execute `git clone`")?;
+
+    if !status.success() {
+        anyhow::bail!("`git clone` failed with exit status: {}", status);
path: crates/iota-mainnet-unlocks/src/bin/generate_aggregated_data.rs
user: login: Thoralf-M
body: There are ~10 more places like this
```suggestion
        anyhow::bail!("`git clone` failed with exit status: {status}");
```
created_at: 2025-03-30T17:59:01Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,162 @@
+// Copyright (c) 2025 IOTA Stiftung
+// SPDX-License-Identifier: Apache-2.0
+
+use std::{
+    collections::BTreeMap,
+    path::{Path, PathBuf},
+    process::Command,
+};
+
+use anyhow::{Context, Result};
+use chrono::{DateTime, Utc};
+use csv::{Reader, Writer};
+use iota_mainnet_unlocks::store::{INPUT_FILE as OUTPUT_FILE, StillLockedEntry};
+use regex::Regex;
+use tempfile::{TempDir, tempdir};
+
+// Folders in the raw data repository that contain the CSV files.
+const FOLDERS: &[&str] = &[
+    "Assembly_IF_Members",
+    "Assembly_Investors",
+    "IOTA_Airdrop",
+    "IOTA_Foundation",
+    "New_Investors",
+    "TEA",
+    "Treasury_DAO",
+    "UAE",
+];
+
+/// Clones the repository containing raw data into a temporary directory.
+fn clone_repo(tmp_dir: &TempDir) -> Result<PathBuf> {
+    let repo_path = tmp_dir.path().join("new_supply");
+
+    let status = Command::new("git")
+        .args([
+            "clone",
+            "--depth",
+            "1",
+            "https://github.com/iotaledger/new_supply.git",
+        ])
+        .arg(&repo_path)
+        .status()
+        .context("failed to execute `git clone`")?;
+
+    if !status.success() {
+        anyhow::bail!("`git clone` failed with exit status: {}", status);
path: crates/iota-mainnet-unlocks/src/bin/generate_aggregated_data.rs
user: login: samuel-rufi
body: Ah yes, let me fix 👍 
created_at: 2025-03-30T18:03:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,162 @@
+// Copyright (c) 2025 IOTA Stiftung
+// SPDX-License-Identifier: Apache-2.0
+
+use std::{
+    collections::BTreeMap,
+    path::{Path, PathBuf},
+    process::Command,
+};
+
+use anyhow::{Context, Result};
+use chrono::{DateTime, Utc};
+use csv::{Reader, Writer};
+use iota_mainnet_unlocks::store::{INPUT_FILE as OUTPUT_FILE, StillLockedEntry};
+use regex::Regex;
+use tempfile::{TempDir, tempdir};
+
+// Folders in the raw data repository that contain the CSV files.
+const FOLDERS: &[&str] = &[
+    "Assembly_IF_Members",
+    "Assembly_Investors",
+    "IOTA_Airdrop",
+    "IOTA_Foundation",
+    "New_Investors",
+    "TEA",
+    "Treasury_DAO",
+    "UAE",
+];
+
+/// Clones the repository containing raw data into a temporary directory.
+fn clone_repo(tmp_dir: &TempDir) -> Result<PathBuf> {
+    let repo_path = tmp_dir.path().join("new_supply");
+
+    let status = Command::new("git")
+        .args([
+            "clone",
+            "--depth",
+            "1",
+            "https://github.com/iotaledger/new_supply.git",
+        ])
+        .arg(&repo_path)
+        .status()
+        .context("failed to execute `git clone`")?;
+
+    if !status.success() {
+        anyhow::bail!("`git clone` failed with exit status: {}", status);
path: crates/iota-mainnet-unlocks/src/bin/generate_aggregated_data.rs
user: login: samuel-rufi
body: Fixed in https://github.com/iotaledger/iota/pull/6123/commits/877c1d255c3ac56c7f5efe443f51f06a0054891d
created_at: 2025-03-30T20:38:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -80,4 +80,8 @@ pub trait CoinReadApi {
         /// type name for the coin (e.g., 0x168da5bf1f48dafc111b0a488fa454aca95e0b5e::usdc::USDC)
         coin_type: String,
     ) -> RpcResult<Supply>;
+
+    /// Return the circulating supply summary.
+    #[method(name = "getCirculatingSupply")]
+    async fn get_circulating_supply(&self) -> RpcResult<IotaCirculatingSupply>;
path: crates/iota-json-rpc-api/src/coin.rs
user: login: thibault-martinez
body: Should we add this to the SDK?
created_at: 2025-03-31T07:18:02Z
author_association: MEMBER
diff_hunk: @@ -80,4 +80,8 @@ pub trait CoinReadApi {
         /// type name for the coin (e.g., 0x168da5bf1f48dafc111b0a488fa454aca95e0b5e::usdc::USDC)
         coin_type: String,
     ) -> RpcResult<Supply>;
+
+    /// Return the circulating supply summary.
+    #[method(name = "getCirculatingSupply")]
+    async fn get_circulating_supply(&self) -> RpcResult<IotaCirculatingSupply>;
path: crates/iota-json-rpc-api/src/coin.rs
user: login: samuel-rufi
body: I think this would be helpful but can be done as separate task
created_at: 2025-03-31T07:32:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -80,4 +80,8 @@ pub trait CoinReadApi {
         /// type name for the coin (e.g., 0x168da5bf1f48dafc111b0a488fa454aca95e0b5e::usdc::USDC)
         coin_type: String,
     ) -> RpcResult<Supply>;
+
+    /// Return the circulating supply summary.
+    #[method(name = "getCirculatingSupply")]
+    async fn get_circulating_supply(&self) -> RpcResult<IotaCirculatingSupply>;
path: crates/iota-json-rpc-api/src/coin.rs
user: login: thibault-martinez
body: https://github.com/iotaledger/iota/issues/6145
created_at: 2025-03-31T07:51:15Z
author_association: MEMBER
path: crates/iota-indexer/token_unlocks_data/Assembly_IF_Members.csv
user: login: kodemartin
body: That is fine. Could you please create a new issue for adjusting the `ReadApi` then? Reasoning about the benefits of the implementation you introduced here would be also helpful.
created_at: 2025-03-31T08:22:14Z
author_association: CONTRIBUTOR
path: crates/iota-mainnet-unlocks/data/create_aggregated_mainnet_unlocks.sh
user: login: kodemartin
body: Ok. But why do we need ssh host verification and credentials? Doesn't `git2` work with `https`?
created_at: 2025-03-31T08:23:18Z
author_association: CONTRIBUTOR
path: crates/iota-indexer/token_unlocks_data/Assembly_IF_Members.csv
user: login: samuel-rufi
body: Absolutely, will open a issue with the context 👍 
created_at: 2025-03-31T08:38:23Z
author_association: CONTRIBUTOR
path: crates/iota-mainnet-unlocks/data/create_aggregated_mainnet_unlocks.sh
user: login: samuel-rufi
body: Perfect 👍 Yes, `git2` should work with `https` also that feature is enabled by `default`. But it seems like it still needed the `known_hosts` file. After resetting that and performing `ssh-keyscan github.com >> ~/.ssh/known_hosts` the certification issues were solved but it continued to auth using ssh. Not sure why it continued to login using ssh, my guess is due to some local environment misconfiguration. Would need to investigate it more.
created_at: 2025-03-31T10:52:41Z
author_association: CONTRIBUTOR
path: crates/iota-indexer/token_unlocks_data/Assembly_IF_Members.csv
user: login: samuel-rufi
body: Hi @kodemartin I created the issue here: https://github.com/iotaledger/iota/issues/6156
created_at: 2025-03-31T11:20:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,232 @@
+// Copyright (c) 2025 IOTA Stiftung
+// SPDX-License-Identifier: Apache-2.0
+
+use std::{collections::BTreeMap, fs, path::PathBuf};
+
+use anyhow::{Context, Result};
+use chrono::{DateTime, Utc};
+use csv::ReaderBuilder;
+use serde::{Deserialize, Serialize};
+
+/// File name of the mainnet unlock data.
+pub const INPUT_FILE: &str = "aggregated_mainnet_unlocks.csv";
+
+/// Represents a single entry in the store.
+/// It defines how many tokens still remain locked at a specific point in time.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct StillLockedEntry {
+    /// UTC timestamp at which the tokens are still locked.
+    pub timestamp: DateTime<Utc>,
+    /// Total locked amount (nano-units) still locked at the timestamp.
+    pub amount_still_locked: u64,
+}
+
+/// In-memory store holding the aggregated token unlock data.
+#[derive(Debug, Clone)]
+pub struct MainnetUnlocksStore {
+    // Each entry represents the total number of tokens still locked at the specific point in time.
+    entries: BTreeMap<DateTime<Utc>, StillLockedEntry>,
+}
+
+impl MainnetUnlocksStore {
+    /// Creates a new store with the aggregated unlock data for mainnet.
+    /// Loads the aggregated token unlock data from the given JSON file at the
+    /// crate root.
+    pub fn new() -> Result<Self> {
+        let crate_dir = env!("CARGO_MANIFEST_DIR");
+        let path = PathBuf::from(crate_dir).join("data").join(INPUT_FILE);
+
+        let data = fs::read_to_string(&path)
+            .with_context(|| format!("could not read locked supply file: {:?}", path))?;
+
+        Self::from_csv_str(&data)
+    }
+
+    /// Parses the given CSV string into a `MainnetUnlocksStore`.
+    fn from_csv_str(csv_str: &str) -> Result<Self> {
+        let mut rdr = ReaderBuilder::new()
+            .has_headers(true)
+            .from_reader(csv_str.as_bytes());
+
+        let mut map = BTreeMap::new();
+
+        for result in rdr.deserialize() {
+            let entry: StillLockedEntry =
+                result.context("failed to deserialize CSV row into StillLockedEntry")?;
+
+            if map.contains_key(&entry.timestamp) {
path: crates/iota-mainnet-unlocks/src/store.rs
user: login: thibault-martinez
body: Not very important but FYI `insert` returns whether the entry existed or not, so `contains_key` is a bit redundant
created_at: 2025-03-31T11:29:46Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,232 @@
+// Copyright (c) 2025 IOTA Stiftung
+// SPDX-License-Identifier: Apache-2.0
+
+use std::{collections::BTreeMap, fs, path::PathBuf};
+
+use anyhow::{Context, Result};
+use chrono::{DateTime, Utc};
+use csv::ReaderBuilder;
+use serde::{Deserialize, Serialize};
+
+/// File name of the mainnet unlock data.
+pub const INPUT_FILE: &str = "aggregated_mainnet_unlocks.csv";
+
+/// Represents a single entry in the store.
+/// It defines how many tokens still remain locked at a specific point in time.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct StillLockedEntry {
+    /// UTC timestamp at which the tokens are still locked.
+    pub timestamp: DateTime<Utc>,
+    /// Total locked amount (nano-units) still locked at the timestamp.
+    pub amount_still_locked: u64,
+}
+
+/// In-memory store holding the aggregated token unlock data.
+#[derive(Debug, Clone)]
+pub struct MainnetUnlocksStore {
+    // Each entry represents the total number of tokens still locked at the specific point in time.
+    entries: BTreeMap<DateTime<Utc>, StillLockedEntry>,
+}
+
+impl MainnetUnlocksStore {
+    /// Creates a new store with the aggregated unlock data for mainnet.
+    /// Loads the aggregated token unlock data from the given JSON file at the
+    /// crate root.
+    pub fn new() -> Result<Self> {
+        let crate_dir = env!("CARGO_MANIFEST_DIR");
+        let path = PathBuf::from(crate_dir).join("data").join(INPUT_FILE);
+
+        let data = fs::read_to_string(&path)
+            .with_context(|| format!("could not read locked supply file: {:?}", path))?;
+
+        Self::from_csv_str(&data)
+    }
+
+    /// Parses the given CSV string into a `MainnetUnlocksStore`.
+    fn from_csv_str(csv_str: &str) -> Result<Self> {
+        let mut rdr = ReaderBuilder::new()
+            .has_headers(true)
+            .from_reader(csv_str.as_bytes());
+
+        let mut map = BTreeMap::new();
+
+        for result in rdr.deserialize() {
+            let entry: StillLockedEntry =
+                result.context("failed to deserialize CSV row into StillLockedEntry")?;
+
+            if map.contains_key(&entry.timestamp) {
+                return Err(anyhow::anyhow!(
+                    "duplicate entry found for timestamp: {}",
+                    entry.timestamp
+                ));
+            }
+
+            map.insert(entry.timestamp, entry);
+        }
+
+        Ok(Self { entries: map })
+    }
+
+    /// Returns the total amount of tokens (in nano-units) that are still locked
+    /// at the given timestamp.
+    pub fn still_locked_tokens(&self, date_time: DateTime<Utc>) -> u64 {
+        self.entries
+            .range(..=date_time)
+            .next_back()
+            .map(|(_, entry)| entry.amount_still_locked)
+            .unwrap_or_else(|| {
+                // No earlier entries exist: use first available as retroactively valid
path: crates/iota-mainnet-unlocks/src/store.rs
user: login: thibault-martinez
body: So if I request for a date after `2027-09-29`, I will get `1638422740898008000`? Is this correct?
created_at: 2025-03-31T11:35:57Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,162 @@
+// Copyright (c) 2025 IOTA Stiftung
+// SPDX-License-Identifier: Apache-2.0
+
+use std::{
+    collections::BTreeMap,
+    path::{Path, PathBuf},
+    process::Command,
+};
+
+use anyhow::{Context, Result};
+use chrono::{DateTime, Utc};
+use csv::{Reader, Writer};
+use iota_mainnet_unlocks::store::{INPUT_FILE as OUTPUT_FILE, StillLockedEntry};
+use regex::Regex;
+use tempfile::{TempDir, tempdir};
+
+// Folders in the raw data repository that contain the CSV files.
+const FOLDERS: &[&str] = &[
+    "Assembly_IF_Members",
+    "Assembly_Investors",
+    "IOTA_Airdrop",
+    "IOTA_Foundation",
+    "New_Investors",
+    "TEA",
+    "Treasury_DAO",
+    "UAE",
+];
+
+/// Clones the repository containing raw data into a temporary directory.
+fn clone_repo(tmp_dir: &TempDir) -> Result<PathBuf> {
+    let repo_path = tmp_dir.path().join("new_supply");
+
+    let status = Command::new("git")
+        .args([
+            "clone",
+            "--depth",
+            "1",
+            "https://github.com/iotaledger/new_supply.git",
+        ])
+        .arg(&repo_path)
+        .status()
+        .context("failed to execute `git clone`")?;
+
+    if !status.success() {
+        anyhow::bail!("`git clone` failed with exit status: {}", status);
+    }
+
+    Ok(repo_path)
+}
+
+/// Reads and aggregates the CSV unlock data from the cloned repository.
+/// Returns a BTreeMap keyed by unlock date (as a String) with the aggregated
+/// token amount (in nano-units).
+fn aggregate_unlocks(repo_path: &Path) -> Result<BTreeMap<String, u64>> {
+    let mut locked_by_date: BTreeMap<String, u64> = BTreeMap::new();
+
+    for folder in FOLDERS {
+        let csv_path = repo_path.join(folder).join("summary.csv");
+        println!("Processing file: {:?}", csv_path);
+
+        let mut rdr = Reader::from_path(&csv_path)
+            .with_context(|| format!("failed to open CSV file: {csv_path:?}"))?;
+
+        // Iterate over CSV records (header is skipped automatically).
+        for result in rdr.records() {
+            let record = result?;
+            if record.len() < 2 {
+                eprintln!("Skipping invalid record: {:?}", record);
path: crates/iota-mainnet-unlocks/src/bin/generate_aggregated_data.rs
user: login: thibault-martinez
body: did you actually encounter one? Sounds important enough to not ignore it
created_at: 2025-03-31T11:48:26Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,232 @@
+// Copyright (c) 2025 IOTA Stiftung
+// SPDX-License-Identifier: Apache-2.0
+
+use std::{collections::BTreeMap, fs, path::PathBuf};
+
+use anyhow::{Context, Result};
+use chrono::{DateTime, Utc};
+use csv::ReaderBuilder;
+use serde::{Deserialize, Serialize};
+
+/// File name of the mainnet unlock data.
+pub const INPUT_FILE: &str = "aggregated_mainnet_unlocks.csv";
+
+/// Represents a single entry in the store.
+/// It defines how many tokens still remain locked at a specific point in time.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct StillLockedEntry {
+    /// UTC timestamp at which the tokens are still locked.
+    pub timestamp: DateTime<Utc>,
+    /// Total locked amount (nano-units) still locked at the timestamp.
+    pub amount_still_locked: u64,
+}
+
+/// In-memory store holding the aggregated token unlock data.
+#[derive(Debug, Clone)]
+pub struct MainnetUnlocksStore {
+    // Each entry represents the total number of tokens still locked at the specific point in time.
+    entries: BTreeMap<DateTime<Utc>, StillLockedEntry>,
+}
+
+impl MainnetUnlocksStore {
+    /// Creates a new store with the aggregated unlock data for mainnet.
+    /// Loads the aggregated token unlock data from the given JSON file at the
+    /// crate root.
+    pub fn new() -> Result<Self> {
+        let crate_dir = env!("CARGO_MANIFEST_DIR");
+        let path = PathBuf::from(crate_dir).join("data").join(INPUT_FILE);
+
+        let data = fs::read_to_string(&path)
+            .with_context(|| format!("could not read locked supply file: {:?}", path))?;
+
+        Self::from_csv_str(&data)
+    }
+
+    /// Parses the given CSV string into a `MainnetUnlocksStore`.
+    fn from_csv_str(csv_str: &str) -> Result<Self> {
+        let mut rdr = ReaderBuilder::new()
+            .has_headers(true)
+            .from_reader(csv_str.as_bytes());
+
+        let mut map = BTreeMap::new();
+
+        for result in rdr.deserialize() {
+            let entry: StillLockedEntry =
+                result.context("failed to deserialize CSV row into StillLockedEntry")?;
+
+            if map.contains_key(&entry.timestamp) {
+                return Err(anyhow::anyhow!(
+                    "duplicate entry found for timestamp: {}",
+                    entry.timestamp
+                ));
+            }
+
+            map.insert(entry.timestamp, entry);
+        }
+
+        Ok(Self { entries: map })
+    }
+
+    /// Returns the total amount of tokens (in nano-units) that are still locked
+    /// at the given timestamp.
+    pub fn still_locked_tokens(&self, date_time: DateTime<Utc>) -> u64 {
+        self.entries
+            .range(..=date_time)
+            .next_back()
+            .map(|(_, entry)| entry.amount_still_locked)
+            .unwrap_or_else(|| {
+                // No earlier entries exist: use first available as retroactively valid
path: crates/iota-mainnet-unlocks/src/store.rs
user: login: samuel-rufi
body: No, you should get `0`. 

If you query for any date after `2027-09-29`, the last valid entry is `2027-09-29T00:00:00Z,0`. Still locked tokens after `2027-09-29` is still 0.

```rust
    #[test]
    fn test_date_after_last_entry() {
        let store = MainnetUnlocksStore::new().unwrap();

        let query_exact = Utc.with_ymd_and_hms(2027, 9, 29, 0, 0, 0).unwrap();
        assert_eq!(store.still_locked_tokens(query_exact), 0);

        let query_after = Utc.with_ymd_and_hms(2029, 4, 5, 0, 0, 0).unwrap();
        assert_eq!(store.still_locked_tokens(query_after), 0);

        let query_before = Utc.with_ymd_and_hms(2027, 9, 28, 0, 0, 0).unwrap();
        assert_eq!(store.still_locked_tokens(query_before), 12370411012499000);
    }
```

`test store::tests::test_date_after_last_entry ... ok`

created_at: 2025-03-31T11:54:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,162 @@
+// Copyright (c) 2025 IOTA Stiftung
+// SPDX-License-Identifier: Apache-2.0
+
+use std::{
+    collections::BTreeMap,
+    path::{Path, PathBuf},
+    process::Command,
+};
+
+use anyhow::{Context, Result};
+use chrono::{DateTime, Utc};
+use csv::{Reader, Writer};
+use iota_mainnet_unlocks::store::{INPUT_FILE as OUTPUT_FILE, StillLockedEntry};
+use regex::Regex;
+use tempfile::{TempDir, tempdir};
+
+// Folders in the raw data repository that contain the CSV files.
+const FOLDERS: &[&str] = &[
+    "Assembly_IF_Members",
+    "Assembly_Investors",
+    "IOTA_Airdrop",
+    "IOTA_Foundation",
+    "New_Investors",
+    "TEA",
+    "Treasury_DAO",
+    "UAE",
+];
+
+/// Clones the repository containing raw data into a temporary directory.
+fn clone_repo(tmp_dir: &TempDir) -> Result<PathBuf> {
+    let repo_path = tmp_dir.path().join("new_supply");
+
+    let status = Command::new("git")
+        .args([
+            "clone",
+            "--depth",
+            "1",
+            "https://github.com/iotaledger/new_supply.git",
+        ])
+        .arg(&repo_path)
+        .status()
+        .context("failed to execute `git clone`")?;
+
+    if !status.success() {
+        anyhow::bail!("`git clone` failed with exit status: {}", status);
+    }
+
+    Ok(repo_path)
+}
+
+/// Reads and aggregates the CSV unlock data from the cloned repository.
+/// Returns a BTreeMap keyed by unlock date (as a String) with the aggregated
+/// token amount (in nano-units).
+fn aggregate_unlocks(repo_path: &Path) -> Result<BTreeMap<String, u64>> {
+    let mut locked_by_date: BTreeMap<String, u64> = BTreeMap::new();
+
+    for folder in FOLDERS {
+        let csv_path = repo_path.join(folder).join("summary.csv");
+        println!("Processing file: {:?}", csv_path);
+
+        let mut rdr = Reader::from_path(&csv_path)
+            .with_context(|| format!("failed to open CSV file: {csv_path:?}"))?;
+
+        // Iterate over CSV records (header is skipped automatically).
+        for result in rdr.records() {
+            let record = result?;
+            if record.len() < 2 {
+                eprintln!("Skipping invalid record: {:?}", record);
path: crates/iota-mainnet-unlocks/src/bin/generate_aggregated_data.rs
user: login: samuel-rufi
body: Good point, will fix! And nope, didn't encounter one 👍 All .csv files in the repo were properly formatted!
created_at: 2025-03-31T11:56:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,232 @@
+// Copyright (c) 2025 IOTA Stiftung
+// SPDX-License-Identifier: Apache-2.0
+
+use std::{collections::BTreeMap, fs, path::PathBuf};
+
+use anyhow::{Context, Result};
+use chrono::{DateTime, Utc};
+use csv::ReaderBuilder;
+use serde::{Deserialize, Serialize};
+
+/// File name of the mainnet unlock data.
+pub const INPUT_FILE: &str = "aggregated_mainnet_unlocks.csv";
+
+/// Represents a single entry in the store.
+/// It defines how many tokens still remain locked at a specific point in time.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct StillLockedEntry {
+    /// UTC timestamp at which the tokens are still locked.
+    pub timestamp: DateTime<Utc>,
+    /// Total locked amount (nano-units) still locked at the timestamp.
+    pub amount_still_locked: u64,
+}
+
+/// In-memory store holding the aggregated token unlock data.
+#[derive(Debug, Clone)]
+pub struct MainnetUnlocksStore {
+    // Each entry represents the total number of tokens still locked at the specific point in time.
+    entries: BTreeMap<DateTime<Utc>, StillLockedEntry>,
+}
+
+impl MainnetUnlocksStore {
+    /// Creates a new store with the aggregated unlock data for mainnet.
+    /// Loads the aggregated token unlock data from the given JSON file at the
+    /// crate root.
+    pub fn new() -> Result<Self> {
+        let crate_dir = env!("CARGO_MANIFEST_DIR");
+        let path = PathBuf::from(crate_dir).join("data").join(INPUT_FILE);
+
+        let data = fs::read_to_string(&path)
+            .with_context(|| format!("could not read locked supply file: {:?}", path))?;
+
+        Self::from_csv_str(&data)
+    }
+
+    /// Parses the given CSV string into a `MainnetUnlocksStore`.
+    fn from_csv_str(csv_str: &str) -> Result<Self> {
+        let mut rdr = ReaderBuilder::new()
+            .has_headers(true)
+            .from_reader(csv_str.as_bytes());
+
+        let mut map = BTreeMap::new();
+
+        for result in rdr.deserialize() {
+            let entry: StillLockedEntry =
+                result.context("failed to deserialize CSV row into StillLockedEntry")?;
+
+            if map.contains_key(&entry.timestamp) {
+                return Err(anyhow::anyhow!(
+                    "duplicate entry found for timestamp: {}",
+                    entry.timestamp
+                ));
+            }
+
+            map.insert(entry.timestamp, entry);
+        }
+
+        Ok(Self { entries: map })
+    }
+
+    /// Returns the total amount of tokens (in nano-units) that are still locked
+    /// at the given timestamp.
+    pub fn still_locked_tokens(&self, date_time: DateTime<Utc>) -> u64 {
+        self.entries
+            .range(..=date_time)
+            .next_back()
+            .map(|(_, entry)| entry.amount_still_locked)
+            .unwrap_or_else(|| {
+                // No earlier entries exist: use first available as retroactively valid
path: crates/iota-mainnet-unlocks/src/store.rs
user: login: samuel-rufi
body: The first entry is only returned if the aggregated .csv  would contain one row, e.g.

```rust
    #[test]
    fn test_first_entry_is_retrospective() {
        let csv = r#"
            timestamp,amount_still_locked
            2022-06-01T00:00:00Z,888
        "#;
        let store = store(csv.trim());

        let far_before = Utc.with_ymd_and_hms(2000, 1, 1, 0, 0, 0).unwrap();
        let just_before = Utc.with_ymd_and_hms(2022, 5, 31, 23, 59, 59).unwrap();
        let exact = Utc.with_ymd_and_hms(2022, 6, 1, 0, 0, 0).unwrap();
        let just_after = Utc.with_ymd_and_hms(2022, 6, 1, 0, 0, 1).unwrap();

        assert_eq!(store.still_locked_tokens(far_before), 888);
        assert_eq!(store.still_locked_tokens(just_before), 888);
        assert_eq!(store.still_locked_tokens(exact), 888);
        assert_eq!(store.still_locked_tokens(just_after), 888);
    }
```

Still locked tokens before, exact and after will be `888`.
created_at: 2025-03-31T12:06:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,232 @@
+// Copyright (c) 2025 IOTA Stiftung
+// SPDX-License-Identifier: Apache-2.0
+
+use std::{collections::BTreeMap, fs, path::PathBuf};
+
+use anyhow::{Context, Result};
+use chrono::{DateTime, Utc};
+use csv::ReaderBuilder;
+use serde::{Deserialize, Serialize};
+
+/// File name of the mainnet unlock data.
+pub const INPUT_FILE: &str = "aggregated_mainnet_unlocks.csv";
+
+/// Represents a single entry in the store.
+/// It defines how many tokens still remain locked at a specific point in time.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct StillLockedEntry {
+    /// UTC timestamp at which the tokens are still locked.
+    pub timestamp: DateTime<Utc>,
+    /// Total locked amount (nano-units) still locked at the timestamp.
+    pub amount_still_locked: u64,
+}
+
+/// In-memory store holding the aggregated token unlock data.
+#[derive(Debug, Clone)]
+pub struct MainnetUnlocksStore {
+    // Each entry represents the total number of tokens still locked at the specific point in time.
+    entries: BTreeMap<DateTime<Utc>, StillLockedEntry>,
+}
+
+impl MainnetUnlocksStore {
+    /// Creates a new store with the aggregated unlock data for mainnet.
+    /// Loads the aggregated token unlock data from the given JSON file at the
+    /// crate root.
+    pub fn new() -> Result<Self> {
+        let crate_dir = env!("CARGO_MANIFEST_DIR");
+        let path = PathBuf::from(crate_dir).join("data").join(INPUT_FILE);
+
+        let data = fs::read_to_string(&path)
+            .with_context(|| format!("could not read locked supply file: {:?}", path))?;
+
+        Self::from_csv_str(&data)
+    }
+
+    /// Parses the given CSV string into a `MainnetUnlocksStore`.
+    fn from_csv_str(csv_str: &str) -> Result<Self> {
+        let mut rdr = ReaderBuilder::new()
+            .has_headers(true)
+            .from_reader(csv_str.as_bytes());
+
+        let mut map = BTreeMap::new();
+
+        for result in rdr.deserialize() {
+            let entry: StillLockedEntry =
+                result.context("failed to deserialize CSV row into StillLockedEntry")?;
+
+            if map.contains_key(&entry.timestamp) {
path: crates/iota-mainnet-unlocks/src/store.rs
user: login: samuel-rufi
body: Good point, made use of that `Option` in https://github.com/iotaledger/iota/pull/6123/commits/1a16ba645060ca55ffe5edd134c95a8cc66570ac
created_at: 2025-03-31T12:15:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,162 @@
+// Copyright (c) 2025 IOTA Stiftung
+// SPDX-License-Identifier: Apache-2.0
+
+use std::{
+    collections::BTreeMap,
+    path::{Path, PathBuf},
+    process::Command,
+};
+
+use anyhow::{Context, Result};
+use chrono::{DateTime, Utc};
+use csv::{Reader, Writer};
+use iota_mainnet_unlocks::store::{INPUT_FILE as OUTPUT_FILE, StillLockedEntry};
+use regex::Regex;
+use tempfile::{TempDir, tempdir};
+
+// Folders in the raw data repository that contain the CSV files.
+const FOLDERS: &[&str] = &[
+    "Assembly_IF_Members",
+    "Assembly_Investors",
+    "IOTA_Airdrop",
+    "IOTA_Foundation",
+    "New_Investors",
+    "TEA",
+    "Treasury_DAO",
+    "UAE",
+];
+
+/// Clones the repository containing raw data into a temporary directory.
+fn clone_repo(tmp_dir: &TempDir) -> Result<PathBuf> {
+    let repo_path = tmp_dir.path().join("new_supply");
+
+    let status = Command::new("git")
+        .args([
+            "clone",
+            "--depth",
+            "1",
+            "https://github.com/iotaledger/new_supply.git",
+        ])
+        .arg(&repo_path)
+        .status()
+        .context("failed to execute `git clone`")?;
+
+    if !status.success() {
+        anyhow::bail!("`git clone` failed with exit status: {}", status);
+    }
+
+    Ok(repo_path)
+}
+
+/// Reads and aggregates the CSV unlock data from the cloned repository.
+/// Returns a BTreeMap keyed by unlock date (as a String) with the aggregated
+/// token amount (in nano-units).
+fn aggregate_unlocks(repo_path: &Path) -> Result<BTreeMap<String, u64>> {
+    let mut locked_by_date: BTreeMap<String, u64> = BTreeMap::new();
+
+    for folder in FOLDERS {
+        let csv_path = repo_path.join(folder).join("summary.csv");
+        println!("Processing file: {:?}", csv_path);
+
+        let mut rdr = Reader::from_path(&csv_path)
+            .with_context(|| format!("failed to open CSV file: {csv_path:?}"))?;
+
+        // Iterate over CSV records (header is skipped automatically).
+        for result in rdr.records() {
+            let record = result?;
+            if record.len() < 2 {
+                eprintln!("Skipping invalid record: {:?}", record);
path: crates/iota-mainnet-unlocks/src/bin/generate_aggregated_data.rs
user: login: samuel-rufi
body: Fixed in https://github.com/iotaledger/iota/pull/6123/commits/1a16ba645060ca55ffe5edd134c95a8cc66570ac
created_at: 2025-03-31T12:16:00Z
author_association: CONTRIBUTOR

PR comments:
user: login: vercel[bot]
created_at: 2025-03-26T22:31:27Z
body: [vc]: #UP61pICj64WkR2FFKpVH9fUDZktMy+ko++SxDRr+0d8=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJhcHBzLXVpLWtpdCIsInJvb3REaXJlY3RvcnkiOiJhcHBzL3VpLWtpdC8iLCJsaXZlRmVlZGJhY2siOnsicmVzb2x2ZWQiOjAsInVucmVzb2x2ZWQiOjAsInRvdGFsIjowLCJsaW5rIjoiIn0sImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS9pb3RhL2FwcHMtdWkta2l0LzV3Y2hWVWc2RVlRTnVvVHJvanRYdlhheXlpUk4iLCJuZXh0Q29tbWl0U3RhdHVzIjoiSUdOT1JFRCIsInByZXZpZXdVcmwiOiJhcHBzLXVpLWtpdC1naXQtbDFzYy1pbmZyYWluZGV4ZXItcnBjLWNpcmN1bGF0aW5nLXN1cHBseS1pb3RhMS52ZXJjZWwuYXBwIn0seyJuYW1lIjoid2FsbGV0LWRhc2hib2FyZCIsInJvb3REaXJlY3RvcnkiOiJhcHBzL3dhbGxldC1kYXNoYm9hcmQiLCJsaXZlRmVlZGJhY2siOnsicmVzb2x2ZWQiOjAsInVucmVzb2x2ZWQiOjAsInRvdGFsIjowLCJsaW5rIjoiIn0sImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS9pb3RhL3dhbGxldC1kYXNoYm9hcmQvQzR0UFg5OHZzRVZaV3BKY2tYM0o3TTR0NkV6WCIsIm5leHRDb21taXRTdGF0dXMiOiJJR05PUkVEIiwicHJldmlld1VybCI6IndhbGxldC1kYXNoYm9hcmQtZ2l0LWwxc2MtaW5mcmFpbmRleGVyLXJwYy1jaXJjdWxhLTEwZjU4ZC1pb3RhMS52ZXJjZWwuYXBwIn0seyJuYW1lIjoicmViYXNlZC1leHBsb3JlciIsInJvb3REaXJlY3RvcnkiOiJhcHBzL2V4cGxvcmVyIiwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjowLCJ1bnJlc29sdmVkIjowLCJ0b3RhbCI6MCwibGluayI6IiJ9LCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vaW90YS9yZWJhc2VkLWV4cGxvcmVyL0ZxSzZGUmZmd0JzU284WlRnemdvb3RhQVk1V2kiLCJuZXh0Q29tbWl0U3RhdHVzIjoiSUdOT1JFRCIsInByZXZpZXdVcmwiOiJyZWJhc2VkLWV4cGxvcmVyLWdpdC1sMXNjLWluZnJhaW5kZXhlci1ycGMtY2lyY3VsYS01ODUwYmEtaW90YTEudmVyY2VsLmFwcCJ9LHsibmFtZSI6ImFwcHMtYmFja2VuZCIsInJvb3REaXJlY3RvcnkiOiJhcHBzL2FwcHMtYmFja2VuZCIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiIifSwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL2lvdGEvYXBwcy1iYWNrZW5kLzVNVTJzOHV3RERDTWkzdVF0TUtyQTdOSENuYnYiLCJuZXh0Q29tbWl0U3RhdHVzIjoiSUdOT1JFRCIsInByZXZpZXdVcmwiOiJhcHBzLWJhY2tlbmQtZ2l0LWwxc2MtaW5mcmFpbmRleGVyLXJwYy1jaXJjdWxhdGluZy1zdXBwbHktaW90YTEudmVyY2VsLmFwcCJ9XX0=
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)


<details><summary>4 Skipped Deployments</summary>

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **apps-backend** | ⬜️ Ignored ([Inspect](https://vercel.com/iota/apps-backend/5MU2s8uwDDCMi3uQtMKrA7NHCnbv)) | [Visit Preview](https://apps-backend-git-l1sc-infraindexer-rpc-circulating-supply-iota1.vercel.app) |  | Mar 31, 2025 0:16am |
| **apps-ui-kit** | ⬜️ Ignored ([Inspect](https://vercel.com/iota/apps-ui-kit/5wchVUg6EYQNuoTrojtXvXayyiRN)) | [Visit Preview](https://apps-ui-kit-git-l1sc-infraindexer-rpc-circulating-supply-iota1.vercel.app) |  | Mar 31, 2025 0:16am |
| **rebased-explorer** | ⬜️ Ignored ([Inspect](https://vercel.com/iota/rebased-explorer/FqK6FRffwBsSo8ZTgzgootaAY5Wi)) | [Visit Preview](https://rebased-explorer-git-l1sc-infraindexer-rpc-circula-5850ba-iota1.vercel.app) |  | Mar 31, 2025 0:16am |
| **wallet-dashboard** | ⬜️ Ignored ([Inspect](https://vercel.com/iota/wallet-dashboard/C4tPX98vsEVZWpJckX3J7M4t6EzX)) | [Visit Preview](https://wallet-dashboard-git-l1sc-infraindexer-rpc-circula-10f58d-iota1.vercel.app) |  | Mar 31, 2025 0:16am |
</details>





author_association: NONE
user: login: lzpap
created_at: 2025-03-27T10:16:25Z
body: One thing that is missing from this PR is the `getCirculatingSupply` awerness of which chain it is running on. The precompiled unlock data is only valid for mainnet, which you could identify in code via the `chainId`.

I don't know if the indexer already relies on iota-protocol-config crate, but with that one could even get the `Chain` which can be overwritten with environment variables. At least that's how we force the nodes to run with mainnet configs during the ceremony/bootstrapping, before we know and commit the actual mainnet chainId into the repo.
author_association: MEMBER