Skip to content
Merged
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
1 change: 1 addition & 0 deletions contracts/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
resolver = "2"
members = [
"access_control",
"cntr",
"manage_hub",
"membership_token",
"common_types",
Expand Down
4 changes: 4 additions & 0 deletions contracts/cntr/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[package]
name = "cntr"
version = "0.1.0"
edition = "2021"
58 changes: 58 additions & 0 deletions contracts/cntr/src/credit_topup.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/// Validates a credit top-up operation.
///
/// Returns `Ok(new_balance)` when `topup_amount > 0` and
/// `current_balance + topup_amount <= max_balance`.
pub fn validate_topup(
current_balance: i128,
topup_amount: i128,
max_balance: i128,
) -> Result<i128, &'static str> {
if topup_amount <= 0 {
return Err("Top-up amount must be positive");
}
let new_balance = current_balance.checked_add(topup_amount).ok_or("Balance would exceed maximum allowed")?;
if new_balance > max_balance {
return Err("Balance would exceed maximum allowed");
}
Ok(new_balance)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_valid_topup() {
assert_eq!(validate_topup(100, 50, 1000), Ok(150));
}

#[test]
fn test_zero_topup_rejected() {
assert_eq!(validate_topup(100, 0, 1000), Err("Top-up amount must be positive"));
}

#[test]
fn test_negative_topup_rejected() {
assert_eq!(validate_topup(100, -10, 1000), Err("Top-up amount must be positive"));
}

#[test]
fn test_exact_maximum_boundary() {
assert_eq!(validate_topup(900, 100, 1000), Ok(1000));
}

#[test]
fn test_one_above_maximum_rejected() {
assert_eq!(validate_topup(900, 101, 1000), Err("Balance would exceed maximum allowed"));
}

#[test]
fn test_overflow_guard() {
assert_eq!(validate_topup(i128::MAX, 1, i128::MAX), Err("Balance would exceed maximum allowed"));
}

#[test]
fn test_zero_current_balance() {
assert_eq!(validate_topup(0, 500, 1000), Ok(500));
}
}
4 changes: 4 additions & 0 deletions contracts/cntr/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
pub mod credit_topup;
pub mod multi_booking;
pub mod payment_validator;
pub mod role_checker;
61 changes: 61 additions & 0 deletions contracts/cntr/src/multi_booking.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/// Returns the maximum number of simultaneous active bookings allowed for a tier.
/// Unknown tier strings default to the Bronze limit (2).
pub fn get_booking_limit(tier: &str) -> u32 {
match tier {
"Bronze" => 2,
"Silver" => 5,
"Gold" => 10,
"Platinum" => u32::MAX,
_ => 2,
}
}

/// Returns true if the member can make another booking given their current active bookings.
pub fn can_make_booking(tier: &str, current_active_bookings: u32) -> bool {
current_active_bookings < get_booking_limit(tier)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_bronze_limit() {
assert_eq!(get_booking_limit("Bronze"), 2);
}

#[test]
fn test_silver_limit() {
assert_eq!(get_booking_limit("Silver"), 5);
}

#[test]
fn test_gold_limit() {
assert_eq!(get_booking_limit("Gold"), 10);
}

#[test]
fn test_platinum_limit() {
assert_eq!(get_booking_limit("Platinum"), u32::MAX);
}

#[test]
fn test_unknown_tier_defaults_to_bronze() {
assert_eq!(get_booking_limit("Diamond"), 2);
}

#[test]
fn test_can_make_booking_below_limit() {
assert!(can_make_booking("Bronze", 1));
}

#[test]
fn test_cannot_make_booking_at_limit() {
assert!(!can_make_booking("Bronze", 2));
}

#[test]
fn test_cannot_make_booking_above_limit() {
assert!(!can_make_booking("Silver", 6));
}
}
58 changes: 58 additions & 0 deletions contracts/cntr/src/payment_validator.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/// Validates that a received payment amount is within tolerance of the expected amount.
///
/// Returns `Ok(())` when `received_stroops >= expected_stroops - tolerance_stroops`.
pub fn validate_payment_amount(
received_stroops: i128,
expected_stroops: i128,
tolerance_stroops: i128,
) -> Result<(), &'static str> {
if expected_stroops <= 0 {
return Err("Expected amount must be positive");
}
let threshold = expected_stroops.saturating_sub(tolerance_stroops);
if received_stroops >= threshold {
Ok(())
} else {
Err("Payment amount insufficient")
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_exact_amount() {
assert_eq!(validate_payment_amount(1000, 1000, 0), Ok(()));
}

#[test]
fn test_within_tolerance() {
assert_eq!(validate_payment_amount(990, 1000, 10), Ok(()));
}

#[test]
fn test_one_stroop_below_tolerance() {
assert_eq!(validate_payment_amount(989, 1000, 10), Err("Payment amount insufficient"));
}

#[test]
fn test_overpayment_accepted() {
assert_eq!(validate_payment_amount(1500, 1000, 0), Ok(()));
}

#[test]
fn test_zero_expected_rejected() {
assert_eq!(validate_payment_amount(0, 0, 0), Err("Expected amount must be positive"));
}

#[test]
fn test_negative_expected_rejected() {
assert_eq!(validate_payment_amount(100, -1, 0), Err("Expected amount must be positive"));
}

#[test]
fn test_underpayment_no_tolerance() {
assert_eq!(validate_payment_amount(999, 1000, 0), Err("Payment amount insufficient"));
}
}
41 changes: 41 additions & 0 deletions contracts/cntr/src/role_checker.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
use std::collections::HashMap;

