diff --git a/Cargo.lock b/Cargo.lock
index 6c1fd28b..211136ab 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -776,6 +776,13 @@ dependencies = [
"cpufeatures",
]
+[[package]]
+name = "key-discovery-registry"
+version = "0.1.0"
+dependencies = [
+ "soroban-sdk",
+]
+
[[package]]
name = "libc"
version = "0.2.183"
diff --git a/Cargo.toml b/Cargo.toml
index bcf6e0ad..82106888 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,5 +1,5 @@
[workspace]
-members = ["creator-keys", "creator-keys-factory", "referral-contract", "lp-reward-contract", "multisig-admin"]
+members = ["creator-keys", "creator-keys-factory", "referral-contract", "lp-reward-contract", "multisig-admin", "key-discovery-registry"]
resolver = "2"
[workspace.package]
diff --git a/key-discovery-registry/Cargo.toml b/key-discovery-registry/Cargo.toml
new file mode 100644
index 00000000..df89a89d
--- /dev/null
+++ b/key-discovery-registry/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "key-discovery-registry"
+version = "0.1.0"
+edition = "2021"
+publish = false
+
+[lib]
+crate-type = ["cdylib", "rlib"]
+
+[dependencies]
+soroban-sdk = { workspace = true }
+
+[dev-dependencies]
+soroban-sdk = { workspace = true, features = ["testutils"] }
diff --git a/key-discovery-registry/src/lib.rs b/key-discovery-registry/src/lib.rs
new file mode 100644
index 00000000..f55f4643
--- /dev/null
+++ b/key-discovery-registry/src/lib.rs
@@ -0,0 +1,420 @@
+#![no_std]
+//! On-chain key discovery registry (issue #1037).
+//!
+//! Tracks keys deployed through the creator-keys factory, records per-key trade
+//! volume, and exposes sorted discovery views for "trending" (top keys by 24h
+//! volume) and "new keys" (keys deployed since a timestamp).
+//!
+//! Integration points:
+//! * `register_key` is called by the factory (or a relayer) whenever a
+//! `KeyDeployed` event is emitted for a newly deployed key.
+//! * `record_volume` is called by the bonding curve on each trade to report the
+//! traded volume for a key.
+//!
+//! Both entrypoints are gated: only the admin, or an address the admin
+//! explicitly authorised (`set_registrar` / `set_recorder`), may call them.
+
+use soroban_sdk::{
+ contract, contracterror, contractimpl, contracttype, symbol_short, Address, Env, Symbol, Vec,
+};
+
+#[cfg(test)]
+mod test;
+
+/// Number of seconds in an hour; used for volume-window math.
+pub const SECONDS_PER_HOUR: u64 = 3_600;
+/// Rolling window used by `get_trending` (24 hours).
+pub const VOLUME_WINDOW_24H: u64 = 24 * SECONDS_PER_HOUR;
+/// Volume records older than this are pruned on write (48 hours).
+pub const VOLUME_RETENTION_48H: u64 = 48 * SECONDS_PER_HOUR;
+
+pub const KEY_REGISTERED_EVENT_NAME: Symbol = symbol_short!("key_reg");
+pub const VOLUME_RECORDED_EVENT_NAME: Symbol = symbol_short!("vol_rec");
+
+#[contracterror]
+#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
+#[repr(u32)]
+pub enum DiscoveryError {
+ AlreadyInitialised = 1,
+ NotInitialised = 2,
+ Unauthorized = 3,
+ AlreadyRegistered = 4,
+ KeyNotRegistered = 5,
+ NotPositiveAmount = 6,
+ ArithmeticOverflow = 7,
+}
+
+/// Registration metadata for a discovered key.
+#[derive(Clone, Debug, Eq, PartialEq)]
+#[contracttype]
+pub struct KeyInfo {
+ pub key_id: Address,
+ pub creator: Address,
+ pub deploy_timestamp: u64,
+}
+
+/// A single volume observation recorded by the bonding curve.
+#[derive(Clone, Debug, Eq, PartialEq)]
+#[contracttype]
+pub struct VolumeEntry {
+ pub timestamp: u64,
+ pub amount: i128,
+}
+
+/// One row returned by `get_trending`.
+#[derive(Clone, Debug, Eq, PartialEq)]
+#[contracttype]
+pub struct TrendingEntry {
+ pub key_id: Address,
+ pub volume_24h: i128,
+ pub deploy_timestamp: u64,
+}
+
+#[derive(Clone)]
+#[contracttype]
+pub enum DataKey {
+ Admin,
+ Initialised,
+ Registrar(Address),
+ Recorder(Address),
+ KeyInfo(Address),
+ KeyIndex,
+ Volume(Address),
+}
+
+#[contract]
+pub struct KeyDiscoveryRegistry;
+
+fn is_initialised(env: &Env) -> bool {
+ env.storage()
+ .instance()
+ .get(&DataKey::Initialised)
+ .unwrap_or(false)
+}
+
+fn read_admin(env: &Env) -> Result
{
+ env.storage()
+ .instance()
+ .get(&DataKey::Admin)
+ .ok_or(DiscoveryError::NotInitialised)
+}
+
+fn read_key_index(env: &Env) -> Vec {
+ env.storage()
+ .persistent()
+ .get(&DataKey::KeyIndex)
+ .unwrap_or_else(|| Vec::new(env))
+}
+
+fn write_key_index(env: &Env, index: &Vec) {
+ env.storage().persistent().set(&DataKey::KeyIndex, index);
+}
+
+fn read_key_info(env: &Env, key_id: &Address) -> Option {
+ env.storage()
+ .persistent()
+ .get(&DataKey::KeyInfo(key_id.clone()))
+}
+
+fn load_volume_entries(env: &Env, key_id: &Address) -> Vec {
+ env.storage()
+ .persistent()
+ .get(&DataKey::Volume(key_id.clone()))
+ .unwrap_or_else(|| Vec::new(env))
+}
+
+fn write_volume_entries(env: &Env, key_id: &Address, entries: &Vec) {
+ env.storage()
+ .persistent()
+ .set(&DataKey::Volume(key_id.clone()), entries);
+}
+
+/// Drops every volume record strictly older than the 48-hour retention window
+/// relative to `now`. Returns `true` when at least one record was removed.
+fn prune_volume(env: &Env, key_id: &Address, now: u64) -> bool {
+ let entries = load_volume_entries(env, key_id);
+ if entries.is_empty() {
+ return false;
+ }
+ let cutoff = now.saturating_sub(VOLUME_RETENTION_48H);
+ let mut kept: Vec = Vec::new(env);
+ let mut removed = false;
+ for entry in entries.iter() {
+ if entry.timestamp >= cutoff {
+ kept.push_back(entry);
+ } else {
+ removed = true;
+ }
+ }
+ if removed {
+ write_volume_entries(env, key_id, &kept);
+ }
+ removed
+}
+
+/// Sums the volume recorded for `key_id` within `[now - 24h, now]`.
+fn volume_24h(env: &Env, key_id: &Address, now: u64) -> i128 {
+ let entries = load_volume_entries(env, key_id);
+ let window_start = now.saturating_sub(VOLUME_WINDOW_24H);
+ let mut total: i128 = 0;
+ for entry in entries.iter() {
+ if entry.timestamp >= window_start {
+ total = total.saturating_add(entry.amount);
+ }
+ }
+ total
+}
+
+/// Returns `true` when `a` should be ordered ahead of `b` in the trending list:
+/// higher 24h volume first, ties broken by earlier deployment.
+fn trending_ranks_higher(a: &TrendingEntry, b: &TrendingEntry) -> bool {
+ if a.volume_24h != b.volume_24h {
+ a.volume_24h > b.volume_24h
+ } else {
+ a.deploy_timestamp < b.deploy_timestamp
+ }
+}
+
+/// Stable insertion sort over the trending rows (descending by volume).
+fn sort_trending(entries: &mut Vec) {
+ let n = entries.len();
+ let mut i: u32 = 1;
+ while i < n {
+ let mut j = i;
+ while j > 0 {
+ let cur = entries.get(j).unwrap();
+ let prev = entries.get(j - 1).unwrap();
+ if trending_ranks_higher(&cur, &prev) {
+ entries.set(j, prev);
+ entries.set(j - 1, cur);
+ j -= 1;
+ } else {
+ break;
+ }
+ }
+ i += 1;
+ }
+}
+
+#[contractimpl]
+impl KeyDiscoveryRegistry {
+ /// One-time setup: records the admin allowed to manage authorisations.
+ pub fn initialise(env: Env, admin: Address) -> Result<(), DiscoveryError> {
+ if is_initialised(&env) {
+ return Err(DiscoveryError::AlreadyInitialised);
+ }
+ admin.require_auth();
+ env.storage().instance().set(&DataKey::Admin, &admin);
+ env.storage().instance().set(&DataKey::Initialised, &true);
+ Ok(())
+ }
+
+ /// Admin allows `registrar` to register newly deployed keys.
+ pub fn set_registrar(
+ env: Env,
+ registrar: Address,
+ allowed: bool,
+ ) -> Result<(), DiscoveryError> {
+ let admin = read_admin(&env)?;
+ admin.require_auth();
+ env.storage()
+ .instance()
+ .set(&DataKey::Registrar(registrar), &allowed);
+ Ok(())
+ }
+
+ /// Admin allows `recorder` to report trade volume.
+ pub fn set_recorder(env: Env, recorder: Address, allowed: bool) -> Result<(), DiscoveryError> {
+ let admin = read_admin(&env)?;
+ admin.require_auth();
+ env.storage()
+ .instance()
+ .set(&DataKey::Recorder(recorder), &allowed);
+ Ok(())
+ }
+
+ /// Registers a newly deployed key. Called by the factory (or a relayer)
+ /// when a `KeyDeployed` event fires.
+ pub fn register_key(
+ env: Env,
+ caller: Address,
+ key_id: Address,
+ creator: Address,
+ ) -> Result<(), DiscoveryError> {
+ caller.require_auth();
+ if !is_initialised(&env) {
+ return Err(DiscoveryError::NotInitialised);
+ }
+ let admin = read_admin(&env)?;
+ let authorised = caller == admin
+ || env
+ .storage()
+ .instance()
+ .get(&DataKey::Registrar(caller.clone()))
+ .unwrap_or(false);
+ if !authorised {
+ return Err(DiscoveryError::Unauthorized);
+ }
+ if read_key_info(&env, &key_id).is_some() {
+ return Err(DiscoveryError::AlreadyRegistered);
+ }
+
+ let deploy_timestamp = env.ledger().timestamp();
+ let info = KeyInfo {
+ key_id: key_id.clone(),
+ creator: creator.clone(),
+ deploy_timestamp,
+ };
+ env.storage()
+ .persistent()
+ .set(&DataKey::KeyInfo(key_id.clone()), &info);
+
+ let mut index = read_key_index(&env);
+ index.push_back(key_id.clone());
+ write_key_index(&env, &index);
+
+ env.events().publish(
+ (KEY_REGISTERED_EVENT_NAME, key_id.clone()),
+ KeyInfo {
+ key_id: key_id.clone(),
+ creator,
+ deploy_timestamp,
+ },
+ );
+ Ok(())
+ }
+
+ /// Records trade volume for `key_id`. Called by the bonding curve on each
+ /// trade. Prunes records older than 48 hours, then appends the new one.
+ pub fn record_volume(
+ env: Env,
+ caller: Address,
+ key_id: Address,
+ amount: i128,
+ ) -> Result<(), DiscoveryError> {
+ caller.require_auth();
+ if !is_initialised(&env) {
+ return Err(DiscoveryError::NotInitialised);
+ }
+ let admin = read_admin(&env)?;
+ let authorised = caller == admin
+ || env
+ .storage()
+ .instance()
+ .get(&DataKey::Recorder(caller.clone()))
+ .unwrap_or(false);
+ if !authorised {
+ return Err(DiscoveryError::Unauthorized);
+ }
+ if amount <= 0 {
+ return Err(DiscoveryError::NotPositiveAmount);
+ }
+ if read_key_info(&env, &key_id).is_none() {
+ return Err(DiscoveryError::KeyNotRegistered);
+ }
+
+ let now = env.ledger().timestamp();
+ prune_volume(&env, &key_id, now);
+
+ let mut entries = load_volume_entries(&env, &key_id);
+ entries.push_back(VolumeEntry {
+ timestamp: now,
+ amount,
+ });
+ write_volume_entries(&env, &key_id, &entries);
+
+ env.events().publish(
+ (VOLUME_RECORDED_EVENT_NAME, key_id.clone()),
+ VolumeEntry {
+ timestamp: now,
+ amount,
+ },
+ );
+ Ok(())
+ }
+
+ /// Removes volume records older than 48 hours for `key_id`. Callable by
+ /// anyone; it is a pure housekeeping operation.
+ pub fn prune(env: Env, key_id: Address) -> bool {
+ let now = env.ledger().timestamp();
+ prune_volume(&env, &key_id, now)
+ }
+
+ /// Read-only view: 24h rolling volume for a single key.
+ pub fn get_volume_24h(env: Env, key_id: Address) -> i128 {
+ let now = env.ledger().timestamp();
+ volume_24h(&env, &key_id, now)
+ }
+
+ /// Read-only view: all retained volume records for a key (newest last).
+ pub fn get_volume_entries(env: Env, key_id: Address) -> Vec {
+ load_volume_entries(&env, &key_id)
+ }
+
+ /// Read-only view: registration metadata for a key, if registered.
+ pub fn get_key_info(env: Env, key_id: Address) -> Option {
+ read_key_info(&env, &key_id)
+ }
+
+ /// Read-only view: number of registered keys.
+ pub fn get_registered_count(env: Env) -> u32 {
+ read_key_index(&env).len()
+ }
+
+ /// Read-only view: the top `limit` keys by 24h volume, descending. Keys with
+ /// zero 24h volume are omitted. Ties are broken by earlier deployment.
+ pub fn get_trending(env: Env, limit: u32) -> Vec {
+ if limit == 0 {
+ return Vec::new(&env);
+ }
+ let now = env.ledger().timestamp();
+ let index = read_key_index(&env);
+ let mut rows: Vec = Vec::new(&env);
+ for key_id in index.iter() {
+ let vol = volume_24h(&env, &key_id, now);
+ if vol > 0 {
+ let deploy_timestamp = read_key_info(&env, &key_id)
+ .map(|info| info.deploy_timestamp)
+ .unwrap_or(0);
+ rows.push_back(TrendingEntry {
+ key_id,
+ volume_24h: vol,
+ deploy_timestamp,
+ });
+ }
+ }
+ sort_trending(&mut rows);
+
+ let mut out: Vec = Vec::new(&env);
+ let mut i: u32 = 0;
+ while i < limit && i < rows.len() {
+ out.push_back(rows.get(i).unwrap());
+ i += 1;
+ }
+ out
+ }
+
+ /// Read-only view: up to `limit` keys deployed at or after `since_timestamp`,
+ /// newest first.
+ pub fn get_new_keys(env: Env, since_timestamp: u64, limit: u32) -> Vec {
+ if limit == 0 {
+ return Vec::new(&env);
+ }
+ let index = read_key_index(&env);
+ let total = index.len();
+ let mut out: Vec = Vec::new(&env);
+ // Walk the registration index newest-first.
+ let mut i = total;
+ while i > 0 && out.len() < limit {
+ i -= 1;
+ let key_id = index.get(i).unwrap();
+ let deployed_at = read_key_info(&env, &key_id)
+ .map(|info| info.deploy_timestamp)
+ .unwrap_or(0);
+ if deployed_at >= since_timestamp {
+ out.push_back(key_id);
+ }
+ }
+ out
+ }
+}
diff --git a/key-discovery-registry/src/test.rs b/key-discovery-registry/src/test.rs
new file mode 100644
index 00000000..2956414d
--- /dev/null
+++ b/key-discovery-registry/src/test.rs
@@ -0,0 +1,330 @@
+use super::*;
+use soroban_sdk::{
+ testutils::{Address as _, Ledger as _},
+ Address, Env,
+};
+
+const T0: u64 = 1_000_000;
+
+fn setup(env: &Env) -> (KeyDiscoveryRegistryClient<'_>, Address) {
+ env.mock_all_auths();
+ env.ledger().with_mut(|l| {
+ l.timestamp = T0;
+ });
+ let contract_id = env.register(KeyDiscoveryRegistry, ());
+ let client = KeyDiscoveryRegistryClient::new(env, &contract_id);
+ let admin = Address::generate(env);
+ client.initialise(&admin);
+ (client, admin)
+}
+
+fn advance(env: &Env, seconds: u64) {
+ env.ledger().with_mut(|l| {
+ l.timestamp += seconds;
+ });
+}
+
+// --- Acceptance: keys registered correctly on factory deployment ---
+
+#[test]
+fn registers_keys_with_metadata_and_count() {
+ let env = Env::default();
+ let (client, admin) = setup(&env);
+ let creator = Address::generate(&env);
+ let key_a = Address::generate(&env);
+ let key_b = Address::generate(&env);
+
+ client.register_key(&admin, &key_a, &creator);
+ advance(&env, 10);
+ client.register_key(&admin, &key_b, &creator);
+
+ assert_eq!(client.get_registered_count(), 2);
+ let info = client.get_key_info(&key_a).unwrap();
+ assert_eq!(info.key_id, key_a);
+ assert_eq!(info.creator, creator);
+ assert_eq!(info.deploy_timestamp, T0);
+ let info_b = client.get_key_info(&key_b).unwrap();
+ assert_eq!(info_b.deploy_timestamp, T0 + 10);
+}
+
+#[test]
+fn register_requires_authorised_caller() {
+ let env = Env::default();
+ let (client, _admin) = setup(&env);
+ let stranger = Address::generate(&env);
+ let creator = Address::generate(&env);
+ let key = Address::generate(&env);
+ assert_eq!(
+ client.try_register_key(&stranger, &key, &creator),
+ Err(Ok(DiscoveryError::Unauthorized))
+ );
+}
+
+#[test]
+fn authorised_registrar_can_register() {
+ let env = Env::default();
+ let (client, _admin) = setup(&env);
+ let registrar = Address::generate(&env);
+ let creator = Address::generate(&env);
+ let key = Address::generate(&env);
+ client.set_registrar(®istrar, &true);
+ client.register_key(®istrar, &key, &creator);
+ assert_eq!(client.get_registered_count(), 1);
+}
+
+#[test]
+fn duplicate_registration_is_rejected() {
+ let env = Env::default();
+ let (client, admin) = setup(&env);
+ let creator = Address::generate(&env);
+ let key = Address::generate(&env);
+ client.register_key(&admin, &key, &creator);
+ assert_eq!(
+ client.try_register_key(&admin, &key, &creator),
+ Err(Ok(DiscoveryError::AlreadyRegistered))
+ );
+}
+
+// --- Acceptance: 24h volume computed correctly, excluding older trades ---
+
+#[test]
+fn volume_24h_excludes_trades_older_than_window() {
+ let env = Env::default();
+ let (client, admin) = setup(&env);
+ let creator = Address::generate(&env);
+ let key = Address::generate(&env);
+ client.register_key(&admin, &key, &creator);
+
+ client.record_volume(&admin, &key, &100);
+ // Still inside the 24h window.
+ advance(&env, VOLUME_WINDOW_24H - 1);
+ assert_eq!(client.get_volume_24h(&key), 100);
+
+ // One second past the window: the original trade is now outside 24h.
+ advance(&env, 2);
+ assert_eq!(client.get_volume_24h(&key), 0);
+}
+
+#[test]
+fn volume_24h_sums_recent_and_drops_old() {
+ let env = Env::default();
+ let (client, admin) = setup(&env);
+ let creator = Address::generate(&env);
+ let key = Address::generate(&env);
+ client.register_key(&admin, &key, &creator);
+
+ client.record_volume(&admin, &key, &100);
+ advance(&env, VOLUME_WINDOW_24H - 100);
+ client.record_volume(&admin, &key, &50);
+ // Both trades are within 24h of "now".
+ assert_eq!(client.get_volume_24h(&key), 150);
+
+ // Advance past the first trade's window but keep the second.
+ advance(&env, 200);
+ assert_eq!(client.get_volume_24h(&key), 50);
+}
+
+#[test]
+fn record_volume_guards() {
+ let env = Env::default();
+ let (client, admin) = setup(&env);
+ let stranger = Address::generate(&env);
+ let creator = Address::generate(&env);
+ let key = Address::generate(&env);
+
+ // Unregistered key.
+ assert_eq!(
+ client.try_record_volume(&admin, &key, &10),
+ Err(Ok(DiscoveryError::KeyNotRegistered))
+ );
+
+ client.register_key(&admin, &key, &creator);
+
+ // Non-positive amount.
+ assert_eq!(
+ client.try_record_volume(&admin, &key, &0),
+ Err(Ok(DiscoveryError::NotPositiveAmount))
+ );
+ assert_eq!(
+ client.try_record_volume(&admin, &key, &-5),
+ Err(Ok(DiscoveryError::NotPositiveAmount))
+ );
+
+ // Unauthorised caller.
+ assert_eq!(
+ client.try_record_volume(&stranger, &key, &10),
+ Err(Ok(DiscoveryError::Unauthorized))
+ );
+}
+
+#[test]
+fn authorised_recorder_can_record() {
+ let env = Env::default();
+ let (client, admin) = setup(&env);
+ let recorder = Address::generate(&env);
+ let creator = Address::generate(&env);
+ let key = Address::generate(&env);
+ client.register_key(&admin, &key, &creator);
+ client.set_recorder(&recorder, &true);
+ client.record_volume(&recorder, &key, &42);
+ assert_eq!(client.get_volume_24h(&key), 42);
+}
+
+// --- Acceptance: get_trending returns correct top N in descending order ---
+
+#[test]
+fn trending_returns_top_n_descending() {
+ let env = Env::default();
+ let (client, admin) = setup(&env);
+ let creator = Address::generate(&env);
+ let key_a = Address::generate(&env);
+ let key_b = Address::generate(&env);
+ let key_c = Address::generate(&env);
+ client.register_key(&admin, &key_a, &creator);
+ client.register_key(&admin, &key_b, &creator);
+ client.register_key(&admin, &key_c, &creator);
+
+ client.record_volume(&admin, &key_a, &50);
+ client.record_volume(&admin, &key_b, &200);
+ client.record_volume(&admin, &key_c, &100);
+
+ let trending = client.get_trending(&3);
+ assert_eq!(trending.len(), 3);
+ assert_eq!(trending.get(0).unwrap().key_id, key_b);
+ assert_eq!(trending.get(0).unwrap().volume_24h, 200);
+ assert_eq!(trending.get(1).unwrap().key_id, key_c);
+ assert_eq!(trending.get(1).unwrap().volume_24h, 100);
+ assert_eq!(trending.get(2).unwrap().key_id, key_a);
+ assert_eq!(trending.get(2).unwrap().volume_24h, 50);
+
+ let top2 = client.get_trending(&2);
+ assert_eq!(top2.len(), 2);
+ assert_eq!(top2.get(0).unwrap().key_id, key_b);
+ assert_eq!(top2.get(1).unwrap().key_id, key_c);
+}
+
+#[test]
+fn trending_excludes_zero_volume_and_expired() {
+ let env = Env::default();
+ let (client, admin) = setup(&env);
+ let creator = Address::generate(&env);
+ let key_a = Address::generate(&env);
+ let key_b = Address::generate(&env);
+ client.register_key(&admin, &key_a, &creator);
+ client.register_key(&admin, &key_b, &creator);
+
+ client.record_volume(&admin, &key_a, &30);
+ // key_b never trades.
+
+ let trending = client.get_trending(&10);
+ assert_eq!(trending.len(), 1);
+ assert_eq!(trending.get(0).unwrap().key_id, key_a);
+
+ // Expire key_a's volume; nothing remains trending.
+ advance(&env, VOLUME_WINDOW_24H + 1);
+ assert_eq!(client.get_trending(&10).len(), 0);
+}
+
+#[test]
+fn trending_limit_zero_is_empty() {
+ let env = Env::default();
+ let (client, admin) = setup(&env);
+ let creator = Address::generate(&env);
+ let key = Address::generate(&env);
+ client.register_key(&admin, &key, &creator);
+ client.record_volume(&admin, &key, &10);
+ assert_eq!(client.get_trending(&0).len(), 0);
+}
+
+// --- Acceptance: get_new_keys returns correct keys since timestamp ---
+
+#[test]
+fn new_keys_returns_recent_newest_first() {
+ let env = Env::default();
+ let (client, admin) = setup(&env);
+ let creator = Address::generate(&env);
+ let key_a = Address::generate(&env);
+ let key_b = Address::generate(&env);
+ let key_c = Address::generate(&env);
+ client.register_key(&admin, &key_a, &creator); // T0
+ advance(&env, 10);
+ client.register_key(&admin, &key_b, &creator); // T0+10
+ advance(&env, 10);
+ client.register_key(&admin, &key_c, &creator); // T0+20
+
+ // Everything since T0, newest first.
+ let all = client.get_new_keys(&T0, &10);
+ assert_eq!(all.len(), 3);
+ assert_eq!(all.get(0).unwrap(), key_c);
+ assert_eq!(all.get(1).unwrap(), key_b);
+ assert_eq!(all.get(2).unwrap(), key_a);
+
+ // Only keys deployed at/after T0+10.
+ let recent = client.get_new_keys(&(T0 + 10), &10);
+ assert_eq!(recent.len(), 2);
+ assert_eq!(recent.get(0).unwrap(), key_c);
+ assert_eq!(recent.get(1).unwrap(), key_b);
+
+ // Limit clamps the newest keys.
+ let limited = client.get_new_keys(&T0, &2);
+ assert_eq!(limited.len(), 2);
+ assert_eq!(limited.get(0).unwrap(), key_c);
+ assert_eq!(limited.get(1).unwrap(), key_b);
+
+ // Nothing deployed yet at a future timestamp.
+ assert_eq!(client.get_new_keys(&(T0 + 1000), &10).len(), 0);
+ assert_eq!(client.get_new_keys(&T0, &0).len(), 0);
+}
+
+// --- Acceptance: volume pruning runs correctly beyond 48h window ---
+
+#[test]
+fn pruning_removes_records_beyond_48h() {
+ let env = Env::default();
+ let (client, admin) = setup(&env);
+ let creator = Address::generate(&env);
+ let key = Address::generate(&env);
+ client.register_key(&admin, &key, &creator);
+
+ client.record_volume(&admin, &key, &10);
+ assert_eq!(client.get_volume_entries(&key).len(), 1);
+
+ // Exactly at the 48h boundary the record is still retained.
+ advance(&env, VOLUME_RETENTION_48H);
+ assert!(!client.prune(&key));
+ assert_eq!(client.get_volume_entries(&key).len(), 1);
+
+ // One second past retention the record is pruned.
+ advance(&env, 1);
+ assert!(client.prune(&key));
+ assert_eq!(client.get_volume_entries(&key).len(), 0);
+}
+
+#[test]
+fn record_volume_prunes_stale_records_on_write() {
+ let env = Env::default();
+ let (client, admin) = setup(&env);
+ let creator = Address::generate(&env);
+ let key = Address::generate(&env);
+ client.register_key(&admin, &key, &creator);
+
+ client.record_volume(&admin, &key, &10);
+ advance(&env, VOLUME_RETENTION_48H + 1);
+ client.record_volume(&admin, &key, &5);
+
+ // The stale record was dropped; only the fresh one remains.
+ let entries = client.get_volume_entries(&key);
+ assert_eq!(entries.len(), 1);
+ assert_eq!(entries.get(0).unwrap().amount, 5);
+ assert_eq!(client.get_volume_24h(&key), 5);
+}
+
+#[test]
+fn initialise_is_once_only() {
+ let env = Env::default();
+ let (client, admin) = setup(&env);
+ assert_eq!(
+ client.try_initialise(&admin),
+ Err(Ok(DiscoveryError::AlreadyInitialised))
+ );
+}