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)) + ); +}