From 41c24666c36cefcf7740fdd3e1c498a2b4e08a3f Mon Sep 17 00:00:00 2001 From: esenyidaniel-hash Date: Tue, 30 Jun 2026 09:39:27 +0100 Subject: [PATCH] feat/implement Create Loading Spinner Component --- contracts/escrow/src/lib.rs | 71 +++++ .../escrow/src/test/contract_id_allocation.rs | 278 ++++++++++++++++++ docs/escrow/abi-reference.md | 20 ++ 3 files changed, 369 insertions(+) diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index bf84198..b86568b 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -1031,6 +1031,42 @@ impl Escrow { total_refund_amount } + /// Checks whether a contract with the given ID exists in storage. + /// + /// This is a cheap, non-panicking existence probe that returns `true` if + /// the contract record is present and `false` otherwise. Unlike [`get_contract`], + /// this function does **not** panic with `ContractNotFound` for missing IDs, + /// making it safe for indexers and clients iterating over ID ranges. + /// + /// # Security + /// This is a read-only operation that does **not** extend the contract's TTL. + /// Probing for contract existence cannot be abused to keep entries alive. + /// Only actual contract operations (reads/writes) extend TTL. + /// + /// # Arguments + /// * `env` - The contract environment + /// * `contract_id` - The contract ID to check + /// + /// # Returns + /// * `true` if the contract exists + /// * `false` if the contract does not exist + /// + /// # Examples + /// ``` + /// // Safe iteration over a range of IDs + /// for id in 1..=100 { + /// if escrow.contract_exists(id) { + /// let contract = escrow.get_contract(id); + /// // process contract + /// } + /// } + /// ``` + pub fn contract_exists(env: Env, contract_id: u32) -> bool { + env.storage() + .persistent() + .has(&DataKey::Contract(contract_id)) + } + /// Retrieves contract information. pub fn get_contract(env: Env, contract_id: u32) -> Contract { let contract = env @@ -1044,6 +1080,41 @@ impl Escrow { contract } + /// Returns the next contract ID to be allocated (the high-water mark). + /// + /// This reader returns the current value of `NextContractId`, which represents + /// the next ID that will be assigned when [`create_contract`] is called. + /// Indexers can use this to determine the allocation high-water mark and + /// safely iterate over the allocated ID range `[1, get_next_contract_id() - 1]`. + /// + /// # Security + /// This is a read-only operation that does not mutate contract state or extend TTL. + /// + /// # Arguments + /// * `env` - The contract environment + /// + /// # Returns + /// The next contract ID to be allocated (always ≥ 1) + /// + /// # Examples + /// ``` + /// // Get the high-water mark + /// let next_id = escrow.get_next_contract_id(); + /// // All allocated IDs are in the range [1, next_id - 1] + /// for id in 1..next_id { + /// if escrow.contract_exists(id) { + /// let contract = escrow.get_contract(id); + /// // process contract + /// } + /// } + /// ``` + pub fn get_next_contract_id(env: Env) -> u32 { + env.storage() + .persistent() + .get(&DataKey::NextContractId) + .unwrap_or(1) + } + /// Returns a structured summary of the contract and its milestones. /// /// Extends contract and milestone TTL on read without requiring caller auth. diff --git a/contracts/escrow/src/test/contract_id_allocation.rs b/contracts/escrow/src/test/contract_id_allocation.rs index 1fb3cc7..7ab5f67 100644 --- a/contracts/escrow/src/test/contract_id_allocation.rs +++ b/contracts/escrow/src/test/contract_id_allocation.rs @@ -7,6 +7,9 @@ //! 4. A `ContractIdOverflow` error is returned when the counter is at `u32::MAX`. //! 5. A `ContractIdCollision` error is returned when the target slot is occupied. //! 6. Neither overflow nor collision mutates `NextContractId`. +//! 7. `contract_exists` correctly identifies existing and missing contracts. +//! 8. `get_next_contract_id` returns the allocation high-water mark. +//! 9. Existence probes do not extend TTL (security invariant). use super::{default_milestones, generated_participants, register_client}; use crate::{DataKey, Error, ReleaseAuthorization}; @@ -359,3 +362,278 @@ fn single_create_stores_exactly_one_contract() { assert!(!phantom2, "phantom contract at id+1 must not exist"); }); } + +// ----------------------------------------------------------------------- +// contract_exists and get_next_contract_id readers +// ----------------------------------------------------------------------- + +/// `contract_exists` returns `true` for an allocated contract and `false` for +/// an ID that was never allocated. +#[test] +fn contract_exists_identifies_allocated_and_missing_ids() { + let env = Env::default(); + env.mock_all_auths(); + let escrow = register_client(&env); + let (client, freelancer, _) = generated_participants(&env); + let milestones = default_milestones(&env); + + // Before any creates, no contracts exist. + assert!(!escrow.contract_exists(1), "id 1 should not exist before creation"); + assert!(!escrow.contract_exists(999), "id 999 should not exist"); + + // Create one contract. + let id = escrow.create_contract( + &client, + &freelancer, + &None, + &milestones, + &ReleaseAuthorization::ClientOnly, + ); + assert_eq!(id, 1); + + // The allocated id exists. + assert!(escrow.contract_exists(1), "id 1 should exist after creation"); + + // Unallocated ids still do not exist. + assert!(!escrow.contract_exists(2), "id 2 should not exist yet"); + assert!(!escrow.contract_exists(0), "id 0 should never exist"); + assert!(!escrow.contract_exists(1000), "id 1000 should not exist"); + + // Create a second contract. + let (c2, f2, _) = generated_participants(&env); + let id2 = escrow.create_contract( + &c2, + &f2, + &None, + &milestones, + &ReleaseAuthorization::ClientOnly, + ); + assert_eq!(id2, 2); + + assert!(escrow.contract_exists(1), "id 1 should still exist"); + assert!(escrow.contract_exists(2), "id 2 should exist after second creation"); + assert!(!escrow.contract_exists(3), "id 3 should not exist yet"); +} + +/// `get_next_contract_id` returns the allocation high-water mark. +#[test] +fn get_next_contract_id_returns_high_water_mark() { + let env = Env::default(); + env.mock_all_auths(); + let escrow = register_client(&env); + let (client, freelancer, _) = generated_participants(&env); + let milestones = default_milestones(&env); + + // Before initialization, the default is 1. + // (In practice callers should initialize first, but the reader is safe.) + let before_init = escrow.get_next_contract_id(); + assert_eq!(before_init, 1, "default next id before init should be 1"); + + // Initialize the contract. + let admin = Address::generate(&env); + escrow.initialize(&admin); + + // After initialization, next id is 1. + assert_eq!(escrow.get_next_contract_id(), 1); + + // After creating one contract, next id is 2. + escrow.create_contract( + &client, + &freelancer, + &None, + &milestones, + &ReleaseAuthorization::ClientOnly, + ); + assert_eq!(escrow.get_next_contract_id(), 2); + + // After creating three total, next id is 4. + for _ in 0..2 { + let (c, f, _) = generated_participants(&env); + escrow.create_contract( + &c, + &f, + &None, + &milestones, + &ReleaseAuthorization::ClientOnly, + ); + } + assert_eq!(escrow.get_next_contract_id(), 4); +} + +/// `contract_exists` does not panic on missing IDs (unlike `get_contract`). +#[test] +fn contract_exists_does_not_panic_on_missing_id() { + let env = Env::default(); + env.mock_all_auths(); + let escrow = register_client(&env); + let (client, freelancer, _) = generated_participants(&env); + let milestones = default_milestones(&env); + + escrow.create_contract( + &client, + &freelancer, + &None, + &milestones, + &ReleaseAuthorization::ClientOnly, + ); + + // These must all return false without panicking. + assert!(!escrow.contract_exists(0)); + assert!(!escrow.contract_exists(2)); + assert!(!escrow.contract_exists(100)); + assert!(!escrow.contract_exists(u32::MAX)); +} + +/// `contract_exists` combined with `get_next_contract_id` gives indexers a +/// safe iteration pattern: `[1, next_id - 1]`. +#[test] +fn indexer_iteration_pattern_with_contract_exists_and_next_id() { + let env = Env::default(); + env.mock_all_auths(); + let escrow = register_client(&env); + let milestones = default_milestones(&env); + + // Create contracts with ids 1, 2, 3 (skip id 4 by winding counter). + for i in 0..3 { + let (c, f, _) = generated_participants(&env); + escrow.create_contract( + &c, + &f, + &None, + &milestones, + &ReleaseAuthorization::ClientOnly, + ); + } + + // Wind counter to 5 so id 4 is skipped (simulating a gap). + env.as_contract(&escrow.address, || { + env.storage() + .persistent() + .set(&DataKey::NextContractId, &5u32); + }); + + let next_id = escrow.get_next_contract_id(); + assert_eq!(next_id, 5); + + // Collect all existing ids via the safe probe. + let mut found: Vec = Vec::new(&env); + for id in 1..next_id { + if escrow.contract_exists(id) { + found.push_back(id); + } + } + + // Only ids 1, 2, 3 should be found; id 4 is a gap. + assert_eq!(found.len(), 3); + assert_eq!(found.get(0), Some(&1)); + assert_eq!(found.get(1), Some(&2)); + assert_eq!(found.get(2), Some(&3)); +} + +/// `contract_exists` is a read-only probe and must not extend the contract TTL. +/// We verify this by checking that the contract's TTL is unchanged after probing. +#[test] +fn contract_exists_does_not_extend_ttl() { + let env = Env::default(); + env.mock_all_auths(); + let escrow = register_client(&env); + let (client, freelancer, _) = generated_participants(&env); + let milestones = default_milestones(&env); + + escrow.create_contract( + &client, + &freelancer, + &None, + &milestones, + &ReleaseAuthorization::ClientOnly, + ); + + // Read the TTL before probing. + let ttl_before: u32 = env.as_contract(&escrow.address, || { + env.storage() + .persistent() + .get_ttl(&DataKey::Contract(1)) + .unwrap() + }); + + // Probe existence — this must NOT extend TTL. + let exists = escrow.contract_exists(1); + assert!(exists); + + // Read the TTL after probing. + let ttl_after: u32 = env.as_contract(&escrow.address, || { + env.storage() + .persistent() + .get_ttl(&DataKey::Contract(1)) + .unwrap() + }); + + // TTL must be unchanged. + assert_eq!( + ttl_before, ttl_after, + "contract_exists must not extend TTL (before={}, after={})", + ttl_before, ttl_after + ); +} + +/// `get_next_contract_id` is also read-only and must not extend any TTL. +#[test] +fn get_next_contract_id_does_not_extend_ttl() { + let env = Env::default(); + env.mock_all_auths(); + let escrow = register_client(&env); + let (client, freelancer, _) = generated_participants(&env); + let milestones = default_milestones(&env); + + escrow.create_contract( + &client, + &freelancer, + &None, + &milestones, + &ReleaseAuthorization::ClientOnly, + ); + + // Read the contract TTL before calling get_next_contract_id. + let ttl_before: u32 = env.as_contract(&escrow.address, || { + env.storage() + .persistent() + .get_ttl(&DataKey::Contract(1)) + .unwrap() + }); + + // Call the reader. + let _next = escrow.get_next_contract_id(); + + // Read the contract TTL after. + let ttl_after: u32 = env.as_contract(&escrow.address, || { + env.storage() + .persistent() + .get_ttl(&DataKey::Contract(1)) + .unwrap() + }); + + assert_eq!( + ttl_before, ttl_after, + "get_next_contract_id must not extend contract TTL (before={}, after={})", + ttl_before, ttl_after + ); + + // Also verify NextContractId TTL is unchanged. + let next_ttl_before: u32 = env.as_contract(&escrow.address, || { + env.storage() + .persistent() + .get_ttl(&DataKey::NextContractId) + .unwrap() + }); + let _next2 = escrow.get_next_contract_id(); + let next_ttl_after: u32 = env.as_contract(&escrow.address, || { + env.storage() + .persistent() + .get_ttl(&DataKey::NextContractId) + .unwrap() + }); + assert_eq!( + next_ttl_before, next_ttl_after, + "get_next_contract_id must not extend NextContractId TTL" + ); +} diff --git a/docs/escrow/abi-reference.md b/docs/escrow/abi-reference.md index 353e51d..49c7cb2 100644 --- a/docs/escrow/abi-reference.md +++ b/docs/escrow/abi-reference.md @@ -148,6 +148,26 @@ The list intentionally omits planned or reserved entrypoints that are not implem - Events: None in the current implementation - Errors: `EmptyRefundRequest`, `DuplicateMilestoneInRefund`, `ContractNotFound`, `InvalidState`, `IndexOutOfBounds`, `AlreadyReleased`, `AlreadyRefunded`, `InsufficientFunds`, `AlreadyFinalized` +### contract_exists + +- Signature: `contract_exists(env: Env, contract_id: u32) -> bool` +- Kind: Read-only +- Auth: None +- Semantics: Checks whether a contract with the given ID exists in storage. Returns `true` if the contract record is present, `false` otherwise. This is a cheap, non-panicking existence probe suitable for indexers iterating over ID ranges. Unlike `get_contract`, this function does not panic with `ContractNotFound` for missing IDs. +- Events: None +- Errors: None +- Security: This is a read-only operation that does **not** extend the contract's TTL. Probing for contract existence cannot be abused to keep entries alive. + +### get_next_contract_id + +- Signature: `get_next_contract_id(env: Env) -> u32` +- Kind: Read-only +- Auth: None +- Semantics: Returns the next contract ID to be allocated (the allocation high-water mark). Indexers can use this to determine the allocated ID range `[1, get_next_contract_id() - 1]` and safely probe for existing contracts using `contract_exists`. +- Events: None +- Errors: None +- Security: This is a read-only operation that does not mutate contract state or extend TTL. + ### get_contract - Signature: `get_contract(env: Env, contract_id: u32) -> Contract`