diff --git a/omni-utils-derive/src/trusted_relayer.rs b/omni-utils-derive/src/trusted_relayer.rs index 2113874..b1dcd3d 100644 --- a/omni-utils-derive/src/trusted_relayer.rs +++ b/omni-utils-derive/src/trusted_relayer.rs @@ -150,7 +150,7 @@ fn gen_bypass_is_trusted(bypass_roles: &[Expr]) -> TokenStream2 { return true; } - ::omni_utils::trusted_relayer::tr_relayers_map() + ::omni_utils::trusted_relayer::tr_load_relayers() .get(account_id) .is_some_and(|state| { ::near_sdk::env::block_timestamp() >= state.activate_at.0 @@ -263,6 +263,32 @@ fn gen_public_methods( ) -> ::omni_utils::trusted_relayer::RelayerConfig { ::_tr_get_config(self) } + + #[must_use] + pub fn get_active_relayers( + &self, + from_index: Option, + limit: Option, + ) -> Vec<(::near_sdk::AccountId, ::omni_utils::trusted_relayer::RelayerState)> { + ::_tr_get_active_relayers( + self, + from_index, + limit, + ) + } + + #[must_use] + pub fn get_pending_relayers( + &self, + from_index: Option, + limit: Option, + ) -> Vec<(::near_sdk::AccountId, ::omni_utils::trusted_relayer::RelayerState)> { + ::_tr_get_pending_relayers( + self, + from_index, + limit, + ) + } } } } @@ -319,7 +345,7 @@ fn gen_method_bypass_guard(bypass_roles: &[Expr]) -> syn::Stmt { __caller.clone(), ) { ::near_sdk::require!( - ::omni_utils::trusted_relayer::tr_relayers_map() + ::omni_utils::trusted_relayer::tr_load_relayers() .get(&__caller) .is_some_and(|state| { ::near_sdk::env::block_timestamp() >= state.activate_at.0 diff --git a/omni-utils/src/trusted_relayer.rs b/omni-utils/src/trusted_relayer.rs index c74c53f..8124f7d 100644 --- a/omni-utils/src/trusted_relayer.rs +++ b/omni-utils/src/trusted_relayer.rs @@ -1,11 +1,15 @@ +use core::ops::{Deref, DerefMut}; + use near_sdk::borsh::{self, BorshDeserialize}; use near_sdk::json_types::{U64, U128}; use near_sdk::serde_json::json; -use near_sdk::store::LookupMap; +use near_sdk::store::IterableMap; use near_sdk::{AccountId, NearToken, Promise, env, near, require}; const TR_CONFIG_KEY: &[u8] = b"__tr_config"; const TR_RELAYERS_PREFIX: &[u8] = b"__tr_relayers"; +const TR_RELAYERS_META_KEY: &[u8] = b"__tr_relayers_meta"; +const TR_DEFAULT_PAGE_LIMIT: u32 = 100; #[derive(Debug, Clone)] #[near(serializers = [json, borsh])] @@ -71,8 +75,59 @@ pub fn tr_save_config(config: &RelayerConfig) { ); } -pub fn tr_relayers_map() -> LookupMap { - LookupMap::new(TR_RELAYERS_PREFIX) +/// Wrapper around `IterableMap` that automatically persists iteration metadata +/// (the internal `len` field) on drop. Entry data is flushed by the inner +/// `IterableMap`'s own `Drop` impl; this wrapper handles the metadata that +/// `IterableMap` cannot persist on its own in a detached (non-struct-field) pattern. +/// +/// Metadata is only written when the map has been mutated (via `DerefMut`), +/// so read-only usage in view calls does not trigger `storage_write`. +pub struct RelayerMap { + inner: IterableMap, + modified: bool, +} + +impl Deref for RelayerMap { + type Target = IterableMap; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl DerefMut for RelayerMap { + fn deref_mut(&mut self) -> &mut Self::Target { + self.modified = true; + &mut self.inner + } +} + +impl Drop for RelayerMap { + fn drop(&mut self) { + if self.modified { + env::storage_write( + TR_RELAYERS_META_KEY, + &borsh::to_vec(&self.inner).unwrap_or_else(|_| { + env::panic_str("Failed to serialize relayers map metadata") + }), + ); + } + } +} + +/// Load the relayers map, restoring iteration metadata from storage. +/// On first use (no metadata yet), creates a fresh empty map. +/// Metadata is persisted automatically when the returned `RelayerMap` is dropped. +pub fn tr_load_relayers() -> RelayerMap { + let inner = match env::storage_read(TR_RELAYERS_META_KEY) { + Some(bytes) => BorshDeserialize::try_from_slice(&bytes) + .unwrap_or_else(|_| env::panic_str("Failed to deserialize relayers map metadata")), + None => IterableMap::new(TR_RELAYERS_PREFIX), + }; + RelayerMap { + inner, + modified: false, + } } /// Trusted relayer staking support for NEAR contracts. @@ -87,14 +142,14 @@ pub trait TrustedRelayer { return true; } - tr_relayers_map() + tr_load_relayers() .get(account_id) .is_some_and(|state| env::block_timestamp() >= state.activate_at.0) } fn _tr_apply(&mut self) { let account_id = env::predecessor_account_id(); - let mut relayers = tr_relayers_map(); + let mut relayers = tr_load_relayers(); require!( relayers.get(&account_id).is_none(), @@ -137,7 +192,7 @@ pub trait TrustedRelayer { fn _tr_resign(&mut self) -> Promise { let account_id = env::predecessor_account_id(); - let mut relayers = tr_relayers_map(); + let mut relayers = tr_load_relayers(); let state = relayers .remove(&account_id) @@ -158,7 +213,7 @@ pub trait TrustedRelayer { } fn _tr_reject(&mut self, account_id: AccountId) -> Promise { - let mut relayers = tr_relayers_map(); + let mut relayers = tr_load_relayers(); let state = relayers .remove(&account_id) @@ -181,14 +236,14 @@ pub trait TrustedRelayer { } fn _tr_get_application(&self, account_id: &AccountId) -> Option { - tr_relayers_map() + tr_load_relayers() .get(account_id) .filter(|state| env::block_timestamp() < state.activate_at.0) .cloned() } fn _tr_get_stake(&self, account_id: &AccountId) -> Option { - tr_relayers_map() + tr_load_relayers() .get(account_id) .filter(|state| env::block_timestamp() >= state.activate_at.0) .map(|state| U128(state.stake.as_yoctonear())) @@ -197,4 +252,36 @@ pub trait TrustedRelayer { fn _tr_get_config(&self) -> RelayerConfig { tr_load_config() } + + fn _tr_get_active_relayers( + &self, + from_index: Option, + limit: Option, + ) -> Vec<(AccountId, RelayerState)> { + let relayers = tr_load_relayers(); + let now = env::block_timestamp(); + relayers + .iter() + .filter(|(_, state)| now >= state.activate_at.0) + .skip(from_index.unwrap_or(0) as usize) + .take(limit.unwrap_or(TR_DEFAULT_PAGE_LIMIT) as usize) + .map(|(id, state)| (id.clone(), state.clone())) + .collect() + } + + fn _tr_get_pending_relayers( + &self, + from_index: Option, + limit: Option, + ) -> Vec<(AccountId, RelayerState)> { + let relayers = tr_load_relayers(); + let now = env::block_timestamp(); + relayers + .iter() + .filter(|(_, state)| now < state.activate_at.0) + .skip(from_index.unwrap_or(0) as usize) + .take(limit.unwrap_or(TR_DEFAULT_PAGE_LIMIT) as usize) + .map(|(id, state)| (id.clone(), state.clone())) + .collect() + } } diff --git a/omni-utils/tests/trusted_relayer.rs b/omni-utils/tests/trusted_relayer.rs index 0fc7441..eaa6dac 100644 --- a/omni-utils/tests/trusted_relayer.rs +++ b/omni-utils/tests/trusted_relayer.rs @@ -1388,3 +1388,404 @@ async fn test_custom_generated_public_methods_exist() { .expect("Admin should be able to set config") .assert_success(); } + +#[tokio::test] +async fn test_get_active_relayers_empty() { + let env = get_env().await; + let (contract_id, _) = deploy_contract(env).await; + + let result: Data> = Contract(contract_id) + .call_function("get_active_relayers", json!({})) + .read_only() + .fetch_from(&env.network) + .await + .unwrap(); + + assert!( + result.data.is_empty(), + "Should return empty list when no relayers exist" + ); +} + +#[tokio::test] +async fn test_get_pending_relayers_empty() { + let env = get_env().await; + let (contract_id, _) = deploy_contract(env).await; + + let result: Data> = Contract(contract_id) + .call_function("get_pending_relayers", json!({})) + .read_only() + .fetch_from(&env.network) + .await + .unwrap(); + + assert!( + result.data.is_empty(), + "Should return empty list when no relayers exist" + ); +} + +#[tokio::test] +async fn test_get_pending_relayers_returns_pending() { + let env = get_env().await; + let (contract_id, contract_signer) = deploy_contract(env).await; + let (admin_id, admin_signer) = create_account(env, 10).await; + let (relayer_id, relayer_signer) = create_account(env, 20).await; + + grant_role(env, &contract_id, &contract_signer, "Admin", &admin_id).await; + // Long waiting period so the relayer stays pending + set_short_config(env, &contract_id, &admin_id, &admin_signer, 5, u64::MAX).await; + + // Apply + Contract(contract_id.clone()) + .call_function("apply_for_trusted_relayer", json!({})) + .transaction() + .deposit(near_api::NearToken::from_near(5)) + .with_signer(relayer_id.clone(), relayer_signer) + .send_to(&env.network) + .await + .expect("Apply should succeed") + .assert_success(); + + // Should appear in pending + let pending: Data> = Contract(contract_id.clone()) + .call_function("get_pending_relayers", json!({})) + .read_only() + .fetch_from(&env.network) + .await + .unwrap(); + + assert_eq!(pending.data.len(), 1, "Should have one pending relayer"); + assert_eq!( + pending.data[0].0, + relayer_id.to_string(), + "Pending relayer account_id should match" + ); + + // Should NOT appear in active + let active: Data> = Contract(contract_id) + .call_function("get_active_relayers", json!({})) + .read_only() + .fetch_from(&env.network) + .await + .unwrap(); + + assert!( + active.data.is_empty(), + "Should have no active relayers while pending" + ); +} + +#[tokio::test] +async fn test_get_active_relayers_returns_active() { + let env = get_env().await; + let (contract_id, contract_signer) = deploy_contract(env).await; + let (admin_id, admin_signer) = create_account(env, 10).await; + let (relayer_id, relayer_signer) = create_account(env, 20).await; + + grant_role(env, &contract_id, &contract_signer, "Admin", &admin_id).await; + // 0 waiting period → instantly active + set_short_config(env, &contract_id, &admin_id, &admin_signer, 5, 0).await; + + // Apply + Contract(contract_id.clone()) + .call_function("apply_for_trusted_relayer", json!({})) + .transaction() + .deposit(near_api::NearToken::from_near(5)) + .with_signer(relayer_id.clone(), relayer_signer) + .send_to(&env.network) + .await + .expect("Apply should succeed") + .assert_success(); + + // Should appear in active + let active: Data> = Contract(contract_id.clone()) + .call_function("get_active_relayers", json!({})) + .read_only() + .fetch_from(&env.network) + .await + .unwrap(); + + assert_eq!(active.data.len(), 1, "Should have one active relayer"); + assert_eq!( + active.data[0].0, + relayer_id.to_string(), + "Active relayer account_id should match" + ); + + // Should NOT appear in pending + let pending: Data> = Contract(contract_id) + .call_function("get_pending_relayers", json!({})) + .read_only() + .fetch_from(&env.network) + .await + .unwrap(); + + assert!( + pending.data.is_empty(), + "Should have no pending relayers when instantly active" + ); +} + +#[tokio::test] +async fn test_get_active_relayers_multiple_with_pagination() { + let env = get_env().await; + let (contract_id, contract_signer) = deploy_contract(env).await; + let (admin_id, admin_signer) = create_account(env, 10).await; + + grant_role(env, &contract_id, &contract_signer, "Admin", &admin_id).await; + // 0 waiting period → instantly active + set_short_config(env, &contract_id, &admin_id, &admin_signer, 5, 0).await; + + // Apply with 3 relayers + let mut relayer_ids = Vec::new(); + for _ in 0..3 { + let (relayer_id, relayer_signer) = create_account(env, 20).await; + Contract(contract_id.clone()) + .call_function("apply_for_trusted_relayer", json!({})) + .transaction() + .deposit(near_api::NearToken::from_near(5)) + .with_signer(relayer_id.clone(), relayer_signer) + .send_to(&env.network) + .await + .expect("Apply should succeed") + .assert_success(); + relayer_ids.push(relayer_id.to_string()); + } + + // Get all active relayers (no pagination args) + let all: Data> = Contract(contract_id.clone()) + .call_function("get_active_relayers", json!({})) + .read_only() + .fetch_from(&env.network) + .await + .unwrap(); + + assert_eq!(all.data.len(), 3, "Should have 3 active relayers"); + + // Paginate: first 2 + let page1: Data> = Contract(contract_id.clone()) + .call_function( + "get_active_relayers", + json!({ "from_index": 0, "limit": 2 }), + ) + .read_only() + .fetch_from(&env.network) + .await + .unwrap(); + + assert_eq!(page1.data.len(), 2, "First page should have 2 relayers"); + + // Paginate: next page starting from index 2 + let page2: Data> = Contract(contract_id.clone()) + .call_function( + "get_active_relayers", + json!({ "from_index": 2, "limit": 2 }), + ) + .read_only() + .fetch_from(&env.network) + .await + .unwrap(); + + assert_eq!(page2.data.len(), 1, "Second page should have 1 relayer"); + + // Paginate: beyond range + let page3: Data> = Contract(contract_id) + .call_function( + "get_active_relayers", + json!({ "from_index": 10, "limit": 2 }), + ) + .read_only() + .fetch_from(&env.network) + .await + .unwrap(); + + assert!(page3.data.is_empty(), "Page beyond range should be empty"); +} + +#[tokio::test] +async fn test_get_pending_relayers_multiple_with_pagination() { + let env = get_env().await; + let (contract_id, contract_signer) = deploy_contract(env).await; + let (admin_id, admin_signer) = create_account(env, 10).await; + + grant_role(env, &contract_id, &contract_signer, "Admin", &admin_id).await; + // Long waiting period so all relayers stay pending + set_short_config(env, &contract_id, &admin_id, &admin_signer, 5, u64::MAX).await; + + // Apply with 3 relayers + for _ in 0..3 { + let (relayer_id, relayer_signer) = create_account(env, 20).await; + Contract(contract_id.clone()) + .call_function("apply_for_trusted_relayer", json!({})) + .transaction() + .deposit(near_api::NearToken::from_near(5)) + .with_signer(relayer_id.clone(), relayer_signer) + .send_to(&env.network) + .await + .expect("Apply should succeed") + .assert_success(); + } + + // Get all pending relayers + let all: Data> = Contract(contract_id.clone()) + .call_function("get_pending_relayers", json!({})) + .read_only() + .fetch_from(&env.network) + .await + .unwrap(); + + assert_eq!(all.data.len(), 3, "Should have 3 pending relayers"); + + // Paginate: first 1 + let page1: Data> = Contract(contract_id.clone()) + .call_function( + "get_pending_relayers", + json!({ "from_index": 0, "limit": 1 }), + ) + .read_only() + .fetch_from(&env.network) + .await + .unwrap(); + + assert_eq!(page1.data.len(), 1, "First page should have 1 relayer"); + + // Paginate: skip 1, take 2 + let page2: Data> = Contract(contract_id) + .call_function( + "get_pending_relayers", + json!({ "from_index": 1, "limit": 2 }), + ) + .read_only() + .fetch_from(&env.network) + .await + .unwrap(); + + assert_eq!(page2.data.len(), 2, "Second page should have 2 relayers"); +} + +#[tokio::test] +async fn test_get_relayers_mixed_active_and_pending() { + let env = get_env().await; + let (contract_id, contract_signer) = deploy_contract(env).await; + let (admin_id, admin_signer) = create_account(env, 10).await; + + grant_role(env, &contract_id, &contract_signer, "Admin", &admin_id).await; + + // First relayer: instantly active (waiting_period = 0) + set_short_config(env, &contract_id, &admin_id, &admin_signer, 5, 0).await; + let (active_relayer_id, active_relayer_signer) = create_account(env, 20).await; + Contract(contract_id.clone()) + .call_function("apply_for_trusted_relayer", json!({})) + .transaction() + .deposit(near_api::NearToken::from_near(5)) + .with_signer(active_relayer_id.clone(), active_relayer_signer) + .send_to(&env.network) + .await + .expect("Apply should succeed") + .assert_success(); + + // Second relayer: pending (long waiting period) + set_short_config(env, &contract_id, &admin_id, &admin_signer, 5, u64::MAX).await; + let (pending_relayer_id, pending_relayer_signer) = create_account(env, 20).await; + Contract(contract_id.clone()) + .call_function("apply_for_trusted_relayer", json!({})) + .transaction() + .deposit(near_api::NearToken::from_near(5)) + .with_signer(pending_relayer_id.clone(), pending_relayer_signer) + .send_to(&env.network) + .await + .expect("Apply should succeed") + .assert_success(); + + // Verify active list + let active: Data> = Contract(contract_id.clone()) + .call_function("get_active_relayers", json!({})) + .read_only() + .fetch_from(&env.network) + .await + .unwrap(); + + assert_eq!(active.data.len(), 1, "Should have exactly 1 active relayer"); + assert_eq!( + active.data[0].0, + active_relayer_id.to_string(), + "Active relayer should be the one with 0 waiting period" + ); + + // Verify pending list + let pending: Data> = Contract(contract_id) + .call_function("get_pending_relayers", json!({})) + .read_only() + .fetch_from(&env.network) + .await + .unwrap(); + + assert_eq!( + pending.data.len(), + 1, + "Should have exactly 1 pending relayer" + ); + assert_eq!( + pending.data[0].0, + pending_relayer_id.to_string(), + "Pending relayer should be the one with long waiting period" + ); +} + +#[tokio::test] +async fn test_get_active_relayers_after_resign() { + let env = get_env().await; + let (contract_id, contract_signer) = deploy_contract(env).await; + let (admin_id, admin_signer) = create_account(env, 10).await; + let (relayer_id, relayer_signer) = create_account(env, 20).await; + + grant_role(env, &contract_id, &contract_signer, "Admin", &admin_id).await; + set_short_config(env, &contract_id, &admin_id, &admin_signer, 5, 0).await; + + // Apply + Contract(contract_id.clone()) + .call_function("apply_for_trusted_relayer", json!({})) + .transaction() + .deposit(near_api::NearToken::from_near(5)) + .with_signer(relayer_id.clone(), relayer_signer.clone()) + .send_to(&env.network) + .await + .expect("Apply should succeed") + .assert_success(); + + // Verify active + let active: Data> = Contract(contract_id.clone()) + .call_function("get_active_relayers", json!({})) + .read_only() + .fetch_from(&env.network) + .await + .unwrap(); + assert_eq!( + active.data.len(), + 1, + "Should have 1 active relayer before resign" + ); + + // Resign + Contract(contract_id.clone()) + .call_function("resign_trusted_relayer", json!({})) + .transaction() + .with_signer(relayer_id, relayer_signer) + .send_to(&env.network) + .await + .expect("Resign should succeed") + .assert_success(); + + // Verify empty after resign + let active: Data> = Contract(contract_id) + .call_function("get_active_relayers", json!({})) + .read_only() + .fetch_from(&env.network) + .await + .unwrap(); + assert!( + active.data.is_empty(), + "Should have no active relayers after resign" + ); +} diff --git a/tests/test-contract-custom/src/lib.rs b/tests/test-contract-custom/src/lib.rs index bf97b73..3cd6a69 100644 --- a/tests/test-contract-custom/src/lib.rs +++ b/tests/test-contract-custom/src/lib.rs @@ -35,7 +35,7 @@ impl TrustedRelayer for CustomContract { } // Fall back to the standard staking map check - omni_utils::trusted_relayer::tr_relayers_map() + omni_utils::trusted_relayer::tr_load_relayers() .get(account_id) .is_some_and(|state| env::block_timestamp() >= state.activate_at.0) }