Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions contracts/escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
278 changes: 278 additions & 0 deletions contracts/escrow/src/test/contract_id_allocation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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<u32> = 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"
);
}
Loading