/// Simple in-memory role registry for access control.
#[derive(Default)]
pub struct RoleRegistry {
roles: HashMap<String, Vec<String>>,
}

impl RoleRegistry {
pub fn new() -> Self {
Self::default()
}

/// Grants a role to an address. Idempotent — duplicate grants are ignored.
pub fn grant_role(&mut self, address: &str, role: &str) {
let roles = self.roles.entry(address.to_string()).or_default();
if !roles.contains(&role.to_string()) {
roles.push(role.to_string());
}
}

/// Revokes a role from an address. No-op if the role is not held.
pub fn revoke_role(&mut self, address: &str, role: &str) {
if let Some(roles) = self.roles.get_mut(address) {
roles.retain(|r| r != role);
}
}

/// Returns true if the address holds the given role.
pub fn has_role(&self, address: &str, role: &str) -> bool {
self.roles
.get(address)
.map(|roles| roles.contains(&role.to_string()))
.unwrap_or(false)
}

/// Returns all roles held by an address.
pub fn get_roles(&self, address: &str) -> Vec<String> {
self.roles.get(address).cloned().unwrap_or_default()
}
}
98 changes: 98 additions & 0 deletions contracts/cntr/tests/access_control_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
use cntr::role_checker::RoleRegistry;

#[test]
fn test_grant_role_to_new_address() {
let mut reg = RoleRegistry::new();
reg.grant_role("alice", "Admin");
assert!(reg.has_role("alice", "Admin"));
}

#[test]
fn test_grant_same_role_twice_is_idempotent() {
let mut reg = RoleRegistry::new();
reg.grant_role("alice", "Admin");
reg.grant_role("alice", "Admin");
let roles = reg.get_roles("alice");
assert_eq!(roles.iter().filter(|r| *r == "Admin").count(), 1);
}

#[test]
fn test_grant_two_different_roles_to_same_address() {
let mut reg = RoleRegistry::new();
reg.grant_role("alice", "Admin");
reg.grant_role("alice", "Member");
assert!(reg.has_role("alice", "Admin"));
assert!(reg.has_role("alice", "Member"));
}

#[test]
fn test_revoke_existing_role() {
let mut reg = RoleRegistry::new();
reg.grant_role("alice", "Admin");
reg.revoke_role("alice", "Admin");
assert!(!reg.has_role("alice", "Admin"));
}

#[test]
fn test_revoke_nonexistent_role_does_not_panic() {
let mut reg = RoleRegistry::new();
reg.revoke_role("alice", "Admin"); // should not panic
}

#[test]
fn test_has_role_for_unknown_address_returns_false() {
let reg = RoleRegistry::new();
assert!(!reg.has_role("unknown", "Admin"));
}

#[test]
fn test_get_all_roles_for_multi_role_holder() {
let mut reg = RoleRegistry::new();
reg.grant_role("bob", "Admin");
reg.grant_role("bob", "Member");
reg.grant_role("bob", "Auditor");
let roles = reg.get_roles("bob");
assert_eq!(roles.len(), 3);
assert!(roles.contains(&"Admin".to_string()));
assert!(roles.contains(&"Member".to_string()));
assert!(roles.contains(&"Auditor".to_string()));
}

#[test]
fn test_has_role_with_empty_registry() {
let reg = RoleRegistry::new();
assert!(!reg.has_role("alice", "Member"));
}

#[test]
fn test_revoke_one_role_leaves_others_intact() {
let mut reg = RoleRegistry::new();
reg.grant_role("carol", "Admin");
reg.grant_role("carol", "Member");
reg.revoke_role("carol", "Admin");
assert!(!reg.has_role("carol", "Admin"));
assert!(reg.has_role("carol", "Member"));
}

#[test]
fn test_multiple_addresses_independent() {
let mut reg = RoleRegistry::new();
reg.grant_role("alice", "Admin");
reg.grant_role("bob", "Member");
assert!(reg.has_role("alice", "Admin"));
assert!(!reg.has_role("alice", "Member"));
assert!(reg.has_role("bob", "Member"));
assert!(!reg.has_role("bob", "Admin"));
}

#[test]
fn test_get_roles_returns_empty_for_unknown_address() {
let reg = RoleRegistry::new();
assert!(reg.get_roles("nobody").is_empty());
}

#[test]
fn test_revoke_from_unknown_address_does_not_panic() {
let mut reg = RoleRegistry::new();
reg.revoke_role("ghost", "Admin"); // should not panic
}
Loading