From a522574e1a3a95bcfd749da166b05791ac28e3a5 Mon Sep 17 00:00:00 2001 From: Quanwritescodes Date: Fri, 28 Aug 2026 14:41:33 +0100 Subject: [PATCH 01/10] feat(registry): extract storage.rs module (issue #1243) --- .../contracts/registry/src/storage.rs | 415 ++++++++++++++++++ 1 file changed, 415 insertions(+) create mode 100644 packages/contracts/contracts/registry/src/storage.rs diff --git a/packages/contracts/contracts/registry/src/storage.rs b/packages/contracts/contracts/registry/src/storage.rs new file mode 100644 index 00000000..ce20ea57 --- /dev/null +++ b/packages/contracts/contracts/registry/src/storage.rs @@ -0,0 +1,415 @@ +//! # Registry Contract — Storage Layer +//! +//! All persisted types, storage key definitions, constants, and pure +//! storage-accessor helpers live here. No business logic, no +//! `require_auth` calls — those belong in `logic.rs` and `lib.rs`. + +use soroban_sdk::{contracttype, Address, BytesN, Env, String, Symbol, Vec}; + +// ============================================================================= +// Constants +// ============================================================================= + +/// Event schema version — bump when adding/removing/renaming events. +pub const VERSION: u32 = 1; + +/// Approximate TTL extension target (~1 year at 5 s/ledger). +pub const TTL_EXTEND_TO: u32 = 535_000; + +/// Extend TTL only when it drops below this threshold (~6 months). +pub const TTL_THRESHOLD: u32 = 267_500; + +/// Cached role strings used for gas-efficient Symbol creation. +pub const ROLE_ADMIN_CACHED: &str = "admin"; +pub const ROLE_PAUSER_CACHED: &str = "pauser"; +pub const ROLE_CURATOR_MGR_CACHED: &str = "curator_mgr"; +pub const ROLE_REP_MGR_CACHED: &str = "rep_mgr"; +pub const ROLE_UPGRADER_CACHED: &str = "upgrader"; + +/// Role IDs for compact storage key optimisation. +pub const ROLE_ADMIN_ID: u64 = 0; +pub const ROLE_PAUSER_ID: u64 = 1; +pub const ROLE_CURATOR_MGR_ID: u64 = 2; +pub const ROLE_REP_MGR_ID: u64 = 3; +pub const ROLE_UPGRADER_ID: u64 = 4; + +/// Full admin — can grant/revoke any role and call all privileged functions. +pub const ROLE_ADMIN: &str = "admin"; +/// May pause and unpause the contract. +pub const ROLE_PAUSER: &str = "pauser"; +/// May add and remove curators. +pub const ROLE_CURATOR_MGR: &str = "curator_mgr"; +/// May update worker reputation scores. +pub const ROLE_REP_MGR: &str = "rep_mgr"; +/// May upgrade the contract WASM. +pub const ROLE_UPGRADER: &str = "upgrader"; + +// ============================================================================= +// Types +// ============================================================================= + +/// Subscription tier for a worker. +#[contracttype] +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum SubscriptionTier { + /// Free tier — no subscription. + Free = 0, + /// Basic tier — standard visibility. + Basic = 1, + /// Premium tier — enhanced visibility and features. + Premium = 2, +} + +/// Worker subscription information. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct WorkerSubscription { + pub tier: SubscriptionTier, + /// Unix timestamp when subscription expires (0 = never expires). + pub expires_at: u64, + pub last_renewed_at: u64, +} + +/// On-chain worker profile stored in persistent contract storage. +/// +/// `location_hash` and `contact_hash` are SHA-256 digests — raw PII is never +/// stored on-chain. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct Worker { + /// Unique worker identifier (matches the off-chain database id). + pub id: Symbol, + pub owner: Address, + pub name: String, + pub category: Symbol, + pub is_active: bool, + pub wallet: Address, + /// SHA-256( lowercase(city) + ":" + lowercase(country_iso2) ) + pub location_hash: BytesN<32>, + /// SHA-256( lowercase(email_or_e164_phone) ) + pub contact_hash: BytesN<32>, + /// Reputation score in basis points (0–10000, where 10000 = 100.00%). + pub reputation: u32, + pub verified_categories: Vec, + pub staked_amount: i128, + pub review_count: u32, + /// Average rating in basis points (0–10000). + pub avg_rating: u32, + pub subscription: WorkerSubscription, +} + +/// Delegate record for worker profile management. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct Delegate { + pub address: Address, + /// Unix timestamp when delegation expires (0 = no expiry). + pub expires_at: u64, +} + +/// Performance metrics for a worker. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct PerformanceMetrics { + pub jobs_completed: u32, + pub avg_rating: u32, + pub total_ratings: u32, + pub last_updated: u64, + pub performance_score: u32, +} + +/// On-chain record of a curator verifying a worker's category. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct CategoryVerification { + pub category: Symbol, + pub curator: Address, + pub expires_at: u64, +} + +/// Location verification record for a worker. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct LocationVerification { + pub verifier: Address, + pub verified_at: u64, + pub expires_at: u64, +} + +/// Worker availability status. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct AvailabilityStatus { + pub is_available: bool, + pub updated_at: u64, + pub expires_at: u64, +} + +/// Staking record for a worker. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct StakeInfo { + pub token: Address, + pub amount: i128, + /// Ledger timestamp when unstake was requested (0 = no pending unstake). + pub unstake_requested_at: u64, + pub rewards_accumulated: i128, + pub last_reward_ledger: u64, +} + +/// Badge awarded to a worker for achievements. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct Badge { + pub id: Symbol, + pub name: String, + pub issuer: Address, + pub awarded_at: u64, + pub expires_at: u64, + pub active: bool, +} + +/// Verification level for a worker. +#[contracttype] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum VerificationLevel { + /// No verification — default state. + None = 0, + /// Identity checked by a curator. + Basic = 1, + /// Credentials and category skills verified. + Verified = 2, + /// Expert-level — multiple verified credentials and peer reviews. + Expert = 3, +} + +/// A certified skill entry for a worker. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct CertifiedSkill { + pub skill: Symbol, + pub certified_by: Address, + pub certified_at: u64, + pub expires_at: u64, +} + +/// A single immutable reputation history entry. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct ReputationEvent { + pub previous_score: u32, + pub new_score: u32, + pub reason: Symbol, + pub timestamp: u64, +} + +/// Aggregated inputs used to compute the weighted reputation score. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct ReputationInputs { + /// Total tips/payments received (used as job-completion proxy). + pub tip_count: u32, + /// Running sum of review ratings (basis points). + pub rating_sum: u64, + pub rating_count: u32, + pub last_review_at: u64, +} + +/// Result of a single registration attempt in `batch_register`. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct BatchRegisterResult { + pub id: Symbol, + pub success: bool, +} + +/// Paginated result for `list_workers_page`. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct WorkerPage { + pub ids: Vec, + pub total: u32, +} + +/// Pending upgrade record for the timelock mechanism. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct PendingUpgrade { + pub wasm_hash: BytesN<32>, + pub execute_after_ledger: u32, +} + +// ============================================================================= +// Storage Keys +// ============================================================================= + +/// Storage keys used throughout the contract. +#[contracttype] +pub enum DataKey { + /// Instance storage — bootstrap admin address, set once at `initialize`. + Admin, + /// Instance storage — paused flag; when `true` all state-mutating functions revert. + Paused, + /// Persistent storage — `Vec
` of members for a given role. + RoleMembers(u64), + /// Persistent storage — ordered list of approved curator `Address`es. + Curators, + /// Persistent storage — `Worker` record keyed by its `id`. + Worker(Symbol), + /// Persistent storage — ordered list of all registered worker id `Symbol`s. + WorkerList, + /// Persistent storage — `CategoryVerification` keyed by `(worker_id, category)`. + CategoryVerification(Symbol, Symbol), + /// Persistent storage — `StakeInfo` keyed by worker id. + StakeInfo(Symbol), + /// Persistent storage — `PerformanceMetrics` keyed by worker id. + PerformanceMetrics(Symbol), + /// Persistent storage — list of delegate addresses for a worker. + Delegates(Symbol), + /// Persistent storage — list of badges for a worker. + WorkerBadges(Symbol), + /// Persistent storage — individual badge keyed by (worker_id, badge_id). + Badge(Symbol, Symbol), + /// Persistent storage — `WorkerSubscription` keyed by worker id. + Subscription(Symbol), + /// Persistent storage — current storage schema version (u32). + SchemaVersion, + /// Persistent storage — `LocationVerification` keyed by worker id. + LocationVerification(Symbol), + /// Persistent storage — `AvailabilityStatus` keyed by worker id. + AvailabilityStatus(Symbol), + /// Persistent storage — `Vec` of valid on-chain categories. + Categories, + /// Persistent storage — total worker count (u32) for efficient pagination. + WorkerCount, + /// Persistent storage — pending upgrade record for the timelock mechanism. + PendingUpgrade, + /// Persistent storage — `Vec` history keyed by worker id. + ReputationHistory(Symbol), + /// Persistent storage — `ReputationInputs` keyed by worker id. + ReputationInputs(Symbol), + /// Persistent storage — `VerificationLevel` keyed by worker id. + VerificationLevel(Symbol), + /// Persistent storage — `Vec` keyed by worker id. + CertifiedSkills(Symbol), +} + +// ============================================================================= +// Storage Accessors +// ============================================================================= + +/// Return the member list for a role by its compact u64 id, or an empty vec. +pub fn get_role_members(env: &Env, role_id: u64) -> Vec
{ + env.storage() + .persistent() + .get(&DataKey::RoleMembers(role_id)) + .unwrap_or(Vec::new(env)) +} + +/// Persist an updated member list for a role. +pub fn set_role_members(env: &Env, role_id: u64, members: &Vec
) { + env.storage() + .persistent() + .set(&DataKey::RoleMembers(role_id), members); +} + +/// Return the current curator list, or an empty vec. +pub fn get_curators(env: &Env) -> Vec
{ + env.storage() + .persistent() + .get(&DataKey::Curators) + .unwrap_or(Vec::new(env)) +} + +/// Persist the curator list. +pub fn set_curators(env: &Env, curators: &Vec
) { + env.storage() + .persistent() + .set(&DataKey::Curators, curators); +} + +/// Return the delegate list for a worker, or an empty vec. +pub fn get_delegates(env: &Env, worker_id: &Symbol) -> Vec { + env.storage() + .persistent() + .get(&DataKey::Delegates(worker_id.clone())) + .unwrap_or(Vec::new(env)) +} + +/// Persist the delegate list for a worker. +pub fn set_delegates(env: &Env, worker_id: &Symbol, delegates: &Vec) { + env.storage() + .persistent() + .set(&DataKey::Delegates(worker_id.clone()), delegates); +} + +/// Fetch a worker record by id. +pub fn get_worker(env: &Env, id: &Symbol) -> Option { + env.storage().persistent().get(&DataKey::Worker(id.clone())) +} + +/// Persist a worker record and extend its TTL. +pub fn set_worker(env: &Env, worker: &Worker) { + let key = DataKey::Worker(worker.id.clone()); + env.storage().persistent().set(&key, worker); + env.storage() + .persistent() + .extend_ttl(&key, TTL_THRESHOLD, TTL_EXTEND_TO); +} + +/// Return the full worker id list. +pub fn get_worker_list(env: &Env) -> Vec { + env.storage() + .persistent() + .get(&DataKey::WorkerList) + .unwrap_or(Vec::new(env)) +} + +/// Persist the worker id list and extend its TTL. +pub fn set_worker_list(env: &Env, list: &Vec) { + env.storage().persistent().set(&DataKey::WorkerList, list); + env.storage() + .persistent() + .extend_ttl(&DataKey::WorkerList, TTL_THRESHOLD, TTL_EXTEND_TO); +} + +/// Return the current worker count. +pub fn get_worker_count(env: &Env) -> u32 { + env.storage() + .persistent() + .get(&DataKey::WorkerCount) + .unwrap_or(0u32) +} + +/// Increment the worker count by 1. +pub fn increment_worker_count(env: &Env) { + let count = get_worker_count(env); + env.storage() + .persistent() + .set(&DataKey::WorkerCount, &(count + 1)); +} + +/// Decrement the worker count by 1 (saturates at 0). +pub fn decrement_worker_count(env: &Env) { + let count = get_worker_count(env); + if count > 0 { + env.storage() + .persistent() + .set(&DataKey::WorkerCount, &(count - 1)); + } +} + +/// Return the schema version (defaults to 1 if not set). +pub fn get_schema_version(env: &Env) -> u32 { + env.storage() + .persistent() + .get(&DataKey::SchemaVersion) + .unwrap_or(1u32) +} + +/// Persist the schema version. +pub fn set_schema_version(env: &Env, version: u32) { + env.storage() + .persistent() + .set(&DataKey::SchemaVersion, &version); +} From 938bfc7487a791b15a29ce544875b6eecd441755 Mon Sep 17 00:00:00 2001 From: Quanwritescodes Date: Fri, 28 Aug 2026 14:41:42 +0100 Subject: [PATCH 02/10] feat(registry): extract logic.rs module (issue #1243) --- .../contracts/contracts/registry/src/logic.rs | 227 ++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 packages/contracts/contracts/registry/src/logic.rs diff --git a/packages/contracts/contracts/registry/src/logic.rs b/packages/contracts/contracts/registry/src/logic.rs new file mode 100644 index 00000000..b141a71b --- /dev/null +++ b/packages/contracts/contracts/registry/src/logic.rs @@ -0,0 +1,227 @@ +//! # Registry Contract — Business Logic Layer +//! +//! Internal helpers: access-control checks, reputation computation, +//! performance scoring, and history bookkeeping. +//! +//! All functions are `pub(crate)` — they are only called from `lib.rs`. + +use bluecollar_types::{helpers, ContractError}; +use soroban_sdk::{Address, Env, Symbol, Vec}; + +use crate::storage::{ + get_delegates, get_role_members, DataKey, ReputationEvent, ReputationInputs, Worker, + ROLE_ADMIN_CACHED, ROLE_ADMIN_ID, ROLE_CURATOR_MGR_CACHED, ROLE_CURATOR_MGR_ID, + ROLE_PAUSER_CACHED, ROLE_PAUSER_ID, ROLE_REP_MGR_CACHED, ROLE_REP_MGR_ID, + ROLE_UPGRADER_CACHED, ROLE_UPGRADER_ID, +}; +use crate::storage::{PerformanceMetrics}; + +// ============================================================================= +// Role helpers +// ============================================================================= + +/// Build a role `Symbol` from a string literal (gas-optimisation helper). +pub(crate) fn role_symbol(env: &Env, role_str: &str) -> Symbol { + Symbol::new(env, role_str) +} + +/// Convert a role `Symbol` to its compact `u64` storage ID. +/// +/// Unknown roles map to `u64::MAX` so they get a distinct bucket without +/// colliding with the known role IDs. +pub(crate) fn role_to_id(env: &Env, role: &Symbol) -> u64 { + if *role == Symbol::new(env, ROLE_ADMIN_CACHED) { + ROLE_ADMIN_ID + } else if *role == Symbol::new(env, ROLE_PAUSER_CACHED) { + ROLE_PAUSER_ID + } else if *role == Symbol::new(env, ROLE_CURATOR_MGR_CACHED) { + ROLE_CURATOR_MGR_ID + } else if *role == Symbol::new(env, ROLE_REP_MGR_CACHED) { + ROLE_REP_MGR_ID + } else if *role == Symbol::new(env, ROLE_UPGRADER_CACHED) { + ROLE_UPGRADER_ID + } else { + u64::MAX + } +} + +/// Assert that `caller` holds `role` and has authorised this call. +pub(crate) fn require_role( + env: &Env, + role: &Symbol, + caller: &Address, +) -> Result<(), ContractError> { + let members = get_role_members(env, role_to_id(env, role)); + helpers::require_role(caller, &members) +} + +/// Assert that the contract is not paused. +pub(crate) fn require_not_paused(env: &Env) -> Result<(), ContractError> { + let paused: bool = env + .storage() + .instance() + .get(&DataKey::Paused) + .unwrap_or(false); + helpers::require_not_paused(paused) +} + +/// Assert that `caller` is either the worker's owner or an active (non-expired) delegate. +pub(crate) fn require_owner_or_delegate( + env: &Env, + worker: &Worker, + caller: &Address, +) -> Result<(), ContractError> { + if worker.owner == *caller { + return Ok(()); + } + let now = env.ledger().timestamp(); + let delegates = get_delegates(env, &worker.id); + let is_valid_delegate = delegates + .iter() + .any(|d| d.address == *caller && (d.expires_at == 0 || d.expires_at > now)); + if !is_valid_delegate { + return Err(ContractError::NotAuthorized); + } + Ok(()) +} + +// ============================================================================= +// Reputation computation +// ============================================================================= + +/// Weights (out of 100) for the three reputation factors. +const REP_WEIGHT_QUALITY: u32 = 60; +const REP_WEIGHT_VOLUME: u32 = 25; +const REP_WEIGHT_RECENCY: u32 = 15; + +/// Recency half-life in seconds (~90 days). +const RECENCY_HALF_LIFE_SECS: u64 = 7_776_000; + +/// Maximum tip count considered for volume score (caps at 10_000 bps). +const MAX_TIP_VOLUME: u32 = 50; + +/// Maximum history entries stored per worker. +pub(crate) const MAX_HISTORY_LEN: u32 = 100; + +/// Minimum average rating that triggers automatic slashing. +pub(crate) const SLASH_THRESHOLD_RATING: u32 = 3_000; +/// Minimum number of reviews before slashing can trigger. +pub(crate) const SLASH_MIN_REVIEWS: u32 = 3; + +/// Compute the weighted reputation score from [`ReputationInputs`]. +/// +/// Returns a score in basis points (0–10 000). +pub(crate) fn compute_weighted_reputation(inputs: &ReputationInputs, now: u64) -> u32 { + // quality component + let avg_rating = if inputs.rating_count == 0 { + 0u32 + } else { + (inputs.rating_sum / inputs.rating_count as u64) as u32 + }; + let quality = avg_rating + .checked_mul(REP_WEIGHT_QUALITY) + .expect("overflow") + / 100; + + // volume component (saturates at MAX_TIP_VOLUME) + let volume_fraction = inputs.tip_count.min(MAX_TIP_VOLUME); + let volume = (volume_fraction as u64) + .checked_mul(10_000) + .expect("overflow") + / MAX_TIP_VOLUME as u64; + let volume = (volume as u32) + .checked_mul(REP_WEIGHT_VOLUME) + .expect("overflow") + / 100; + + // recency component (linear approximation of exponential decay) + let recency = if inputs.last_review_at == 0 { + 0u32 + } else { + let elapsed = now.saturating_sub(inputs.last_review_at); + let decay_bps = if elapsed >= RECENCY_HALF_LIFE_SECS { + 0u32 + } else { + 10_000u32 + - ((elapsed as u64) + .checked_mul(10_000) + .expect("overflow") + / RECENCY_HALF_LIFE_SECS) as u32 + }; + decay_bps + .checked_mul(REP_WEIGHT_RECENCY) + .expect("overflow") + / 100 + }; + + quality + .checked_add(volume) + .expect("overflow") + .checked_add(recency) + .expect("overflow") + .min(10_000) +} + +/// Append an entry to the immutable reputation history (capped at `MAX_HISTORY_LEN`). +pub(crate) fn append_reputation_history( + env: &Env, + id: &Symbol, + previous: u32, + new_score: u32, + reason: Symbol, +) { + let mut history: Vec = env + .storage() + .persistent() + .get(&DataKey::ReputationHistory(id.clone())) + .unwrap_or(Vec::new(env)); + + // Drop oldest entry if at capacity. + if history.len() >= MAX_HISTORY_LEN { + let mut trimmed: Vec = Vec::new(env); + for i in 1..history.len() { + trimmed.push_back(history.get(i).unwrap()); + } + history = trimmed; + } + + history.push_back(ReputationEvent { + previous_score: previous, + new_score, + reason, + timestamp: env.ledger().timestamp(), + }); + + env.storage() + .persistent() + .set(&DataKey::ReputationHistory(id.clone()), &history); +} + +// ============================================================================= +// Performance scoring +// ============================================================================= + +/// Derive a performance score from the given metrics struct. +pub(crate) fn calculate_performance_score(metrics: &PerformanceMetrics) -> u32 { + if metrics.total_ratings == 0 { + return 0; + } + let rating_weight = 70u32; + let completion_weight = 30u32; + + let rating_score = metrics + .avg_rating + .checked_mul(rating_weight) + .expect("overflow") + / 100; + + let completion_score = metrics + .jobs_completed + .min(100) + .checked_mul(completion_weight) + .expect("overflow"); + + rating_score + .checked_add(completion_score) + .expect("overflow") +} From d452e9e6c266dbe635ed27e96e6ce23f3aee57cd Mon Sep 17 00:00:00 2001 From: Quanwritescodes Date: Fri, 28 Aug 2026 14:41:54 +0100 Subject: [PATCH 03/10] feat(registry): refactor lib.rs into thin entrypoint (issue #1243) --- .../contracts/contracts/registry/src/lib.rs | 1651 ++++------------- 1 file changed, 332 insertions(+), 1319 deletions(-) diff --git a/packages/contracts/contracts/registry/src/lib.rs b/packages/contracts/contracts/registry/src/lib.rs index 14dadad6..bb8b63a2 100644 --- a/packages/contracts/contracts/registry/src/lib.rs +++ b/packages/contracts/contracts/registry/src/lib.rs @@ -1,368 +1,48 @@ //! # BlueCollar Registry Contract //! -//! Deployed on Stellar (Soroban), this contract manages on-chain worker registrations -//! for the BlueCollar protocol. It provides a trustless, immutable record of worker -//! listings that can be verified by anyone on the network. +//! Deployed on Stellar (Soroban), this contract manages on-chain worker +//! registrations for the BlueCollar protocol. //! -//! ## Access Control -//! - **Admin**: Set once at [`initialize`]. Can add/remove curators and upgrade the contract. -//! - **Curators**: Approved addresses that may register workers on behalf of owners. -//! - **Owners**: The worker's on-chain owner address; may toggle, update, or deregister their own worker. +//! ## Module layout +//! - **`storage`** — all persisted types, `DataKey`, constants, and pure +//! storage-accessor helpers. +//! - **`logic`** — internal business-logic helpers (access-control, +//! reputation computation, performance scoring). +//! - **`lib`** (this file) — thin public entrypoint: the `#[contract]` struct +//! and its `#[contractimpl]` block, delegating to `storage` and `logic`. //! -//! ## Storage -//! - Instance storage: `Admin` key (set once). -//! - Persistent storage: `Curators` list, individual `Worker` entries, and `WorkerList` index. +//! ## Access Control +//! - **Admin**: Set once at [`initialize`]. Can add/remove curators and +//! upgrade the contract. +//! - **Curators**: Approved addresses that may register workers. +//! - **Owners**: The worker's on-chain owner address; may toggle, update, or +//! deregister their own worker. //! //! ## Privacy -//! Raw PII (location, contact details) is never stored on-chain. -//! Only SHA-256 digests are stored — see `location_hash` and `contact_hash` on [`Worker`]. +//! Raw PII is never stored on-chain. Only SHA-256 digests are stored. #![no_std] -use bluecollar_types::{helpers, ContractError}; +mod logic; +mod storage; + +use bluecollar_types::ContractError; use soroban_sdk::{ - contract, contractimpl, contracttype, symbol_short, token, Address, BytesN, Env, String, - Symbol, Vec, + contract, contractimpl, symbol_short, token, Address, BytesN, Env, String, Symbol, Vec, }; -/// Event schema version — bump when adding/removing/renaming events. -pub const VERSION: u32 = 1; - -/// Approximate TTL extension target (~1 year at 5 s/ledger). -const TTL_EXTEND_TO: u32 = 535_000; -/// Extend TTL only when it drops below this threshold (~6 months). -const TTL_THRESHOLD: u32 = 267_500; - -// ============================================================================= -// Gas Optimization Constants (#351) -// ============================================================================= - -/// Cached role symbols to reduce symbol creation overhead. -const ROLE_ADMIN_CACHED: &str = "admin"; -const ROLE_PAUSER_CACHED: &str = "pauser"; -const ROLE_CURATOR_MGR_CACHED: &str = "curator_mgr"; -const ROLE_REP_MGR_CACHED: &str = "rep_mgr"; -const ROLE_UPGRADER_CACHED: &str = "upgrader"; - -/// Role IDs for storage key optimization. -/// Maps role strings to compact u64 IDs for efficient storage. -const ROLE_ADMIN_ID: u64 = 0; -const ROLE_PAUSER_ID: u64 = 1; -const ROLE_CURATOR_MGR_ID: u64 = 2; -const ROLE_REP_MGR_ID: u64 = 3; -const ROLE_UPGRADER_ID: u64 = 4; - -// ============================================================================= -// Types -// ============================================================================= - -/// Subscription tier for a worker. -#[contracttype] -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum SubscriptionTier { - /// Free tier - no subscription. - Free = 0, - /// Basic tier - standard visibility. - Basic = 1, - /// Premium tier - enhanced visibility and features. - Premium = 2, -} - -/// Worker subscription information. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct WorkerSubscription { - /// Current subscription tier. - pub tier: SubscriptionTier, - /// Unix timestamp when subscription expires (0 = never expires). - pub expires_at: u64, - /// Unix timestamp of last renewal. - pub last_renewed_at: u64, -} - -/// On-chain worker profile stored in persistent contract storage. -/// -/// `location_hash` and `contact_hash` are SHA-256 digests — raw PII is never -/// stored on-chain. See README § Hashing Scheme for the exact input format. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct Worker { - /// Unique worker identifier (matches the off-chain database id). - pub id: Symbol, - /// Stellar address of the worker's owner account. - pub owner: Address, - /// Display name of the worker. - pub name: String, - /// Trade/skill category (e.g. `plumber`, `electrician`). - pub category: Symbol, - /// Whether the worker is currently accepting work. - pub is_active: bool, - /// Stellar wallet address used to receive tips/payments. - pub wallet: Address, - /// SHA-256( lowercase(city) + ":" + lowercase(country_iso2) ) - pub location_hash: BytesN<32>, - /// SHA-256( lowercase(email_or_e164_phone) ) - pub contact_hash: BytesN<32>, - /// Reputation score in basis points (0–10000, where 10000 = 100.00%). - /// Updated by the admin via [`RegistryContract::update_reputation`]. - pub reputation: u32, - /// On-chain verified categories for this worker (see [`CategoryVerification`]). - pub verified_categories: Vec, - /// Total tokens staked by this worker for visibility boost. - pub staked_amount: i128, - /// Total number of reviews received by this worker. - pub review_count: u32, - /// Average rating in basis points (0–10000, where 10000 = 100.00%). - pub avg_rating: u32, - /// Worker subscription status. - pub subscription: WorkerSubscription, -} - -/// Delegate record for worker profile management. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct Delegate { - /// Address granted delegation. - pub address: Address, - /// Unix timestamp when delegation expires (0 = no expiry). - pub expires_at: u64, -} - -/// Performance metrics for a worker (#378). -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct PerformanceMetrics { - /// Total number of jobs completed. - pub jobs_completed: u32, - /// Average rating (0-10000 basis points). - pub avg_rating: u32, - /// Total number of ratings received. - pub total_ratings: u32, - /// Last update timestamp. - pub last_updated: u64, - /// Performance score (calculated from metrics). - pub performance_score: u32, -} - -/// On-chain record of a curator verifying a worker's category. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct CategoryVerification { - /// The category that was verified. - pub category: Symbol, - /// Curator who performed the verification. - pub curator: Address, - /// Unix timestamp when this verification expires. - pub expires_at: u64, -} - -/// Location verification record for a worker. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct LocationVerification { - /// Verifier address. - pub verifier: Address, - /// Unix timestamp when verification was recorded. - pub verified_at: u64, - /// Unix timestamp when verification expires. - pub expires_at: u64, -} - -/// Worker availability status. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct AvailabilityStatus { - /// Whether worker is currently available. - pub is_available: bool, - /// Unix timestamp of last availability update. - pub updated_at: u64, - /// Unix timestamp when availability status expires (0 = no expiry). - pub expires_at: u64, -} - -/// Staking record for a worker. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct StakeInfo { - /// Token contract used for staking. - pub token: Address, - /// Total amount currently staked. - pub amount: i128, - /// Ledger timestamp when unstake was requested (0 = no pending unstake). - pub unstake_requested_at: u64, - /// Accumulated rewards in basis points of staked amount per ledger. - pub rewards_accumulated: i128, - /// Ledger timestamp of last reward calculation. - pub last_reward_ledger: u64, -} - -/// Badge awarded to a worker for achievements (#380). -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct Badge { - /// Badge identifier. - pub id: Symbol, - /// Badge name/title. - pub name: String, - /// Issuer address (admin or curator). - pub issuer: Address, - /// Timestamp when badge was awarded. - pub awarded_at: u64, - /// Expiry timestamp (0 = no expiry). - pub expires_at: u64, - /// Whether badge is currently active. - pub active: bool, -} - -/// Verification level for a worker (#778). -#[contracttype] -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum VerificationLevel { - /// No verification — default state. - None = 0, - /// Identity checked by a curator. - Basic = 1, - /// Credentials and category skills verified. - Verified = 2, - /// Expert-level — multiple verified credentials and peer reviews. - Expert = 3, -} - -/// A certified skill entry for a worker (#778). -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct CertifiedSkill { - /// Skill identifier (e.g., "pipe_fitting", "arc_welding"). - pub skill: Symbol, - /// Curator or admin who certified this skill. - pub certified_by: Address, - /// Unix timestamp when certification was granted. - pub certified_at: u64, - /// Unix timestamp when certification expires (0 = no expiry). - pub expires_at: u64, -} - -/// A single immutable reputation history entry (#677). -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct ReputationEvent { - /// Previous reputation score. - pub previous_score: u32, - /// New reputation score after this event. - pub new_score: u32, - /// Human-readable reason (e.g. "review", "slash", "job_completion"). - pub reason: Symbol, - /// Ledger timestamp when this event was recorded. - pub timestamp: u64, -} - -/// Aggregated inputs used to compute the weighted reputation score (#677). -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct ReputationInputs { - /// Total tips/payments received (used as job-completion proxy). - pub tip_count: u32, - /// Running sum of review ratings (basis points) for weighted-average calc. - pub rating_sum: u64, - /// Total number of ratings submitted. - pub rating_count: u32, - /// Timestamp of the most recent review (for recency decay). - pub last_review_at: u64, -} - -/// Result of a single registration attempt in [`RegistryContract::batch_register`]. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct BatchRegisterResult { - pub id: Symbol, - pub success: bool, -} - -/// Paginated result for [`RegistryContract::list_workers_page`]. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct WorkerPage { - /// Worker ids in this page. - pub ids: Vec, - /// Total number of registered workers. - pub total: u32, -} - -/// Pending upgrade record for the timelock mechanism. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct PendingUpgrade { - /// New WASM hash to apply. - pub wasm_hash: BytesN<32>, - /// Ledger sequence number after which the upgrade may be executed. - pub execute_after_ledger: u32, -} - -// ============================================================================= -// Roles -// ============================================================================= +pub use storage::{ + AvailabilityStatus, Badge, BatchRegisterResult, CategoryVerification, CertifiedSkill, + DataKey, Delegate, LocationVerification, PerformanceMetrics, PendingUpgrade, + ReputationEvent, ReputationInputs, StakeInfo, SubscriptionTier, TTL_EXTEND_TO, + TTL_THRESHOLD, VerificationLevel, Worker, WorkerPage, WorkerSubscription, +}; +pub use storage::{ + ROLE_ADMIN, ROLE_ADMIN_CACHED, ROLE_CURATOR_MGR, ROLE_CURATOR_MGR_CACHED, ROLE_PAUSER, + ROLE_PAUSER_CACHED, ROLE_REP_MGR, ROLE_REP_MGR_CACHED, ROLE_UPGRADER, ROLE_UPGRADER_CACHED, +}; -/// Full admin — can grant/revoke any role and call all privileged functions. -pub const ROLE_ADMIN: &str = "admin"; -/// May pause and unpause the contract. -pub const ROLE_PAUSER: &str = "pauser"; -/// May add and remove curators. -pub const ROLE_CURATOR_MGR: &str = "curator_mgr"; -/// May update worker reputation scores. -pub const ROLE_REP_MGR: &str = "rep_mgr"; -/// May upgrade the contract WASM. -pub const ROLE_UPGRADER: &str = "upgrader"; - -/// Storage keys used throughout the contract. -#[contracttype] -pub enum DataKey { - /// Instance storage — bootstrap admin address, set once at [`RegistryContract::initialize`]. - Admin, - /// Instance storage — paused flag; when `true` all state-mutating functions revert. - Paused, - /// Persistent storage — `Vec
` of members for a given role. - RoleMembers(u64), - /// Persistent storage — ordered list of approved curator [`Address`]es. - Curators, - /// Persistent storage — [`Worker`] record keyed by its `id` [`Symbol`]. - Worker(Symbol), - /// Persistent storage — ordered list of all registered worker id [`Symbol`]s. - WorkerList, - /// Persistent storage — [`CategoryVerification`] keyed by `(worker_id, category)`. - CategoryVerification(Symbol, Symbol), - /// Persistent storage — [`StakeInfo`] keyed by worker id. - StakeInfo(Symbol), - /// Persistent storage — [`PerformanceMetrics`] keyed by worker id. - PerformanceMetrics(Symbol), - /// Persistent storage — list of delegate addresses for a worker. - Delegates(Symbol), - /// Persistent storage — list of badges for a worker. - WorkerBadges(Symbol), - /// Persistent storage — individual badge keyed by (worker_id, badge_id). - Badge(Symbol, Symbol), - /// Persistent storage — [`WorkerSubscription`] keyed by worker id. - Subscription(Symbol), - /// Persistent storage — current storage schema version (u32), used by [`migrate`]. - SchemaVersion, - /// Persistent storage — [`LocationVerification`] keyed by worker id. - LocationVerification(Symbol), - /// Persistent storage — [`AvailabilityStatus`] keyed by worker id. - AvailabilityStatus(Symbol), - /// Persistent storage — `Vec` of valid on-chain categories. - Categories, - /// Persistent storage — total worker count (u32) for efficient pagination. - WorkerCount, - /// Persistent storage — pending upgrade record for the timelock mechanism. - PendingUpgrade, - /// Persistent storage — `Vec` history keyed by worker id (#677). - ReputationHistory(Symbol), - /// Persistent storage — [`ReputationInputs`] keyed by worker id (#677). - ReputationInputs(Symbol), - /// Persistent storage — [`VerificationLevel`] keyed by worker id (#778). - VerificationLevel(Symbol), - /// Persistent storage — `Vec` keyed by worker id (#778). - CertifiedSkills(Symbol), -} +pub use storage::VERSION; // ============================================================================= // Contract @@ -384,103 +64,21 @@ impl RegistryContract { if env.storage().persistent().has(&DataKey::Admin) { return Err(ContractError::AlreadyInitialized); } - // Store admin in persistent storage env.storage().persistent().set(&DataKey::Admin, &admin); - // Set initial schema version - env.storage() - .persistent() - .set(&DataKey::SchemaVersion, &1u32); + storage::set_schema_version(&env, 1u32); + // Bootstrap: grant ROLE_ADMIN to the initial admin. - let role = Symbol::new(&env, ROLE_ADMIN); + let role = Symbol::new(&env, ROLE_ADMIN_CACHED); + let role_id = logic::role_to_id(&env, &role); let mut members: Vec
= Vec::new(&env); members.push_back(admin.clone()); - env.storage().persistent().set( - &DataKey::RoleMembers(Self::role_to_id_with_env(&env, &role)), - &members, - ); + storage::set_role_members(&env, role_id, &members); + env.events() .publish((symbol_short!("RlGrnt"), role, admin), ()); Ok(()) } - // ------------------------------------------------------------------------- - // Internal helpers - // ------------------------------------------------------------------------- - - /// Return the member list for a role, or empty vec if no members exist. - fn get_role_members(env: &Env, role: &Symbol) -> Vec
{ - env.storage() - .persistent() - .get(&DataKey::RoleMembers(Self::role_to_id_with_env(&env, role))) - .unwrap_or(Vec::new(env)) - } - - /// Create a role symbol efficiently (gas optimization #351). - fn role_symbol(env: &Env, role_str: &str) -> Symbol { - Symbol::new(env, role_str) - } - - /// Convert a role symbol to its compact u64 ID for storage optimization. - fn role_to_id_with_env(env: &Env, role: &Symbol) -> u64 { - if *role == Symbol::new(env, ROLE_ADMIN_CACHED) { - ROLE_ADMIN_ID - } else if *role == Symbol::new(env, ROLE_PAUSER_CACHED) { - ROLE_PAUSER_ID - } else if *role == Symbol::new(env, ROLE_CURATOR_MGR_CACHED) { - ROLE_CURATOR_MGR_ID - } else if *role == Symbol::new(env, ROLE_REP_MGR_CACHED) { - ROLE_REP_MGR_ID - } else if *role == Symbol::new(env, ROLE_UPGRADER_CACHED) { - ROLE_UPGRADER_ID - } else { - u64::MAX - } - } - - /// Assert that `caller` holds `role` and has authorised this call. - fn require_role(env: &Env, role: &Symbol, caller: &Address) -> Result<(), ContractError> { - let members = Self::get_role_members(env, role); - helpers::require_role(caller, &members) - } - - /// Assert that the contract is not paused. - fn require_not_paused(env: &Env) -> Result<(), ContractError> { - let paused: bool = env - .storage() - .instance() - .get(&DataKey::Paused) - .unwrap_or(false); - helpers::require_not_paused(paused) - } - - /// Return the delegate list for a worker, or empty vec if none exist. - fn get_delegates(env: &Env, worker_id: &Symbol) -> Vec { - env.storage() - .persistent() - .get(&DataKey::Delegates(worker_id.clone())) - .unwrap_or(Vec::new(env)) - } - - /// Assert that `caller` is either the worker's owner or an active (non-expired) delegate. - fn require_owner_or_delegate( - env: &Env, - worker: &Worker, - caller: &Address, - ) -> Result<(), ContractError> { - if worker.owner == *caller { - return Ok(()); - } - let now = env.ledger().timestamp(); - let delegates = Self::get_delegates(env, &worker.id); - let is_valid_delegate = delegates - .iter() - .any(|d| d.address == *caller && (d.expires_at == 0 || d.expires_at > now)); - if !is_valid_delegate { - return Err(ContractError::NotAuthorized); - } - Ok(()) - } - // ------------------------------------------------------------------------- // Role management (ROLE_ADMIN only) // ------------------------------------------------------------------------- @@ -492,19 +90,16 @@ impl RegistryContract { role: Symbol, account: Address, ) -> Result<(), ContractError> { - let admin_role = Self::role_symbol(&env, ROLE_ADMIN_CACHED); - Self::require_role(&env, &admin_role, &caller)?; - Self::require_not_paused(&env)?; + let admin_role = logic::role_symbol(&env, ROLE_ADMIN_CACHED); + logic::require_role(&env, &admin_role, &caller)?; + logic::require_not_paused(&env)?; - let mut members = Self::get_role_members(&env, &role); + let role_id = logic::role_to_id(&env, &role); + let mut members = storage::get_role_members(&env, role_id); if members.iter().all(|m| m != account) { members.push_back(account.clone()); - env.storage().persistent().set( - &DataKey::RoleMembers(Self::role_to_id_with_env(&env, &role)), - &members, - ); + storage::set_role_members(&env, role_id, &members); } - env.events() .publish((symbol_short!("RlGrnt"), role, account), ()); Ok(()) @@ -517,11 +112,12 @@ impl RegistryContract { role: Symbol, account: Address, ) -> Result<(), ContractError> { - let admin_role = Self::role_symbol(&env, ROLE_ADMIN_CACHED); - Self::require_role(&env, &admin_role, &caller)?; - Self::require_not_paused(&env)?; + let admin_role = logic::role_symbol(&env, ROLE_ADMIN_CACHED); + logic::require_role(&env, &admin_role, &caller)?; + logic::require_not_paused(&env)?; - let members = Self::get_role_members(&env, &role); + let role_id = logic::role_to_id(&env, &role); + let members = storage::get_role_members(&env, role_id); let mut updated: Vec
= Vec::new(&env); let mut found = false; for m in members.iter() { @@ -534,11 +130,7 @@ impl RegistryContract { if !found { return Err(ContractError::AccountDoesNotHoldRole); } - env.storage().persistent().set( - &DataKey::RoleMembers(Self::role_to_id_with_env(&env, &role)), - &updated, - ); - + storage::set_role_members(&env, role_id, &updated); env.events() .publish((symbol_short!("RlRvkd"), role, account), ()); Ok(()) @@ -546,14 +138,19 @@ impl RegistryContract { /// Returns `true` if `account` holds `role`. pub fn has_role(env: Env, role: Symbol, account: Address) -> Result { - Ok(Self::get_role_members(&env, &role) + let role_id = logic::role_to_id(&env, &role); + Ok(storage::get_role_members(&env, role_id) .iter() .any(|m| m == account)) } /// Return all members of a role. - pub fn get_role_members_list(env: Env, role: Symbol) -> Result, ContractError> { - Ok(Self::get_role_members(&env, &role)) + pub fn get_role_members_list( + env: Env, + role: Symbol, + ) -> Result, ContractError> { + let role_id = logic::role_to_id(&env, &role); + Ok(storage::get_role_members(&env, role_id)) } // ------------------------------------------------------------------------- @@ -569,8 +166,7 @@ impl RegistryContract { expires_at: u64, ) -> Result<(), ContractError> { owner.require_auth(); - Self::require_not_paused(&env)?; - + logic::require_not_paused(&env)?; let worker: Worker = env .storage() .persistent() @@ -579,10 +175,7 @@ impl RegistryContract { if worker.owner != owner { return Err(ContractError::NotAuthorized); } - - let mut delegates = Self::get_delegates(&env, &id); - - // Update expiry if delegate already exists, otherwise push. + let mut delegates = storage::get_delegates(&env, &id); let mut found = false; for i in 0..delegates.len() { let mut d = delegates.get(i).unwrap(); @@ -599,11 +192,7 @@ impl RegistryContract { expires_at, }); } - - env.storage() - .persistent() - .set(&DataKey::Delegates(id.clone()), &delegates); - + storage::set_delegates(&env, &id, &delegates); env.events() .publish((symbol_short!("DlgAdd"), id, delegate), expires_at); Ok(()) @@ -617,8 +206,7 @@ impl RegistryContract { delegate: Address, ) -> Result<(), ContractError> { owner.require_auth(); - Self::require_not_paused(&env)?; - + logic::require_not_paused(&env)?; let worker: Worker = env .storage() .persistent() @@ -627,8 +215,7 @@ impl RegistryContract { if worker.owner != owner { return Err(ContractError::NotAuthorized); } - - let delegates = Self::get_delegates(&env, &id); + let delegates = storage::get_delegates(&env, &id); let mut updated: Vec = Vec::new(&env); let mut removed = false; for d in delegates.iter() { @@ -641,38 +228,37 @@ impl RegistryContract { if !removed { return Err(ContractError::DelegateNotFound); } - - env.storage() - .persistent() - .set(&DataKey::Delegates(id.clone()), &updated); - + storage::set_delegates(&env, &id, &updated); env.events() .publish((symbol_short!("DlgRem"), id, delegate), ()); Ok(()) } /// Get all delegates for a worker. - pub fn get_worker_delegates(env: Env, id: Symbol) -> Result, ContractError> { - Ok(Self::get_delegates(&env, &id)) + pub fn get_worker_delegates( + env: Env, + id: Symbol, + ) -> Result, ContractError> { + Ok(storage::get_delegates(&env, &id)) } // ------------------------------------------------------------------------- - // Pause / Unpause (admin only) + // Pause / Unpause // ------------------------------------------------------------------------- /// Pause the contract, blocking all state-mutating operations. pub fn pause(env: Env, admin: Address) -> Result<(), ContractError> { - let pauser_role = Self::role_symbol(&env, ROLE_PAUSER_CACHED); - Self::require_role(&env, &pauser_role, &admin)?; + let pauser_role = logic::role_symbol(&env, ROLE_PAUSER_CACHED); + logic::require_role(&env, &pauser_role, &admin)?; env.storage().instance().set(&DataKey::Paused, &true); env.events().publish((symbol_short!("Paused"), admin), ()); Ok(()) } - /// Unpause the contract, re-enabling all state-mutating operations. + /// Unpause the contract. pub fn unpause(env: Env, admin: Address) -> Result<(), ContractError> { - let pauser_role = Self::role_symbol(&env, ROLE_PAUSER_CACHED); - Self::require_role(&env, &pauser_role, &admin)?; + let pauser_role = logic::role_symbol(&env, ROLE_PAUSER_CACHED); + logic::require_role(&env, &pauser_role, &admin)?; env.storage().instance().set(&DataKey::Paused, &false); env.events().publish((symbol_short!("Unpaused"), admin), ()); Ok(()) @@ -687,52 +273,46 @@ impl RegistryContract { .unwrap_or(false)) } - /// Return the current curator list, or an empty vec if none have been added yet. - fn get_curators(env: &Env) -> Vec
{ - env.storage() - .persistent() - .get(&DataKey::Curators) - .unwrap_or(Vec::new(env)) - } - // ------------------------------------------------------------------------- // Curator management // ------------------------------------------------------------------------- - /// Add a curator (admin only). Idempotent — adding an existing curator is a no-op. - pub fn add_curator(env: Env, admin: Address, curator: Address) -> Result<(), ContractError> { - let curator_mgr_role = Self::role_symbol(&env, ROLE_CURATOR_MGR_CACHED); - Self::require_role(&env, &curator_mgr_role, &admin)?; - Self::require_not_paused(&env)?; - - let mut curators = Self::get_curators(&env); + /// Add a curator (admin only). Idempotent. + pub fn add_curator( + env: Env, + admin: Address, + curator: Address, + ) -> Result<(), ContractError> { + let curator_mgr_role = logic::role_symbol(&env, ROLE_CURATOR_MGR_CACHED); + logic::require_role(&env, &curator_mgr_role, &admin)?; + logic::require_not_paused(&env)?; + let mut curators = storage::get_curators(&env); if curators.iter().all(|c| c != curator) { curators.push_back(curator.clone()); - env.storage() - .persistent() - .set(&DataKey::Curators, &curators); + storage::set_curators(&env, &curators); } - env.events() .publish((symbol_short!("CurAdd"), admin, curator), ()); Ok(()) } /// Remove a curator (admin only). - pub fn remove_curator(env: Env, admin: Address, curator: Address) -> Result<(), ContractError> { - let curator_mgr_role = Self::role_symbol(&env, ROLE_CURATOR_MGR_CACHED); - Self::require_role(&env, &curator_mgr_role, &admin)?; - Self::require_not_paused(&env)?; - - let curators = Self::get_curators(&env); + pub fn remove_curator( + env: Env, + admin: Address, + curator: Address, + ) -> Result<(), ContractError> { + let curator_mgr_role = logic::role_symbol(&env, ROLE_CURATOR_MGR_CACHED); + logic::require_role(&env, &curator_mgr_role, &admin)?; + logic::require_not_paused(&env)?; + let curators = storage::get_curators(&env); let mut updated: Vec
= Vec::new(&env); for c in curators.iter() { if c != curator { updated.push_back(c); } } - env.storage().persistent().set(&DataKey::Curators, &updated); - + storage::set_curators(&env, &updated); env.events() .publish((symbol_short!("CurRem"), admin, curator), ()); Ok(()) @@ -740,7 +320,7 @@ impl RegistryContract { /// Returns `true` if `addr` is an approved curator. pub fn is_curator(env: Env, addr: Address) -> Result { - Ok(Self::get_curators(&env).iter().any(|c| c == addr)) + Ok(storage::get_curators(&env).iter().any(|c| c == addr)) } // ------------------------------------------------------------------------- @@ -759,12 +339,11 @@ impl RegistryContract { curator: Address, ) -> Result<(), ContractError> { curator.require_auth(); - Self::require_not_paused(&env)?; - if !Self::get_curators(&env).iter().any(|c| c == curator) { + logic::require_not_paused(&env)?; + if !storage::get_curators(&env).iter().any(|c| c == curator) { return Err(ContractError::CallerIsNotCurator); } - - // #531: Validate category against on-chain list (if any categories are set). + // Validate category against on-chain list (if any categories are set). let cats: Vec = env .storage() .persistent() @@ -795,33 +374,12 @@ impl RegistryContract { }, }; - let key = DataKey::Worker(id.clone()); - env.storage().persistent().set(&key, &worker); - env.storage() - .persistent() - .extend_ttl(&key, TTL_THRESHOLD, TTL_EXTEND_TO); + storage::set_worker(&env, &worker); - let list_key = DataKey::WorkerList; - let mut list: Vec = env - .storage() - .persistent() - .get(&list_key) - .unwrap_or(Vec::new(&env)); + let mut list = storage::get_worker_list(&env); list.push_back(id.clone()); - env.storage().persistent().set(&list_key, &list); - env.storage() - .persistent() - .extend_ttl(&list_key, TTL_THRESHOLD, TTL_EXTEND_TO); - - // #529: Maintain WorkerCount for efficient pagination. - let count: u32 = env - .storage() - .persistent() - .get(&DataKey::WorkerCount) - .unwrap_or(0u32); - env.storage() - .persistent() - .set(&DataKey::WorkerCount, &(count + 1)); + storage::set_worker_list(&env, &list); + storage::increment_worker_count(&env); env.events() .publish((symbol_short!("WrkReg"), id), (owner, category)); @@ -835,19 +393,16 @@ impl RegistryContract { /// Toggle a worker's `is_active` status. Only the worker's owner may call this. pub fn toggle(env: Env, id: Symbol, caller: Address) -> Result<(), ContractError> { caller.require_auth(); - Self::require_not_paused(&env)?; + logic::require_not_paused(&env)?; let mut worker: Worker = env .storage() .persistent() .get(&DataKey::Worker(id.clone())) .ok_or(ContractError::WorkerNotFound)?; - Self::require_owner_or_delegate(&env, &worker, &caller)?; + logic::require_owner_or_delegate(&env, &worker, &caller)?; worker.is_active = !worker.is_active; let new_status = worker.is_active; - env.storage() - .persistent() - .set(&DataKey::Worker(id.clone()), &worker); - + storage::set_worker(&env, &worker); env.events() .publish((symbol_short!("WrkTgl"), id), new_status); Ok(()) @@ -864,23 +419,18 @@ impl RegistryContract { contact_hash: BytesN<32>, ) -> Result<(), ContractError> { caller.require_auth(); - Self::require_not_paused(&env)?; + logic::require_not_paused(&env)?; let mut worker: Worker = env .storage() .persistent() .get(&DataKey::Worker(id.clone())) .ok_or(ContractError::WorkerNotFound)?; - Self::require_owner_or_delegate(&env, &worker, &caller)?; - + logic::require_owner_or_delegate(&env, &worker, &caller)?; worker.name = name.clone(); worker.category = category.clone(); worker.location_hash = location_hash; worker.contact_hash = contact_hash; - - env.storage() - .persistent() - .set(&DataKey::Worker(id.clone()), &worker); - + storage::set_worker(&env, &worker); env.events() .publish((symbol_short!("WrkUpd"), id), (name, category)); Ok(()) @@ -896,23 +446,17 @@ impl RegistryContract { wallet: Address, ) -> Result<(), ContractError> { caller.require_auth(); - Self::require_not_paused(&env)?; - + logic::require_not_paused(&env)?; let mut worker: Worker = env .storage() .persistent() .get(&DataKey::Worker(id.clone())) .ok_or(ContractError::WorkerNotFound)?; - - Self::require_owner_or_delegate(&env, &worker, &caller)?; - + logic::require_owner_or_delegate(&env, &worker, &caller)?; worker.name = name.clone(); worker.category = category.clone(); worker.wallet = wallet.clone(); - env.storage() - .persistent() - .set(&DataKey::Worker(id.clone()), &worker); - + storage::set_worker(&env, &worker); env.events().publish( (symbol_short!("WrkUpd"), id, caller), (name, category, wallet), @@ -923,7 +467,7 @@ impl RegistryContract { /// Permanently remove a worker from the registry. Owner only. pub fn deregister(env: Env, id: Symbol, caller: Address) -> Result<(), ContractError> { caller.require_auth(); - Self::require_not_paused(&env)?; + logic::require_not_paused(&env)?; let worker: Worker = env .storage() .persistent() @@ -935,29 +479,12 @@ impl RegistryContract { env.storage() .persistent() .remove(&DataKey::Worker(id.clone())); - - let mut list: Vec = env - .storage() - .persistent() - .get(&DataKey::WorkerList) - .unwrap_or(Vec::new(&env)); + let mut list = storage::get_worker_list(&env); if let Some(pos) = list.iter().position(|x| x == id) { list.remove(pos as u32); } - env.storage().persistent().set(&DataKey::WorkerList, &list); - - // #529: Decrement WorkerCount. - let count: u32 = env - .storage() - .persistent() - .get(&DataKey::WorkerCount) - .unwrap_or(0u32); - if count > 0 { - env.storage() - .persistent() - .set(&DataKey::WorkerCount, &(count - 1)); - } - + storage::set_worker_list(&env, &list); + storage::decrement_worker_count(&env); env.events() .publish((symbol_short!("WrkDrg"), id, caller), ()); Ok(()) @@ -974,11 +501,7 @@ impl RegistryContract { /// List all registered worker ids. pub fn list_workers(env: Env) -> Result, ContractError> { - Ok(env - .storage() - .persistent() - .get(&DataKey::WorkerList) - .unwrap_or(Vec::new(&env))) + Ok(storage::get_worker_list(&env)) } /// Return a page of worker ids starting at `offset`, up to `limit` items. @@ -987,19 +510,12 @@ impl RegistryContract { offset: u32, limit: u32, ) -> Result, ContractError> { - let list: Vec = env - .storage() - .persistent() - .get(&DataKey::WorkerList) - .unwrap_or(Vec::new(&env)); - + let list = storage::get_worker_list(&env); let total = list.len(); let mut page: Vec = Vec::new(&env); - if offset >= total || limit == 0 { return Ok(page); } - let end = (offset + limit).min(total); for i in offset..end { page.push_back(list.get(i).unwrap()); @@ -1009,12 +525,7 @@ impl RegistryContract { /// Return the total number of registered workers. pub fn worker_count(env: Env) -> Result { - let list: Vec = env - .storage() - .persistent() - .get(&DataKey::WorkerList) - .unwrap_or(Vec::new(&env)); - Ok(list.len()) + Ok(storage::get_worker_list(&env).len()) } /// Extend the TTL of a worker entry. Callable by anyone. @@ -1055,11 +566,10 @@ impl RegistryContract { .get(&DataKey::Admin) .ok_or(ContractError::NotInitialized)?; current_admin.require_auth(); - env.storage().persistent().set(&DataKey::Admin, &new_admin); - - let admin_role = Self::role_symbol(&env, ROLE_ADMIN); - let mut members = Self::get_role_members(&env, &admin_role); + let admin_role = logic::role_symbol(&env, ROLE_ADMIN); + let role_id = logic::role_to_id(&env, &admin_role); + let members = storage::get_role_members(&env, role_id); let mut updated: Vec
= Vec::new(&env); for m in members.iter() { if m != current_admin { @@ -1069,10 +579,7 @@ impl RegistryContract { if updated.iter().all(|m| m != new_admin) { updated.push_back(new_admin.clone()); } - env.storage().persistent().set( - &DataKey::RoleMembers(Self::role_to_id_with_env(&env, &admin_role)), - &updated, - ); + storage::set_role_members(&env, role_id, &updated); Ok(()) } @@ -1080,154 +587,33 @@ impl RegistryContract { // Reputation // ------------------------------------------------------------------------- - /// Update a worker's on-chain reputation score (admin only). + /// Update a worker's on-chain reputation score (rep_mgr only). pub fn update_reputation( env: Env, admin: Address, id: Symbol, score: u32, ) -> Result<(), ContractError> { - let rep_mgr_role = Self::role_symbol(&env, ROLE_REP_MGR_CACHED); - Self::require_role(&env, &rep_mgr_role, &admin)?; - Self::require_not_paused(&env)?; + let rep_mgr_role = logic::role_symbol(&env, ROLE_REP_MGR_CACHED); + logic::require_role(&env, &rep_mgr_role, &admin)?; + logic::require_not_paused(&env)?; if score > 10_000 { return Err(ContractError::ScoreOutOfRange); } - let mut worker: Worker = env .storage() .persistent() .get(&DataKey::Worker(id.clone())) .ok_or(ContractError::WorkerNotFound)?; - let prev = worker.reputation; worker.reputation = score; - env.storage() - .persistent() - .set(&DataKey::Worker(id.clone()), &worker); - - Self::append_reputation_history(&env, &id, prev, score, Symbol::new(&env, "manual")); - + storage::set_worker(&env, &worker); + logic::append_reputation_history(&env, &id, prev, score, Symbol::new(&env, "manual")); env.events().publish((symbol_short!("RepUpd"), id), score); Ok(()) } - // ------------------------------------------------------------------------- - // Reputation system (#677) - // ------------------------------------------------------------------------- - - /// Weights (out of 100) for the three reputation factors. - const REP_WEIGHT_QUALITY: u32 = 60; // review quality (avg rating) - const REP_WEIGHT_VOLUME: u32 = 25; // tip/job-completion volume - const REP_WEIGHT_RECENCY: u32 = 15; // how recent the last review is - - /// Recency half-life in seconds (~90 days). - const RECENCY_HALF_LIFE_SECS: u64 = 7_776_000; - - /// Maximum tip count considered for volume score (caps at 10_000 bps). - const MAX_TIP_VOLUME: u32 = 50; - - /// Maximum history entries stored per worker. - const MAX_HISTORY_LEN: u32 = 100; - - /// Minimum rating sum to trigger a slash (quality below this threshold). - /// Below 3000 bps avg with at least 3 reviews triggers automatic slashing. - const SLASH_THRESHOLD_RATING: u32 = 3_000; - const SLASH_MIN_REVIEWS: u32 = 3; - - /// Compute the weighted reputation score from [`ReputationInputs`]. - /// - /// Formula: - /// - quality_score = avg_rating (bps) × 0.60 - /// - volume_score = min(tip_count / MAX_TIP_VOLUME, 1) × 10000 × 0.25 - /// - recency_score = decay(last_review_at, now) × 10000 × 0.15 - /// where decay = 0.5 ^ (elapsed / HALF_LIFE) (approximated as linear for gas efficiency) - /// - /// Returns a score in basis points (0–10000). - fn compute_weighted_reputation(inputs: &ReputationInputs, now: u64) -> u32 { - // quality component - let avg_rating = if inputs.rating_count == 0 { - 0u32 - } else { - (inputs.rating_sum / inputs.rating_count as u64) as u32 - }; - let quality = avg_rating - .checked_mul(Self::REP_WEIGHT_QUALITY) - .expect("overflow") - / 100; - - // volume component (saturates at MAX_TIP_VOLUME) - let volume_fraction = inputs.tip_count.min(Self::MAX_TIP_VOLUME); - let volume = (volume_fraction as u64) - .checked_mul(10_000) - .expect("overflow") - / Self::MAX_TIP_VOLUME as u64; - let volume = (volume as u32) - .checked_mul(Self::REP_WEIGHT_VOLUME) - .expect("overflow") - / 100; - - // recency component (linear approximation of exponential decay) - let recency = if inputs.last_review_at == 0 { - 0u32 - } else { - let elapsed = now.saturating_sub(inputs.last_review_at); - let decay_bps = if elapsed >= Self::RECENCY_HALF_LIFE_SECS { - 0u32 - } else { - 10_000u32 - - ((elapsed as u64).checked_mul(10_000).expect("overflow") - / Self::RECENCY_HALF_LIFE_SECS) as u32 - }; - decay_bps - .checked_mul(Self::REP_WEIGHT_RECENCY) - .expect("overflow") - / 100 - }; - - quality - .checked_add(volume) - .expect("overflow") - .checked_add(recency) - .expect("overflow") - .min(10_000) - } - - /// Append an entry to the immutable reputation history (capped at MAX_HISTORY_LEN). - fn append_reputation_history( - env: &Env, - id: &Symbol, - previous: u32, - new_score: u32, - reason: Symbol, - ) { - let mut history: Vec = env - .storage() - .persistent() - .get(&DataKey::ReputationHistory(id.clone())) - .unwrap_or(Vec::new(env)); - - // Drop oldest entry if at capacity - if history.len() >= Self::MAX_HISTORY_LEN { - let mut trimmed: Vec = Vec::new(env); - for i in 1..history.len() { - trimmed.push_back(history.get(i).unwrap()); - } - history = trimmed; - } - - history.push_back(ReputationEvent { - previous_score: previous, - new_score, - reason, - timestamp: env.ledger().timestamp(), - }); - env.storage() - .persistent() - .set(&DataKey::ReputationHistory(id.clone()), &history); - } - - /// Submit a user review for a worker (#677). + /// Submit a user review for a worker. pub fn submit_review( env: Env, reviewer: Address, @@ -1235,20 +621,16 @@ impl RegistryContract { rating: u32, ) -> Result<(), ContractError> { reviewer.require_auth(); - Self::require_not_paused(&env)?; + logic::require_not_paused(&env)?; if rating > 10_000 { return Err(ContractError::RatingOutOfRange); } - let mut worker: Worker = env .storage() .persistent() .get(&DataKey::Worker(worker_id.clone())) .ok_or(ContractError::WorkerNotFound)?; - let now = env.ledger().timestamp(); - - // Update ReputationInputs let mut inputs: ReputationInputs = env .storage() .persistent() @@ -1259,7 +641,6 @@ impl RegistryContract { rating_count: 0, last_review_at: 0, }); - inputs.rating_sum = inputs .rating_sum .checked_add(rating as u64) @@ -1267,24 +648,22 @@ impl RegistryContract { inputs.rating_count = inputs.rating_count.checked_add(1).expect("overflow"); inputs.last_review_at = now; - let new_score = Self::compute_weighted_reputation(&inputs, now); - + let new_score = logic::compute_weighted_reputation(&inputs, now); env.storage() .persistent() .set(&DataKey::ReputationInputs(worker_id.clone()), &inputs); - // Update worker aggregate fields let prev_rep = worker.reputation; worker.review_count = worker.review_count.checked_add(1).expect("overflow"); worker.avg_rating = (inputs.rating_sum / inputs.rating_count as u64) as u32; worker.reputation = new_score; - // Slash check: avg below threshold with enough reviews - if worker.avg_rating < Self::SLASH_THRESHOLD_RATING - && worker.review_count >= Self::SLASH_MIN_REVIEWS + // Slash check: avg below threshold with enough reviews. + if worker.avg_rating < logic::SLASH_THRESHOLD_RATING + && worker.review_count >= logic::SLASH_MIN_REVIEWS { let slashed = worker.reputation / 2; - Self::append_reputation_history( + logic::append_reputation_history( &env, &worker_id, worker.reputation, @@ -1297,19 +676,14 @@ impl RegistryContract { (worker.avg_rating, slashed), ); } - - env.storage() - .persistent() - .set(&DataKey::Worker(worker_id.clone()), &worker); - - Self::append_reputation_history( + storage::set_worker(&env, &worker); + logic::append_reputation_history( &env, &worker_id, prev_rep, new_score, Symbol::new(&env, "review"), ); - env.events().publish( (symbol_short!("RevSub"), worker_id), (reviewer, rating, worker.reputation), @@ -1317,24 +691,21 @@ impl RegistryContract { Ok(()) } - /// Record a completed job/tip payment to boost a worker's volume score (#677). + /// Record a completed job/tip payment to boost a worker's volume score. pub fn record_job_completion( env: Env, caller: Address, worker_id: Symbol, ) -> Result<(), ContractError> { - let rep_mgr_role = Self::role_symbol(&env, ROLE_REP_MGR_CACHED); - Self::require_role(&env, &rep_mgr_role, &caller)?; - Self::require_not_paused(&env)?; - + let rep_mgr_role = logic::role_symbol(&env, ROLE_REP_MGR_CACHED); + logic::require_role(&env, &rep_mgr_role, &caller)?; + logic::require_not_paused(&env)?; let mut worker: Worker = env .storage() .persistent() .get(&DataKey::Worker(worker_id.clone())) .ok_or(ContractError::WorkerNotFound)?; - let now = env.ledger().timestamp(); - let mut inputs: ReputationInputs = env .storage() .persistent() @@ -1345,28 +716,21 @@ impl RegistryContract { rating_count: 0, last_review_at: 0, }); - inputs.tip_count = inputs.tip_count.checked_add(1).expect("overflow"); - - let new_score = Self::compute_weighted_reputation(&inputs, now); + let new_score = logic::compute_weighted_reputation(&inputs, now); let prev_rep = worker.reputation; worker.reputation = new_score; - env.storage() .persistent() .set(&DataKey::ReputationInputs(worker_id.clone()), &inputs); - env.storage() - .persistent() - .set(&DataKey::Worker(worker_id.clone()), &worker); - - Self::append_reputation_history( + storage::set_worker(&env, &worker); + logic::append_reputation_history( &env, &worker_id, prev_rep, new_score, Symbol::new(&env, "job_comp"), ); - env.events().publish( (symbol_short!("JobComp"), worker_id), (inputs.tip_count, new_score), @@ -1374,41 +738,34 @@ impl RegistryContract { Ok(()) } - /// Slash a worker's reputation for poor performance (#677). + /// Slash a worker's reputation for poor performance. pub fn slash_reputation( env: Env, caller: Address, worker_id: Symbol, slash_bps: u32, ) -> Result<(), ContractError> { - let rep_mgr_role = Self::role_symbol(&env, ROLE_REP_MGR_CACHED); - Self::require_role(&env, &rep_mgr_role, &caller)?; - Self::require_not_paused(&env)?; + let rep_mgr_role = logic::role_symbol(&env, ROLE_REP_MGR_CACHED); + logic::require_role(&env, &rep_mgr_role, &caller)?; + logic::require_not_paused(&env)?; if slash_bps > 10_000 { return Err(ContractError::ScoreOutOfRange); } - let mut worker: Worker = env .storage() .persistent() .get(&DataKey::Worker(worker_id.clone())) .ok_or(ContractError::WorkerNotFound)?; - let prev = worker.reputation; worker.reputation = worker.reputation.saturating_sub(slash_bps); - - env.storage() - .persistent() - .set(&DataKey::Worker(worker_id.clone()), &worker); - - Self::append_reputation_history( + storage::set_worker(&env, &worker); + logic::append_reputation_history( &env, &worker_id, prev, worker.reputation, Symbol::new(&env, "slash"), ); - env.events().publish( (symbol_short!("RepSlash"), worker_id), (slash_bps, worker.reputation), @@ -1416,7 +773,7 @@ impl RegistryContract { Ok(()) } - /// Get the immutable reputation history for a worker (#677). + /// Get the immutable reputation history for a worker. pub fn get_reputation_history( env: Env, worker_id: Symbol, @@ -1428,7 +785,7 @@ impl RegistryContract { .unwrap_or(Vec::new(&env))) } - /// Get the raw reputation inputs for a worker (#677). + /// Get the raw reputation inputs for a worker. pub fn get_reputation_inputs( env: Env, worker_id: Symbol, @@ -1447,29 +804,19 @@ impl RegistryContract { review_count: u32, avg_rating: u32, ) -> Result<(), ContractError> { - Self::require_role(&env, &Symbol::new(&env, ROLE_ADMIN), &admin)?; - Self::require_not_paused(&env)?; + logic::require_role(&env, &Symbol::new(&env, ROLE_ADMIN), &admin)?; + logic::require_not_paused(&env)?; if avg_rating > 10_000 { return Err(ContractError::RatingOutOfRange); } - let mut worker: Worker = env .storage() .persistent() .get(&DataKey::Worker(id.clone())) .ok_or(ContractError::WorkerNotFound)?; - worker.review_count = review_count; worker.avg_rating = avg_rating; - env.storage() - .persistent() - .set(&DataKey::Worker(id.clone()), &worker); - env.storage().persistent().extend_ttl( - &DataKey::Worker(id.clone()), - TTL_THRESHOLD, - TTL_EXTEND_TO, - ); - + storage::set_worker(&env, &worker); env.events() .publish((symbol_short!("RevUpd"), id), (review_count, avg_rating)); Ok(()) @@ -1483,38 +830,26 @@ impl RegistryContract { tier: u32, expires_at: u64, ) -> Result<(), ContractError> { - Self::require_role(&env, &Symbol::new(&env, ROLE_ADMIN), &admin)?; - Self::require_not_paused(&env)?; - + logic::require_role(&env, &Symbol::new(&env, ROLE_ADMIN), &admin)?; + logic::require_not_paused(&env)?; let tier_enum = match tier { 0 => SubscriptionTier::Free, 1 => SubscriptionTier::Basic, 2 => SubscriptionTier::Premium, _ => return Err(ContractError::InvalidSubscriptionTier), }; - let mut worker: Worker = env .storage() .persistent() .get(&DataKey::Worker(id.clone())) .ok_or(ContractError::WorkerNotFound)?; - let now = env.ledger().timestamp(); worker.subscription = WorkerSubscription { tier: tier_enum, expires_at, last_renewed_at: now, }; - - env.storage() - .persistent() - .set(&DataKey::Worker(id.clone()), &worker); - env.storage().persistent().extend_ttl( - &DataKey::Worker(id.clone()), - TTL_THRESHOLD, - TTL_EXTEND_TO, - ); - + storage::set_worker(&env, &worker); env.events() .publish((symbol_short!("SubUpd"), id), (tier, expires_at)); Ok(()) @@ -1528,47 +863,37 @@ impl RegistryContract { new_expires_at: u64, ) -> Result<(), ContractError> { caller.require_auth(); - Self::require_not_paused(&env)?; - + logic::require_not_paused(&env)?; let mut worker: Worker = env .storage() .persistent() .get(&DataKey::Worker(id.clone())) .ok_or(ContractError::WorkerNotFound)?; - - Self::require_owner_or_delegate(&env, &worker, &caller)?; - + logic::require_owner_or_delegate(&env, &worker, &caller)?; let now = env.ledger().timestamp(); worker.subscription.expires_at = new_expires_at; worker.subscription.last_renewed_at = now; - - env.storage() - .persistent() - .set(&DataKey::Worker(id.clone()), &worker); - env.storage().persistent().extend_ttl( - &DataKey::Worker(id.clone()), - TTL_THRESHOLD, - TTL_EXTEND_TO, - ); - + storage::set_worker(&env, &worker); env.events() .publish((symbol_short!("SubRnw"), id), new_expires_at); Ok(()) } /// Get a worker's subscription status. - pub fn get_subscription(env: Env, id: Symbol) -> Result { + pub fn get_subscription( + env: Env, + id: Symbol, + ) -> Result { let worker: Worker = env .storage() .persistent() .get(&DataKey::Worker(id)) .ok_or(ContractError::WorkerNotFound)?; - Ok(worker.subscription) } // ------------------------------------------------------------------------- - // Category verification (#338) + // Category verification // ------------------------------------------------------------------------- /// Verify a worker's category on-chain. Curator only. @@ -1580,23 +905,18 @@ impl RegistryContract { expires_at: u64, ) -> Result<(), ContractError> { curator.require_auth(); - if !Self::get_curators(&env).iter().any(|c| c == curator) { + if !storage::get_curators(&env).iter().any(|c| c == curator) { return Err(ContractError::CallerIsNotCurator); } - let mut worker: Worker = env .storage() .persistent() .get(&DataKey::Worker(worker_id.clone())) .ok_or(ContractError::WorkerNotFound)?; - if worker.verified_categories.iter().all(|c| c != category) { worker.verified_categories.push_back(category.clone()); - env.storage() - .persistent() - .set(&DataKey::Worker(worker_id.clone()), &worker); + storage::set_worker(&env, &worker); } - let verification = CategoryVerification { category: category.clone(), curator: curator.clone(), @@ -1606,7 +926,6 @@ impl RegistryContract { &DataKey::CategoryVerification(worker_id.clone(), category.clone()), &verification, ); - env.events().publish( (symbol_short!("CatVfy"), worker_id, category), (curator, expires_at), @@ -1627,10 +946,10 @@ impl RegistryContract { } // ------------------------------------------------------------------------- - // Location verification (#352) + // Location verification // ------------------------------------------------------------------------- - /// Verify a worker's location on-chain. Verifier role required. + /// Verify a worker's location on-chain. pub fn verify_location( env: Env, verifier: Address, @@ -1643,7 +962,6 @@ impl RegistryContract { .persistent() .get(&DataKey::Worker(worker_id.clone())) .ok_or(ContractError::WorkerNotFound)?; - let now = env.ledger().timestamp(); let verification = LocationVerification { verifier: verifier.clone(), @@ -1654,7 +972,6 @@ impl RegistryContract { &DataKey::LocationVerification(worker_id.clone()), &verification, ); - env.events().publish( (symbol_short!("LocVfy"), worker_id), (verifier, now, expires_at), @@ -1674,7 +991,7 @@ impl RegistryContract { } // ------------------------------------------------------------------------- - // Availability status (#376) + // Availability status // ------------------------------------------------------------------------- /// Update a worker's availability status. Owner only. @@ -1694,7 +1011,6 @@ impl RegistryContract { if worker.owner != caller { return Err(ContractError::NotAuthorized); } - let now = env.ledger().timestamp(); let status = AvailabilityStatus { is_available, @@ -1704,7 +1020,6 @@ impl RegistryContract { env.storage() .persistent() .set(&DataKey::AvailabilityStatus(id.clone()), &status); - env.events().publish( (symbol_short!("AvlUpd"), id), (is_available, now, expires_at), @@ -1724,34 +1039,33 @@ impl RegistryContract { } // ------------------------------------------------------------------------- - // Batch registration (#340) + // Batch operations // ------------------------------------------------------------------------- - /// Maximum number of workers that can be registered in a single batch call. + /// Maximum number of workers that can be processed in a single batch call. pub const MAX_BATCH_SIZE: u32 = 20; - /// Toggle the `is_active` status of multiple workers in one transaction. Curator only. + /// Toggle the `is_active` status of multiple workers in one transaction. pub fn batch_toggle( env: Env, caller: Address, ids: Vec, ) -> Result, ContractError> { caller.require_auth(); - Self::require_not_paused(&env)?; - if !Self::get_curators(&env).iter().any(|c| c == caller) { + logic::require_not_paused(&env)?; + if !storage::get_curators(&env).iter().any(|c| c == caller) { return Err(ContractError::CallerIsNotCurator); } if ids.len() > Self::MAX_BATCH_SIZE { return Err(ContractError::BatchTooLarge); } - let mut toggled: Vec = Vec::new(&env); for id in ids.iter() { let key = DataKey::Worker(id.clone()); if let Some(mut worker) = env.storage().persistent().get::(&key) { worker.is_active = !worker.is_active; let new_status = worker.is_active; - env.storage().persistent().set(&key, &worker); + storage::set_worker(&env, &worker); env.events() .publish((symbol_short!("WrkTgl"), id.clone()), new_status); toggled.push_back(id); @@ -1772,10 +1086,9 @@ impl RegistryContract { contact_hashes: Vec>, ) -> Result, ContractError> { curator.require_auth(); - if !Self::get_curators(&env).iter().any(|c| c == curator) { + if !storage::get_curators(&env).iter().any(|c| c == curator) { return Err(ContractError::CallerIsNotCurator); } - let n = ids.len(); if n > Self::MAX_BATCH_SIZE { return Err(ContractError::BatchTooLarge); @@ -1788,24 +1101,15 @@ impl RegistryContract { { return Err(ContractError::MismatchedInputLengths); } - let mut results: Vec = Vec::new(&env); - let list_key = DataKey::WorkerList; - let mut list: Vec = env - .storage() - .persistent() - .get(&list_key) - .unwrap_or(Vec::new(&env)); - + let mut list = storage::get_worker_list(&env); for i in 0..n { let id = ids.get(i).unwrap(); let key = DataKey::Worker(id.clone()); - if env.storage().persistent().has(&key) { results.push_back(BatchRegisterResult { id, success: false }); continue; } - let owner = owners.get(i).unwrap(); let worker = Worker { id: id.clone(), @@ -1827,31 +1131,20 @@ impl RegistryContract { last_renewed_at: env.ledger().timestamp(), }, }; - - env.storage().persistent().set(&key, &worker); - env.storage() - .persistent() - .extend_ttl(&key, TTL_THRESHOLD, TTL_EXTEND_TO); + storage::set_worker(&env, &worker); list.push_back(id.clone()); - env.events().publish( (symbol_short!("WrkReg"), id.clone()), (owner, categories.get(i).unwrap()), ); - results.push_back(BatchRegisterResult { id, success: true }); } - - env.storage().persistent().set(&list_key, &list); - env.storage() - .persistent() - .extend_ttl(&list_key, TTL_THRESHOLD, TTL_EXTEND_TO); - + storage::set_worker_list(&env, &list); Ok(results) } // ------------------------------------------------------------------------- - // Worker staking (#341) + // Worker staking // ------------------------------------------------------------------------- /// Cooldown period in seconds before an unstake request can be finalised (~7 days). @@ -1871,7 +1164,6 @@ impl RegistryContract { if amount <= 0 { return Err(ContractError::AmountMustBePositive); } - let mut worker: Worker = env .storage() .persistent() @@ -1880,10 +1172,8 @@ impl RegistryContract { if worker.owner != caller { return Err(ContractError::NotAuthorized); } - let client = token::Client::new(&env, &token_addr); client.transfer(&caller, &env.current_contract_address(), &amount); - let now = env.ledger().timestamp(); let mut info: StakeInfo = env .storage() @@ -1896,13 +1186,11 @@ impl RegistryContract { rewards_accumulated: 0, last_reward_ledger: now, }); - let elapsed = now.saturating_sub(info.last_reward_ledger); let new_rewards = info .amount .checked_mul(Self::REWARD_RATE_BPS_PER_1000_SECS) .and_then(|v| v.checked_mul(elapsed as i128)) - // bps (÷10_000) × per-1000-seconds (÷1_000) = ÷10_000_000 .and_then(|v| v.checked_div(10_000_000)) .expect("Reward overflow"); info.rewards_accumulated = info @@ -1915,12 +1203,8 @@ impl RegistryContract { env.storage() .persistent() .set(&DataKey::StakeInfo(worker_id.clone()), &info); - worker.staked_amount = info.amount; - env.storage() - .persistent() - .set(&DataKey::Worker(worker_id.clone()), &worker); - + storage::set_worker(&env, &worker); env.events().publish( (symbol_short!("Staked"), worker_id, caller), (amount, info.amount), @@ -1943,7 +1227,6 @@ impl RegistryContract { if worker.owner != caller { return Err(ContractError::NotAuthorized); } - let mut info: StakeInfo = env .storage() .persistent() @@ -1955,13 +1238,11 @@ impl RegistryContract { if info.unstake_requested_at != 0 { return Err(ContractError::UnstakeAlreadyRequested); } - let now = env.ledger().timestamp(); info.unstake_requested_at = now; env.storage() .persistent() .set(&DataKey::StakeInfo(worker_id.clone()), &info); - env.events() .publish((symbol_short!("UnstakeRq"), worker_id, caller), now); Ok(()) @@ -1978,7 +1259,6 @@ impl RegistryContract { if worker.owner != caller { return Err(ContractError::NotAuthorized); } - let mut info: StakeInfo = env .storage() .persistent() @@ -1990,32 +1270,27 @@ impl RegistryContract { if info.unstake_requested_at == 0 { return Err(ContractError::UnstakeNotRequested); } - let now = env.ledger().timestamp(); if now < info.unstake_requested_at + Self::UNSTAKE_COOLDOWN_SECS { return Err(ContractError::CooldownNotElapsed); } - let elapsed = now.saturating_sub(info.last_reward_ledger); let final_rewards = info .amount .checked_mul(Self::REWARD_RATE_BPS_PER_1000_SECS) .and_then(|v| v.checked_mul(elapsed as i128)) - // bps (÷10_000) × per-1000-seconds (÷1_000) = ÷10_000_000 .and_then(|v| v.checked_div(10_000_000)) .expect("Reward overflow"); info.rewards_accumulated = info .rewards_accumulated .checked_add(final_rewards) .expect("Reward overflow"); - let total_return = info .amount .checked_add(info.rewards_accumulated) .expect("Return overflow"); let client = token::Client::new(&env, &info.token); client.transfer(&env.current_contract_address(), &caller, &total_return); - let staked = info.amount; let rewards = info.rewards_accumulated; info.amount = 0; @@ -2024,12 +1299,8 @@ impl RegistryContract { env.storage() .persistent() .set(&DataKey::StakeInfo(worker_id.clone()), &info); - worker.staked_amount = 0; - env.storage() - .persistent() - .set(&DataKey::Worker(worker_id.clone()), &worker); - + storage::set_worker(&env, &worker); env.events().publish( (symbol_short!("Unstaked"), worker_id, caller), (staked, rewards), @@ -2038,7 +1309,10 @@ impl RegistryContract { } /// Get staking info for a worker. - pub fn get_stake_info(env: Env, worker_id: Symbol) -> Result, ContractError> { + pub fn get_stake_info( + env: Env, + worker_id: Symbol, + ) -> Result, ContractError> { Ok(env .storage() .persistent() @@ -2046,7 +1320,7 @@ impl RegistryContract { } // ------------------------------------------------------------------------- - // Performance Metrics (#378) + // Performance Metrics // ------------------------------------------------------------------------- /// Update performance metrics for a worker. @@ -2057,11 +1331,10 @@ impl RegistryContract { jobs_completed: u32, rating: u32, ) -> Result<(), ContractError> { - Self::require_role(&env, &Symbol::new(&env, ROLE_REP_MGR), &admin)?; + logic::require_role(&env, &Symbol::new(&env, ROLE_REP_MGR), &admin)?; if rating > 10_000 { return Err(ContractError::RatingOutOfRange); } - let mut metrics: PerformanceMetrics = env .storage() .persistent() @@ -2073,7 +1346,6 @@ impl RegistryContract { last_updated: 0, performance_score: 0, }); - metrics.jobs_completed = jobs_completed; if rating > 0 { let total = (metrics.avg_rating as u64) @@ -2084,12 +1356,10 @@ impl RegistryContract { metrics.avg_rating = (total / metrics.total_ratings as u64) as u32; } metrics.last_updated = env.ledger().timestamp(); - metrics.performance_score = Self::calculate_performance_score(&metrics); - + metrics.performance_score = logic::calculate_performance_score(&metrics); env.storage() .persistent() .set(&DataKey::PerformanceMetrics(worker_id.clone()), &metrics); - env.events().publish( (symbol_short!("MetUpd"), worker_id), ( @@ -2101,28 +1371,6 @@ impl RegistryContract { Ok(()) } - /// Calculate performance score from metrics. - fn calculate_performance_score(metrics: &PerformanceMetrics) -> u32 { - if metrics.total_ratings == 0 { - return 0; - } - let rating_weight = 70u32; - let completion_weight = 30u32; - let rating_score = metrics - .avg_rating - .checked_mul(rating_weight) - .expect("overflow") - / 100; - let completion_score = metrics - .jobs_completed - .min(100) - .checked_mul(completion_weight) - .expect("overflow"); - rating_score - .checked_add(completion_score) - .expect("overflow") - } - /// Get performance metrics for a worker. pub fn get_metrics( env: Env, @@ -2135,7 +1383,7 @@ impl RegistryContract { } // ------------------------------------------------------------------------- - // Badge System (#380) + // Badge System // ------------------------------------------------------------------------- /// Award a badge to a worker (admin or curator). @@ -2148,18 +1396,17 @@ impl RegistryContract { expires_at: u64, ) -> Result<(), ContractError> { issuer.require_auth(); - let is_admin = Self::has_role(env.clone(), Symbol::new(&env, ROLE_ADMIN), issuer.clone())?; + let is_admin = + Self::has_role(env.clone(), Symbol::new(&env, ROLE_ADMIN), issuer.clone())?; let is_curator = Self::is_curator(env.clone(), issuer.clone())?; if !(is_admin || is_curator) { return Err(ContractError::NotAuthorized); } - let _worker: Worker = env .storage() .persistent() .get(&DataKey::Worker(worker_id.clone())) .ok_or(ContractError::WorkerNotFound)?; - let badge = Badge { id: badge_id.clone(), name: name.clone(), @@ -2168,11 +1415,10 @@ impl RegistryContract { expires_at, active: true, }; - - env.storage() - .persistent() - .set(&DataKey::Badge(worker_id.clone(), badge_id.clone()), &badge); - + env.storage().persistent().set( + &DataKey::Badge(worker_id.clone(), badge_id.clone()), + &badge, + ); let mut badges: Vec = env .storage() .persistent() @@ -2184,7 +1430,6 @@ impl RegistryContract { .persistent() .set(&DataKey::WorkerBadges(worker_id.clone()), &badges); } - env.events().publish( (symbol_short!("BdgAwd"), worker_id, badge_id), (issuer, name), @@ -2205,17 +1450,16 @@ impl RegistryContract { .persistent() .get(&DataKey::Badge(worker_id.clone(), badge_id.clone())) .ok_or(ContractError::BadgeNotFound)?; - - let is_admin = Self::has_role(env.clone(), Symbol::new(&env, ROLE_ADMIN), caller.clone())?; + let is_admin = + Self::has_role(env.clone(), Symbol::new(&env, ROLE_ADMIN), caller.clone())?; if !(is_admin || badge.issuer == caller) { return Err(ContractError::NotAuthorized); } - badge.active = false; - env.storage() - .persistent() - .set(&DataKey::Badge(worker_id.clone(), badge_id.clone()), &badge); - + env.storage().persistent().set( + &DataKey::Badge(worker_id.clone(), badge_id.clone()), + &badge, + ); env.events() .publish((symbol_short!("BdgRvk"), worker_id, badge_id), caller); Ok(()) @@ -2240,13 +1484,15 @@ impl RegistryContract { } /// Get all badges for a worker. - pub fn get_worker_badges(env: Env, worker_id: Symbol) -> Result, ContractError> { + pub fn get_worker_badges( + env: Env, + worker_id: Symbol, + ) -> Result, ContractError> { let badge_ids: Vec = env .storage() .persistent() .get(&DataKey::WorkerBadges(worker_id.clone())) .unwrap_or(Vec::new(&env)); - let mut badges: Vec = Vec::new(&env); for badge_id in badge_ids.iter() { if let Some(badge) = env @@ -2273,45 +1519,30 @@ impl RegistryContract { } // ------------------------------------------------------------------------- - // Schema migration (#535) + // Schema migration // ------------------------------------------------------------------------- /// Return the current storage schema version. pub fn get_schema_version(env: Env) -> Result { - Ok(env - .storage() - .persistent() - .get(&DataKey::SchemaVersion) - .unwrap_or(1u32)) + Ok(storage::get_schema_version(&env)) } /// Run version-specific storage migration logic. - pub fn migrate(env: Env, admin: Address, expected_version: u32) -> Result<(), ContractError> { - Self::require_role(&env, &Symbol::new(&env, ROLE_ADMIN), &admin)?; - - let current: u32 = env - .storage() - .persistent() - .get(&DataKey::SchemaVersion) - .unwrap_or(1u32); - + pub fn migrate( + env: Env, + admin: Address, + expected_version: u32, + ) -> Result<(), ContractError> { + logic::require_role(&env, &Symbol::new(&env, ROLE_ADMIN), &admin)?; + let current = storage::get_schema_version(&env); if current != expected_version { return Err(ContractError::WrongSchemaVersion); } - - // ---- version-specific migration logic -------------------------------- - // Version 1 → 2: placeholder (add real field backfills here as needed) if expected_version == 1 { - // Example: no structural change needed for v1→v2 in this release. - // Future migrations add logic here. + // placeholder for v1→v2 migration logic } - // ---------------------------------------------------------------------- - let new_version = expected_version.checked_add(1).expect("Version overflow"); - env.storage() - .persistent() - .set(&DataKey::SchemaVersion, &new_version); - + storage::set_schema_version(&env, new_version); env.events().publish( (symbol_short!("Migrated"),), (expected_version, new_version), @@ -2324,20 +1555,23 @@ impl RegistryContract { // ------------------------------------------------------------------------- /// Upgrade the contract WASM in-place, preserving the contract ID and all storage. - pub fn upgrade(env: Env, new_wasm_hash: soroban_sdk::BytesN<32>) -> Result<(), ContractError> { - let upgrader_role = Self::role_symbol(&env, ROLE_UPGRADER_CACHED); + pub fn upgrade( + env: Env, + new_wasm_hash: soroban_sdk::BytesN<32>, + ) -> Result<(), ContractError> { + let upgrader_role = logic::role_symbol(&env, ROLE_UPGRADER_CACHED); let admin: Address = env .storage() .persistent() .get(&DataKey::Admin) .ok_or(ContractError::NotInitialized)?; - Self::require_role(&env, &upgrader_role, &admin)?; + logic::require_role(&env, &upgrader_role, &admin)?; env.deployer().update_current_contract_wasm(new_wasm_hash); Ok(()) } // ------------------------------------------------------------------------- - // #529: Pagination + // Pagination // ------------------------------------------------------------------------- /// Return a paginated result of worker ids. @@ -2346,18 +1580,8 @@ impl RegistryContract { offset: u32, limit: u32, ) -> Result { - let total: u32 = env - .storage() - .persistent() - .get(&DataKey::WorkerCount) - .unwrap_or(0u32); - - let list: Vec = env - .storage() - .persistent() - .get(&DataKey::WorkerList) - .unwrap_or(Vec::new(&env)); - + let total = storage::get_worker_count(&env); + let list = storage::get_worker_list(&env); let mut ids: Vec = Vec::new(&env); if offset < total && limit > 0 { let end = (offset + limit).min(total); @@ -2365,57 +1589,56 @@ impl RegistryContract { ids.push_back(list.get(i).unwrap()); } } - Ok(WorkerPage { ids, total }) } // ------------------------------------------------------------------------- - // #531: On-chain category validation + // On-chain category validation // ------------------------------------------------------------------------- /// Add a valid category to on-chain storage. Admin only. - pub fn add_category(env: Env, admin: Address, name: Symbol) -> Result<(), ContractError> { - let admin_role = Self::role_symbol(&env, ROLE_ADMIN_CACHED); - Self::require_role(&env, &admin_role, &admin)?; - Self::require_not_paused(&env)?; - + pub fn add_category( + env: Env, + admin: Address, + name: Symbol, + ) -> Result<(), ContractError> { + let admin_role = logic::role_symbol(&env, ROLE_ADMIN_CACHED); + logic::require_role(&env, &admin_role, &admin)?; + logic::require_not_paused(&env)?; let mut cats: Vec = env .storage() .persistent() .get(&DataKey::Categories) .unwrap_or(Vec::new(&env)); - if cats.iter().all(|c| c != name) { cats.push_back(name.clone()); env.storage().persistent().set(&DataKey::Categories, &cats); } - env.events().publish((symbol_short!("CatAdded"), name), ()); Ok(()) } /// Remove a category from on-chain storage. Admin only. - pub fn remove_category(env: Env, admin: Address, name: Symbol) -> Result<(), ContractError> { - let admin_role = Self::role_symbol(&env, ROLE_ADMIN_CACHED); - Self::require_role(&env, &admin_role, &admin)?; - Self::require_not_paused(&env)?; - + pub fn remove_category( + env: Env, + admin: Address, + name: Symbol, + ) -> Result<(), ContractError> { + let admin_role = logic::role_symbol(&env, ROLE_ADMIN_CACHED); + logic::require_role(&env, &admin_role, &admin)?; + logic::require_not_paused(&env)?; let cats: Vec = env .storage() .persistent() .get(&DataKey::Categories) .unwrap_or(Vec::new(&env)); - let mut updated: Vec = Vec::new(&env); for c in cats.iter() { if c != name { updated.push_back(c); } } - env.storage() - .persistent() - .set(&DataKey::Categories, &updated); - + env.storage().persistent().set(&DataKey::Categories, &updated); env.events() .publish((Symbol::new(&env, "CatRemoved"), name), ()); Ok(()) @@ -2431,11 +1654,11 @@ impl RegistryContract { } // ------------------------------------------------------------------------- - // #530: Upgrade timelock + // Upgrade timelock // ------------------------------------------------------------------------- /// Approximate ledger count for 48 hours (~5 s/ledger). - pub const TIMELOCK_LEDGERS: u32 = 34_560; // 48 * 3600 / 5 + pub const TIMELOCK_LEDGERS: u32 = 34_560; /// Propose a contract upgrade with a 48-hour timelock. Admin only. pub fn propose_upgrade( @@ -2443,20 +1666,17 @@ impl RegistryContract { admin: Address, new_wasm_hash: BytesN<32>, ) -> Result<(), ContractError> { - let upgrader_role = Self::role_symbol(&env, ROLE_UPGRADER_CACHED); - Self::require_role(&env, &upgrader_role, &admin)?; - Self::require_not_paused(&env)?; - + let upgrader_role = logic::role_symbol(&env, ROLE_UPGRADER_CACHED); + logic::require_role(&env, &upgrader_role, &admin)?; + logic::require_not_paused(&env)?; if env.storage().persistent().has(&DataKey::PendingUpgrade) { return Err(ContractError::UpgradeAlreadyPending); } - let execute_after_ledger = env .ledger() .sequence() .checked_add(Self::TIMELOCK_LEDGERS) .expect("Ledger overflow"); - let pending = PendingUpgrade { wasm_hash: new_wasm_hash, execute_after_ledger, @@ -2464,7 +1684,6 @@ impl RegistryContract { env.storage() .persistent() .set(&DataKey::PendingUpgrade, &pending); - env.events() .publish((symbol_short!("UpgPropsd"), execute_after_ledger), ()); Ok(()) @@ -2477,11 +1696,9 @@ impl RegistryContract { .persistent() .get(&DataKey::PendingUpgrade) .ok_or(ContractError::NoPendingUpgrade)?; - if env.ledger().sequence() < pending.execute_after_ledger { return Err(ContractError::TimelockNotExpired); } - env.storage().persistent().remove(&DataKey::PendingUpgrade); env.events().publish((symbol_short!("UpgExecd"),), ()); env.deployer() @@ -2491,37 +1708,37 @@ impl RegistryContract { /// Cancel a pending upgrade. Admin only. pub fn cancel_upgrade(env: Env, admin: Address) -> Result<(), ContractError> { - let upgrader_role = Self::role_symbol(&env, ROLE_UPGRADER_CACHED); - Self::require_role(&env, &upgrader_role, &admin)?; - + let upgrader_role = logic::role_symbol(&env, ROLE_UPGRADER_CACHED); + logic::require_role(&env, &upgrader_role, &admin)?; if !env.storage().persistent().has(&DataKey::PendingUpgrade) { return Err(ContractError::NoPendingUpgrade); } - env.storage().persistent().remove(&DataKey::PendingUpgrade); env.events().publish((symbol_short!("UpgCancld"),), ()); Ok(()) } /// Get the pending upgrade, if any. - pub fn get_pending_upgrade(env: Env) -> Result, ContractError> { + pub fn get_pending_upgrade( + env: Env, + ) -> Result, ContractError> { Ok(env.storage().persistent().get(&DataKey::PendingUpgrade)) } // ------------------------------------------------------------------------- - // Verification levels & certified skills (#778) + // Verification levels & certified skills // ------------------------------------------------------------------------- - /// Set the verification level for a worker. Admin or curator-manager only. + /// Set the verification level for a worker. Curator-manager only. pub fn set_verification_level( env: Env, caller: Address, worker_id: Symbol, level: VerificationLevel, ) -> Result<(), ContractError> { - let curator_mgr = Self::role_symbol(&env, ROLE_CURATOR_MGR_CACHED); - Self::require_role(&env, &curator_mgr, &caller)?; - Self::require_not_paused(&env)?; + let curator_mgr = logic::role_symbol(&env, ROLE_CURATOR_MGR_CACHED); + logic::require_role(&env, &curator_mgr, &caller)?; + logic::require_not_paused(&env)?; if !env .storage() .persistent() @@ -2529,11 +1746,9 @@ impl RegistryContract { { return Err(ContractError::WorkerNotFound); } - env.storage() .persistent() .set(&DataKey::VerificationLevel(worker_id.clone()), &level); - env.events().publish( (symbol_short!("VrfLvlSet"), worker_id), (caller, level as u32), @@ -2553,7 +1768,7 @@ impl RegistryContract { .unwrap_or(VerificationLevel::None)) } - /// Add or update a certified skill for a worker. Admin or curator-manager only. + /// Add or update a certified skill for a worker. Curator-manager only. pub fn add_certified_skill( env: Env, caller: Address, @@ -2561,9 +1776,9 @@ impl RegistryContract { skill: Symbol, expires_at: u64, ) -> Result<(), ContractError> { - let curator_mgr = Self::role_symbol(&env, ROLE_CURATOR_MGR_CACHED); - Self::require_role(&env, &curator_mgr, &caller)?; - Self::require_not_paused(&env)?; + let curator_mgr = logic::role_symbol(&env, ROLE_CURATOR_MGR_CACHED); + logic::require_role(&env, &curator_mgr, &caller)?; + logic::require_not_paused(&env)?; if !env .storage() .persistent() @@ -2571,7 +1786,6 @@ impl RegistryContract { { return Err(ContractError::WorkerNotFound); } - let now = env.ledger().timestamp(); let entry = CertifiedSkill { skill: skill.clone(), @@ -2579,14 +1793,11 @@ impl RegistryContract { certified_at: now, expires_at, }; - let mut skills: Vec = env .storage() .persistent() .get(&DataKey::CertifiedSkills(worker_id.clone())) .unwrap_or(Vec::new(&env)); - - // Replace existing entry for the same skill, or append. let mut found = false; let mut updated: Vec = Vec::new(&env); for s in skills.iter() { @@ -2600,11 +1811,9 @@ impl RegistryContract { if !found { updated.push_back(entry); } - env.storage() .persistent() .set(&DataKey::CertifiedSkills(worker_id.clone()), &updated); - env.events().publish( (symbol_short!("SkillCert"), worker_id, skill), (caller, expires_at), @@ -2612,23 +1821,21 @@ impl RegistryContract { Ok(()) } - /// Revoke a certified skill from a worker. Admin or curator-manager only. + /// Revoke a certified skill from a worker. Curator-manager only. pub fn revoke_certified_skill( env: Env, caller: Address, worker_id: Symbol, skill: Symbol, ) -> Result<(), ContractError> { - let curator_mgr = Self::role_symbol(&env, ROLE_CURATOR_MGR_CACHED); - Self::require_role(&env, &curator_mgr, &caller)?; - Self::require_not_paused(&env)?; - + let curator_mgr = logic::role_symbol(&env, ROLE_CURATOR_MGR_CACHED); + logic::require_role(&env, &curator_mgr, &caller)?; + logic::require_not_paused(&env)?; let skills: Vec = env .storage() .persistent() .get(&DataKey::CertifiedSkills(worker_id.clone())) .unwrap_or(Vec::new(&env)); - let mut updated: Vec = Vec::new(&env); let mut removed = false; for s in skills.iter() { @@ -2641,11 +1848,9 @@ impl RegistryContract { if !removed { return Err(ContractError::SkillNotFound); } - env.storage() .persistent() .set(&DataKey::CertifiedSkills(worker_id.clone()), &updated); - env.events() .publish((symbol_short!("SkillRvkd"), worker_id, skill), caller); Ok(()) @@ -2668,11 +1873,8 @@ impl RegistryContract { // Tests // ============================================================================= -// Integration-style unit tests and the contract-upgrade testing framework -// live in `test.rs`; the `mod tests` block below holds the original inline tests. #[cfg(test)] mod test; - #[cfg(test)] mod tests { extern crate std; @@ -2694,41 +1896,26 @@ mod tests { fn new() -> Self { let env = Env::default(); env.mock_all_auths(); - let admin = Address::generate(&env); let curator = Address::generate(&env); let owner = Address::generate(&env); - let contract_id = env.register_contract(None, RegistryContract); let client = RegistryContractClient::new(&env, &contract_id); client.initialize(&admin); - - // Grant all operational roles to the bootstrap admin for convenience in tests. client.grant_role(&admin, &Symbol::new(&env, ROLE_PAUSER), &admin); client.grant_role(&admin, &Symbol::new(&env, ROLE_CURATOR_MGR), &admin); client.grant_role(&admin, &Symbol::new(&env, ROLE_REP_MGR), &admin); client.grant_role(&admin, &Symbol::new(&env, ROLE_UPGRADER), &admin); - - TestEnv { - env, - contract_id, - admin, - curator, - owner, - } + TestEnv { env, contract_id, admin, curator, owner } } fn client(&self) -> RegistryContractClient { RegistryContractClient::new(&self.env, &self.contract_id) } - fn worker_id(&self) -> Symbol { - Symbol::new(&self.env, "worker1") - } + fn worker_id(&self) -> Symbol { Symbol::new(&self.env, "worker1") } - fn zero_hash(&self) -> BytesN<32> { - BytesN::from_array(&self.env, &[0u8; 32]) - } + fn zero_hash(&self) -> BytesN<32> { BytesN::from_array(&self.env, &[0u8; 32]) } fn register_worker(&self, curator: &Address) { self.client().register( @@ -2797,7 +1984,6 @@ mod tests { let t = TestEnv::new(); t.client().add_curator(&t.admin, &t.curator); t.register_worker(&t.curator); - let worker = t.client().get_worker(&t.worker_id()).unwrap(); assert_eq!(worker.owner, t.owner); assert!(worker.is_active); @@ -2807,10 +1993,8 @@ mod tests { fn test_register_stores_hashes() { let t = TestEnv::new(); t.client().add_curator(&t.admin, &t.curator); - let loc = BytesN::from_array(&t.env, &[1u8; 32]); let con = BytesN::from_array(&t.env, &[2u8; 32]); - t.client().register( &t.worker_id(), &t.owner, @@ -2820,7 +2004,6 @@ mod tests { &con, &t.curator, ); - let worker = t.client().get_worker(&t.worker_id()).unwrap(); assert_eq!(worker.location_hash, loc); assert_eq!(worker.contact_hash, con); @@ -2831,10 +2014,8 @@ mod tests { let t = TestEnv::new(); t.client().add_curator(&t.admin, &t.curator); t.register_worker(&t.curator); - let new_loc = BytesN::from_array(&t.env, &[3u8; 32]); let new_con = BytesN::from_array(&t.env, &[4u8; 32]); - t.client().update( &t.worker_id(), &t.owner, @@ -2843,7 +2024,6 @@ mod tests { &new_loc, &new_con, ); - let worker = t.client().get_worker(&t.worker_id()).unwrap(); assert_eq!(worker.location_hash, new_loc); assert_eq!(worker.contact_hash, new_con); @@ -2869,10 +2049,8 @@ mod tests { let t = TestEnv::new(); t.client().add_curator(&t.admin, &t.curator); t.register_worker(&t.curator); - t.client().toggle(&t.worker_id(), &t.owner); assert!(!t.client().get_worker(&t.worker_id()).unwrap().is_active); - t.client().toggle(&t.worker_id(), &t.owner); assert!(t.client().get_worker(&t.worker_id()).unwrap().is_active); } @@ -2882,7 +2060,6 @@ mod tests { let t = TestEnv::new(); t.client().add_curator(&t.admin, &t.curator); t.register_worker(&t.curator); - t.client().deregister(&t.worker_id(), &t.owner); assert!(t.client().get_worker(&t.worker_id()).is_none()); assert_eq!(t.client().list_workers().len(), 0); @@ -2911,8 +2088,7 @@ mod tests { let t = TestEnv::new(); t.client().add_curator(&t.admin, &t.curator); t.register_worker(&t.curator); - t.client() - .update_reputation(&t.admin, &t.worker_id(), &8500); + t.client().update_reputation(&t.admin, &t.worker_id(), &8500); let worker = t.client().get_worker(&t.worker_id()).unwrap(); assert_eq!(worker.reputation, 8500); } @@ -2923,8 +2099,7 @@ mod tests { t.client().add_curator(&t.admin, &t.curator); t.register_worker(&t.curator); assert_eq!( - t.client() - .try_update_reputation(&t.admin, &t.worker_id(), &10_001), + t.client().try_update_reputation(&t.admin, &t.worker_id(), &10_001), Err(Ok(ContractError::ScoreOutOfRange)) ); } @@ -2936,8 +2111,7 @@ mod tests { t.register_worker(&t.curator); let stranger = Address::generate(&t.env); assert_eq!( - t.client() - .try_update_reputation(&stranger, &t.worker_id(), &5000), + t.client().try_update_reputation(&stranger, &t.worker_id(), &5000), Err(Ok(ContractError::MissingRole)) ); } @@ -2946,7 +2120,6 @@ mod tests { fn test_list_workers_paginated() { let t = TestEnv::new(); t.client().add_curator(&t.admin, &t.curator); - for i in 0..5u8 { let id = Symbol::new(&t.env, &std::format!("w{i}")); t.client().register( @@ -2959,35 +2132,23 @@ mod tests { &t.curator, ); } - let page = t.client().list_workers_paginated(&0, &3); assert_eq!(page.len(), 3); - let page2 = t.client().list_workers_paginated(&3, &3); assert_eq!(page2.len(), 2); } - // ------------------------------------------------------------------------- - // Category verification tests (#338) - // ------------------------------------------------------------------------- - + // Category verification tests #[test] fn test_verify_category_stores_record() { let t = TestEnv::new(); t.client().add_curator(&t.admin, &t.curator); t.register_worker(&t.curator); - let cat = Symbol::new(&t.env, "plumber"); - t.client() - .verify_category(&t.curator, &t.worker_id(), &cat, &9999); - - let v = t - .client() - .get_category_verification(&t.worker_id(), &cat) - .unwrap(); + t.client().verify_category(&t.curator, &t.worker_id(), &cat, &9999); + let v = t.client().get_category_verification(&t.worker_id(), &cat).unwrap(); assert_eq!(v.curator, t.curator); assert_eq!(v.expires_at, 9999); - let worker = t.client().get_worker(&t.worker_id()).unwrap(); assert_eq!(worker.verified_categories.len(), 1); } @@ -2997,13 +2158,9 @@ mod tests { let t = TestEnv::new(); t.client().add_curator(&t.admin, &t.curator); t.register_worker(&t.curator); - let cat = Symbol::new(&t.env, "plumber"); - t.client() - .verify_category(&t.curator, &t.worker_id(), &cat, &9999); - t.client() - .verify_category(&t.curator, &t.worker_id(), &cat, &9999); - + t.client().verify_category(&t.curator, &t.worker_id(), &cat, &9999); + t.client().verify_category(&t.curator, &t.worker_id(), &cat, &9999); let worker = t.client().get_worker(&t.worker_id()).unwrap(); assert_eq!(worker.verified_categories.len(), 1); } @@ -3025,16 +2182,12 @@ mod tests { ); } - // ------------------------------------------------------------------------- - // Batch registration tests (#340) - // ------------------------------------------------------------------------- - + // Batch registration tests #[test] fn test_batch_register_all_succeed() { let t = TestEnv::new(); t.client().add_curator(&t.admin, &t.curator); - - let ids = soroban_sdk::vec![&t.env, Symbol::new(&t.env, "b1"), Symbol::new(&t.env, "b2"),]; + let ids = soroban_sdk::vec![&t.env, Symbol::new(&t.env, "b1"), Symbol::new(&t.env, "b2")]; let owners = soroban_sdk::vec![&t.env, t.owner.clone(), t.owner.clone()]; let names = soroban_sdk::vec![ &t.env, @@ -3047,11 +2200,8 @@ mod tests { Symbol::new(&t.env, "welder"), ]; let hashes = soroban_sdk::vec![&t.env, t.zero_hash(), t.zero_hash()]; - - let results = t - .client() - .batch_register(&t.curator, &ids, &owners, &names, &cats, &hashes, &hashes); - + let results = + t.client().batch_register(&t.curator, &ids, &owners, &names, &cats, &hashes, &hashes); assert_eq!(results.len(), 2); assert!(results.get(0).unwrap().success); assert!(results.get(1).unwrap().success); @@ -3062,13 +2212,8 @@ mod tests { fn test_batch_register_partial_success_on_duplicate() { let t = TestEnv::new(); t.client().add_curator(&t.admin, &t.curator); - t.register_worker(&t.curator); // registers "worker1" - - let ids = soroban_sdk::vec![ - &t.env, - t.worker_id(), // duplicate - Symbol::new(&t.env, "b2"), - ]; + t.register_worker(&t.curator); + let ids = soroban_sdk::vec![&t.env, t.worker_id(), Symbol::new(&t.env, "b2")]; let owners = soroban_sdk::vec![&t.env, t.owner.clone(), t.owner.clone()]; let names = soroban_sdk::vec![ &t.env, @@ -3081,27 +2226,22 @@ mod tests { Symbol::new(&t.env, "welder"), ]; let hashes = soroban_sdk::vec![&t.env, t.zero_hash(), t.zero_hash()]; - - let results = t - .client() - .batch_register(&t.curator, &ids, &owners, &names, &cats, &hashes, &hashes); - - assert!(!results.get(0).unwrap().success); // duplicate + let results = + t.client().batch_register(&t.curator, &ids, &owners, &names, &cats, &hashes, &hashes); + assert!(!results.get(0).unwrap().success); assert!(results.get(1).unwrap().success); - assert_eq!(t.client().worker_count(), 2); // original + b2 + assert_eq!(t.client().worker_count(), 2); } #[test] fn test_batch_register_too_large_panics() { let t = TestEnv::new(); t.client().add_curator(&t.admin, &t.curator); - let mut ids = Vec::new(&t.env); let mut owners = Vec::new(&t.env); let mut names = Vec::new(&t.env); let mut cats = Vec::new(&t.env); let mut hashes = Vec::new(&t.env); - for i in 0..21u32 { let id_str = std::format!("w{i}"); ids.push_back(Symbol::new(&t.env, &id_str)); @@ -3110,18 +2250,13 @@ mod tests { cats.push_back(Symbol::new(&t.env, "plumber")); hashes.push_back(t.zero_hash()); } - assert_eq!( - t.client() - .try_batch_register(&t.curator, &ids, &owners, &names, &cats, &hashes, &hashes), + t.client().try_batch_register(&t.curator, &ids, &owners, &names, &cats, &hashes, &hashes), Err(Ok(ContractError::BatchTooLarge)) ); } - // ------------------------------------------------------------------------- - // Staking tests (#341) - // ------------------------------------------------------------------------- - + // Staking tests struct StakeTestEnv { base: TestEnv, token_addr: Address, @@ -3135,7 +2270,6 @@ mod tests { let token_id = base.env.register_stellar_asset_contract_v2(admin.clone()); let token_addr = token_id.address(); StellarAssetClient::new(&base.env, &token_addr).mint(&base.owner, &1_000_000); - // Mint to contract for reward payouts StellarAssetClient::new(&base.env, &token_addr).mint(&base.contract_id, &1_000_000); StakeTestEnv { base, token_addr } } @@ -3157,15 +2291,10 @@ mod tests { let s = StakeTestEnv::new(); s.base.client().add_curator(&s.base.admin, &s.base.curator); s.base.register_worker(&s.base.curator); - s.set_time(1000); - s.base - .client() - .stake(&s.base.owner, &s.base.worker_id(), &s.token_addr, &500_000); - + s.base.client().stake(&s.base.owner, &s.base.worker_id(), &s.token_addr, &500_000); let info = s.base.client().get_stake_info(&s.base.worker_id()).unwrap(); assert_eq!(info.amount, 500_000); - let worker = s.base.client().get_worker(&s.base.worker_id()).unwrap(); assert_eq!(worker.staked_amount, 500_000); } @@ -3175,24 +2304,13 @@ mod tests { let s = StakeTestEnv::new(); s.base.client().add_curator(&s.base.admin, &s.base.curator); s.base.register_worker(&s.base.curator); - s.set_time(1000); - s.base - .client() - .stake(&s.base.owner, &s.base.worker_id(), &s.token_addr, &500_000); - + s.base.client().stake(&s.base.owner, &s.base.worker_id(), &s.token_addr, &500_000); s.set_time(2000); - s.base - .client() - .request_unstake(&s.base.owner, &s.base.worker_id()); - - // advance past cooldown + s.base.client().request_unstake(&s.base.owner, &s.base.worker_id()); s.set_time(2000 + 604_800 + 1); s.base.client().unstake(&s.base.owner, &s.base.worker_id()); - - // owner gets back at least their stake assert!(s.token_balance(&s.base.owner) >= 500_000); - let info = s.base.client().get_stake_info(&s.base.worker_id()).unwrap(); assert_eq!(info.amount, 0); } @@ -3202,18 +2320,11 @@ mod tests { let s = StakeTestEnv::new(); s.base.client().add_curator(&s.base.admin, &s.base.curator); s.base.register_worker(&s.base.curator); - s.set_time(1000); - s.base - .client() - .stake(&s.base.owner, &s.base.worker_id(), &s.token_addr, &100_000); - s.base - .client() - .request_unstake(&s.base.owner, &s.base.worker_id()); + s.base.client().stake(&s.base.owner, &s.base.worker_id(), &s.token_addr, &100_000); + s.base.client().request_unstake(&s.base.owner, &s.base.worker_id()); assert_eq!( - s.base - .client() - .try_unstake(&s.base.owner, &s.base.worker_id()), + s.base.client().try_unstake(&s.base.owner, &s.base.worker_id()), Err(Ok(ContractError::CooldownNotElapsed)) ); } @@ -3223,39 +2334,24 @@ mod tests { let s = StakeTestEnv::new(); s.base.client().add_curator(&s.base.admin, &s.base.curator); s.base.register_worker(&s.base.curator); - s.set_time(1000); - s.base - .client() - .stake(&s.base.owner, &s.base.worker_id(), &s.token_addr, &100_000); - s.base - .client() - .request_unstake(&s.base.owner, &s.base.worker_id()); + s.base.client().stake(&s.base.owner, &s.base.worker_id(), &s.token_addr, &100_000); + s.base.client().request_unstake(&s.base.owner, &s.base.worker_id()); assert_eq!( - s.base - .client() - .try_request_unstake(&s.base.owner, &s.base.worker_id()), + s.base.client().try_request_unstake(&s.base.owner, &s.base.worker_id()), Err(Ok(ContractError::UnstakeAlreadyRequested)) ); } - // ------------------------------------------------------------------------- - // Location verification tests (#352) - // ------------------------------------------------------------------------- - + // Location verification tests #[test] fn test_verify_location_stores_record() { let t = TestEnv::new(); t.client().add_curator(&t.admin, &t.curator); t.register_worker(&t.curator); - let verifier = Address::generate(&t.env); t.client().verify_location(&verifier, &t.worker_id(), &9999); - - let v = t - .client() - .get_location_verification(&t.worker_id()) - .unwrap(); + let v = t.client().get_location_verification(&t.worker_id()).unwrap(); assert_eq!(v.verifier, verifier); assert_eq!(v.expires_at, 9999); } @@ -3266,25 +2362,18 @@ mod tests { let verifier = Address::generate(&t.env); let nonexistent = Symbol::new(&t.env, "nonexistent"); assert_eq!( - t.client() - .try_verify_location(&verifier, &nonexistent, &9999), + t.client().try_verify_location(&verifier, &nonexistent, &9999), Err(Ok(ContractError::WorkerNotFound)) ); } - // ------------------------------------------------------------------------- - // Availability status tests (#376) - // ------------------------------------------------------------------------- - + // Availability status tests #[test] fn test_update_availability_stores_status() { let t = TestEnv::new(); t.client().add_curator(&t.admin, &t.curator); t.register_worker(&t.curator); - - t.client() - .update_availability(&t.worker_id(), &t.owner, &true, &9999); - + t.client().update_availability(&t.worker_id(), &t.owner, &true, &9999); let status = t.client().get_availability(&t.worker_id()).unwrap(); assert!(status.is_available); assert_eq!(status.expires_at, 9999); @@ -3295,16 +2384,10 @@ mod tests { let t = TestEnv::new(); t.client().add_curator(&t.admin, &t.curator); t.register_worker(&t.curator); - - t.client() - .update_availability(&t.worker_id(), &t.owner, &true, &0); - let status1 = t.client().get_availability(&t.worker_id()).unwrap(); - assert!(status1.is_available); - - t.client() - .update_availability(&t.worker_id(), &t.owner, &false, &0); - let status2 = t.client().get_availability(&t.worker_id()).unwrap(); - assert!(!status2.is_available); + t.client().update_availability(&t.worker_id(), &t.owner, &true, &0); + assert!(t.client().get_availability(&t.worker_id()).unwrap().is_available); + t.client().update_availability(&t.worker_id(), &t.owner, &false, &0); + assert!(!t.client().get_availability(&t.worker_id()).unwrap().is_available); } #[test] @@ -3312,11 +2395,9 @@ mod tests { let t = TestEnv::new(); t.client().add_curator(&t.admin, &t.curator); t.register_worker(&t.curator); - let stranger = Address::generate(&t.env); assert_eq!( - t.client() - .try_update_availability(&t.worker_id(), &stranger, &true, &0), + t.client().try_update_availability(&t.worker_id(), &stranger, &true, &0), Err(Ok(ContractError::NotAuthorized)) ); } @@ -3326,34 +2407,21 @@ mod tests { let t = TestEnv::new(); let nonexistent = Symbol::new(&t.env, "nonexistent"); assert_eq!( - t.client() - .try_update_availability(&nonexistent, &t.owner, &true, &0), + t.client().try_update_availability(&nonexistent, &t.owner, &true, &0), Err(Ok(ContractError::WorkerNotFound)) ); } - // ------------------------------------------------------------------------- - // Contract upgrade tests (#375) - // ------------------------------------------------------------------------- - - /// State-migration data integrity: a schema migration must preserve all - /// existing worker storage. (A real WASM-swap upgrade is exercised in - /// `test.rs` behind the `wasm-upgrade-tests` feature, since the in-process - /// host cannot install a WASM blob from a dummy hash.) + // Schema migration tests #[test] fn test_upgrade_preserves_storage() { let t = TestEnv::new(); t.client().add_curator(&t.admin, &t.curator); t.register_worker(&t.curator); - let worker_before = t.client().get_worker(&t.worker_id()).unwrap(); assert_eq!(worker_before.name, String::from_str(&t.env, "Alice")); assert_eq!(t.client().get_schema_version(), 1u32); - - // Run a schema migration (the data-integrity path of an upgrade). t.client().migrate(&t.admin, &1u32); - - // Storage must be preserved across the migration. let worker_after = t.client().get_worker(&t.worker_id()).unwrap(); assert_eq!(worker_after.name, worker_before.name); assert_eq!(worker_after.owner, worker_before.owner); @@ -3363,16 +2431,12 @@ mod tests { #[test] fn test_upgrade_requires_upgrader_role() { - // Build a contract whose admin was NOT granted ROLE_UPGRADER. let env = Env::default(); env.mock_all_auths(); let admin = Address::generate(&env); let contract_id = env.register_contract(None, RegistryContract); let client = RegistryContractClient::new(&env, &contract_id); client.initialize(&admin); - - // `upgrade` requires the stored admin to hold ROLE_UPGRADER, which was - // never granted here, so this must return Err with MissingRole. let dummy_hash = BytesN::from_array(&env, &[1u8; 32]); assert_eq!( client.try_upgrade(&dummy_hash), @@ -3380,10 +2444,6 @@ mod tests { ); } - // ------------------------------------------------------------------------- - // Migration tests (#535) - // ------------------------------------------------------------------------- - #[test] fn test_initial_schema_version_is_1() { let t = TestEnv::new(); @@ -3393,7 +2453,6 @@ mod tests { #[test] fn test_migrate_v1_to_v2_bumps_version() { let t = TestEnv::new(); - assert_eq!(t.client().get_schema_version(), 1u32); t.client().migrate(&t.admin, &1u32); assert_eq!(t.client().get_schema_version(), 2u32); } @@ -3402,7 +2461,6 @@ mod tests { fn test_migrate_double_run_panics() { let t = TestEnv::new(); t.client().migrate(&t.admin, &1u32); - // Running again with the same expected_version should error assert_eq!( t.client().try_migrate(&t.admin, &1u32), Err(Ok(ContractError::WrongSchemaVersion)) @@ -3412,7 +2470,6 @@ mod tests { #[test] fn test_migrate_wrong_version_panics() { let t = TestEnv::new(); - // Current version is 1, passing 2 should error assert_eq!( t.client().try_migrate(&t.admin, &2u32), Err(Ok(ContractError::WrongSchemaVersion)) @@ -3432,37 +2489,25 @@ mod tests { #[test] fn test_migrate_sequential_versions() { let t = TestEnv::new(); - // v1 → v2 t.client().migrate(&t.admin, &1u32); assert_eq!(t.client().get_schema_version(), 2u32); - // v2 → v3 t.client().migrate(&t.admin, &2u32); assert_eq!(t.client().get_schema_version(), 3u32); } - // ------------------------------------------------------------------------- - // #529: Pagination tests - // ------------------------------------------------------------------------- - + // Pagination tests #[test] fn test_list_workers_page_basic() { let t = TestEnv::new(); t.client().add_curator(&t.admin, &t.curator); - for i in 0..5u8 { let id_str = std::format!("p{i}"); let id = Symbol::new(&t.env, &id_str); t.client().register( - &id, - &t.owner, - &String::from_str(&t.env, "W"), - &Symbol::new(&t.env, "plumber"), - &t.zero_hash(), - &t.zero_hash(), - &t.curator, + &id, &t.owner, &String::from_str(&t.env, "W"), + &Symbol::new(&t.env, "plumber"), &t.zero_hash(), &t.zero_hash(), &t.curator, ); } - let page = t.client().list_workers_page(&0, &3); assert_eq!(page.ids.len(), 3); assert_eq!(page.total, 5); @@ -3472,21 +2517,14 @@ mod tests { fn test_list_workers_page_last_page() { let t = TestEnv::new(); t.client().add_curator(&t.admin, &t.curator); - for i in 0..5u8 { let id_str = std::format!("q{i}"); let id = Symbol::new(&t.env, &id_str); t.client().register( - &id, - &t.owner, - &String::from_str(&t.env, "W"), - &Symbol::new(&t.env, "plumber"), - &t.zero_hash(), - &t.zero_hash(), - &t.curator, + &id, &t.owner, &String::from_str(&t.env, "W"), + &Symbol::new(&t.env, "plumber"), &t.zero_hash(), &t.zero_hash(), &t.curator, ); } - let page = t.client().list_workers_page(&3, &10); assert_eq!(page.ids.len(), 2); assert_eq!(page.total, 5); @@ -3497,7 +2535,6 @@ mod tests { let t = TestEnv::new(); t.client().add_curator(&t.admin, &t.curator); t.register_worker(&t.curator); - let page = t.client().list_workers_page(&100, &10); assert_eq!(page.ids.len(), 0); assert_eq!(page.total, 1); @@ -3511,18 +2548,12 @@ mod tests { assert_eq!(page.total, 0); } - // ------------------------------------------------------------------------- - // #531: On-chain category tests - // ------------------------------------------------------------------------- - + // Category management tests #[test] fn test_add_and_list_categories() { let t = TestEnv::new(); - t.client() - .add_category(&t.admin, &Symbol::new(&t.env, "plumber")); - t.client() - .add_category(&t.admin, &Symbol::new(&t.env, "welder")); - + t.client().add_category(&t.admin, &Symbol::new(&t.env, "plumber")); + t.client().add_category(&t.admin, &Symbol::new(&t.env, "welder")); let cats = t.client().list_categories(); assert_eq!(cats.len(), 2); } @@ -3530,20 +2561,16 @@ mod tests { #[test] fn test_add_category_idempotent() { let t = TestEnv::new(); - t.client() - .add_category(&t.admin, &Symbol::new(&t.env, "plumber")); - t.client() - .add_category(&t.admin, &Symbol::new(&t.env, "plumber")); + t.client().add_category(&t.admin, &Symbol::new(&t.env, "plumber")); + t.client().add_category(&t.admin, &Symbol::new(&t.env, "plumber")); assert_eq!(t.client().list_categories().len(), 1); } #[test] fn test_remove_category() { let t = TestEnv::new(); - t.client() - .add_category(&t.admin, &Symbol::new(&t.env, "plumber")); - t.client() - .remove_category(&t.admin, &Symbol::new(&t.env, "plumber")); + t.client().add_category(&t.admin, &Symbol::new(&t.env, "plumber")); + t.client().remove_category(&t.admin, &Symbol::new(&t.env, "plumber")); assert_eq!(t.client().list_categories().len(), 0); } @@ -3551,9 +2578,7 @@ mod tests { fn test_register_valid_category_succeeds() { let t = TestEnv::new(); t.client().add_curator(&t.admin, &t.curator); - t.client() - .add_category(&t.admin, &Symbol::new(&t.env, "plumber")); - // Should not panic + t.client().add_category(&t.admin, &Symbol::new(&t.env, "plumber")); t.register_worker(&t.curator); } @@ -3561,17 +2586,10 @@ mod tests { fn test_register_invalid_category_panics() { let t = TestEnv::new(); t.client().add_curator(&t.admin, &t.curator); - t.client() - .add_category(&t.admin, &Symbol::new(&t.env, "welder")); - // "plumber" is not in the on-chain list + t.client().add_category(&t.admin, &Symbol::new(&t.env, "welder")); let res = t.client().try_register( - &t.worker_id(), - &t.owner, - &String::from_str(&t.env, "Alice"), - &Symbol::new(&t.env, "plumber"), - &t.zero_hash(), - &t.zero_hash(), - &t.curator, + &t.worker_id(), &t.owner, &String::from_str(&t.env, "Alice"), + &Symbol::new(&t.env, "plumber"), &t.zero_hash(), &t.zero_hash(), &t.curator, ); assert_eq!(res, Err(Ok(ContractError::UnknownCategory))); } @@ -3580,20 +2598,15 @@ mod tests { fn test_register_no_categories_set_allows_any() { let t = TestEnv::new(); t.client().add_curator(&t.admin, &t.curator); - // No categories set — any category is allowed t.register_worker(&t.curator); } - // ------------------------------------------------------------------------- - // #530: Upgrade timelock tests - // ------------------------------------------------------------------------- - + // Upgrade timelock tests #[test] fn test_propose_upgrade_stores_pending() { let t = TestEnv::new(); let hash = BytesN::from_array(&t.env, &[9u8; 32]); t.client().propose_upgrade(&t.admin, &hash); - let pending = t.client().get_pending_upgrade().unwrap(); assert_eq!(pending.wasm_hash, hash); } From de21bdd07966a4e6051ee78f141a6b4998afd60b Mon Sep 17 00:00:00 2001 From: Quanwritescodes Date: Fri, 28 Aug 2026 14:42:04 +0100 Subject: [PATCH 04/10] docs(scripts): add README documenting deployment scripts (issue #1244) --- packages/contracts/scripts/README.md | 173 +++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 packages/contracts/scripts/README.md diff --git a/packages/contracts/scripts/README.md b/packages/contracts/scripts/README.md new file mode 100644 index 00000000..751d4258 --- /dev/null +++ b/packages/contracts/scripts/README.md @@ -0,0 +1,173 @@ +# BlueCollar Contract Deployment Scripts + +This directory contains **manual deployment scripts** for the BlueCollar Soroban smart contracts. They are not invoked by CI — they are run by an operator when deploying or upgrading a contract on testnet or mainnet. + +## Scripts + +| Script | Purpose | +|--------|---------| +| `deploy-registry.sh` | Build and deploy the Registry contract | +| `deploy-market.sh` | Build and deploy the Market contract | + +--- + +## Prerequisites + +Before running any script, make sure the following are installed and available on your `$PATH`: + +| Tool | Install | +|------|---------| +| **Rust** with `wasm32v1-none` target | `rustup target add wasm32v1-none` | +| **Stellar CLI** | `cargo install --locked stellar-cli` | +| **Python 3** | Required for JSON manipulation (`deployments.json` update) | +| **bash** | Scripts use `bash` with `set -euo pipefail` | + +--- + +## `deploy-registry.sh` + +Builds the Registry contract WASM, deploys it to Stellar, initialises it with an admin address, and records the deployed contract ID in `deployments.json`. + +### Usage + +```bash +./scripts/deploy-registry.sh \ + --network testnet|mainnet \ + --source \ + --admin +``` + +### Flags + +| Flag | Required | Description | +|------|----------|-------------| +| `--network` | ✅ | Target network: `testnet` or `mainnet` | +| `--source` | ✅ | Stellar secret key or `stellar keys` alias used to sign the deploy transaction | +| `--admin` | ✅ | Stellar address that becomes the contract admin (granted `ROLE_ADMIN` on initialisation) | + +### What it does + +1. Runs `cargo build --release --target wasm32v1-none --package bluecollar-registry` +2. Deploys the compiled WASM via `stellar contract deploy` +3. Invokes `initialize --admin ` on the newly deployed contract +4. Appends a record to `deployments.json` (creates the file if it doesn't exist) + +### Example + +```bash +./scripts/deploy-registry.sh \ + --network testnet \ + --source SXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX \ + --admin GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX +``` + +### Output + +``` +==> Building registry contract... +==> Deploying to testnet... +==> Contract ID: CXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX +==> Initializing registry contract... +==> Done. Registry contract deployed at CXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX +``` + +--- + +## `deploy-market.sh` + +Builds the Market contract WASM, deploys it to Stellar, initialises it with an admin, a protocol fee, and a fee recipient, and records the deployed contract ID in `deployments.json`. + +### Usage + +```bash +./scripts/deploy-market.sh \ + --network testnet|mainnet \ + --source \ + --admin \ + --fee-bps <0-500> \ + --fee-recipient +``` + +### Flags + +| Flag | Required | Description | +|------|----------|-------------| +| `--network` | ✅ | Target network: `testnet` or `mainnet` | +| `--source` | ✅ | Stellar secret key or `stellar keys` alias used to sign the deploy transaction | +| `--admin` | ✅ | Stellar address that becomes the contract admin | +| `--fee-bps` | ✅ | Protocol fee in basis points (0–500, i.e. 0%–5%). Defaults to `0` if omitted | +| `--fee-recipient` | ✅ | Stellar address that receives collected protocol fees | + +### What it does + +1. Runs `cargo build --release --target wasm32v1-none --package bluecollar-market` +2. Deploys the compiled WASM via `stellar contract deploy` +3. Invokes `initialize --admin --fee_bps --fee_recipient ` +4. Appends a record to `deployments.json` + +### Example + +```bash +./scripts/deploy-market.sh \ + --network testnet \ + --source SXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX \ + --admin GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX \ + --fee-bps 100 \ + --fee-recipient GYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY +``` + +### Output + +``` +==> Building market contract... +==> Deploying to testnet... +==> Contract ID: CXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX +==> Initializing market contract... +==> Done. Market contract deployed at CXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX +``` + +--- + +## `deployments.json` + +Both scripts write a record to `packages/contracts/deployments.json` after a successful deploy. The file has this structure: + +```json +{ + "registry": { + "testnet": { + "contract_id": "CXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + "deployed_at": "2026-01-01T00:00:00Z" + } + }, + "market": { + "testnet": { + "contract_id": "CXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + "deployed_at": "2026-01-01T00:00:00Z" + } + } +} +``` + +Each deployment overwrites the previous record for that contract + network combination. The file is safe to commit — it contains only contract IDs and timestamps, no secrets. + +--- + +## CI Integration + +These scripts are **not run by CI**. GitHub Actions workflows (`ci.yml`, `api-tests.yml`, etc.) run unit/integration tests via `cargo test` only — they do not deploy to any network. + +Deployments are a manual operator action, typically performed: +- When a new contract version is ready for testnet review +- Before a mainnet release (following the [Mainnet Launch Checklist](../../docs/MAINNET_LAUNCH_CHECKLIST.md)) +- After a contract upgrade (WASM install + `upgrade` invocation) + +--- + +## Security Notes + +> ⚠️ **Never commit your secret key** (`--source`) to version control. + +- Use a dedicated deployment key with minimal XLM for fees. Do not reuse your personal wallet. +- On mainnet, prefer using a hardware wallet or `stellar keys` with a local keystore rather than passing the raw secret key on the command line. +- After deploying, transfer the `ROLE_ADMIN` to a multisig or hardware-wallet-controlled address using `grant_role` / `set_admin`. From 080a614040b15dcec6e46a2285865871a48e83ca Mon Sep 17 00:00:00 2001 From: Quanwritescodes Date: Fri, 28 Aug 2026 14:42:16 +0100 Subject: [PATCH 05/10] feat(market): add benchmark harness (issue #1245) --- .../contracts/market/src/benchmarks.rs | 257 ++++++++++++++++++ 1 file changed, 257 insertions(+) create mode 100644 packages/contracts/contracts/market/src/benchmarks.rs diff --git a/packages/contracts/contracts/market/src/benchmarks.rs b/packages/contracts/contracts/market/src/benchmarks.rs new file mode 100644 index 00000000..8a509964 --- /dev/null +++ b/packages/contracts/contracts/market/src/benchmarks.rs @@ -0,0 +1,257 @@ +//! # Market Contract — Benchmark Harness +//! +//! Measures CPU instruction cost and memory byte cost for the key +//! operations in the Market contract using the Soroban test environment's +//! built-in budget. +//! +//! Run with: +//! ``` +//! cd packages/contracts +//! cargo test -p bluecollar-market benchmarks -- --nocapture +//! ``` + +#[cfg(test)] +mod benchmarks { + extern crate std; + + use crate::MarketContract; + use soroban_sdk::{ + testutils::{Address as _, Ledger}, + token::StellarAssetClient, + Address, Env, Symbol, + }; + + /// Shared test harness for all market benchmarks. + struct BenchEnv { + env: Env, + contract_id: Address, + admin: Address, + payer: Address, + worker: Address, + token_addr: Address, + } + + impl BenchEnv { + fn new() -> Self { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let payer = Address::generate(&env); + let worker = Address::generate(&env); + + let token_id = env.register_stellar_asset_contract_v2(admin.clone()); + let token_addr = token_id.address(); + + // Mint generous balance to payer and contract (for reward payouts). + StellarAssetClient::new(&env, &token_addr).mint(&payer, &100_000_000); + + let contract_id = env.register_contract(None, MarketContract); + crate::MarketContractClient::new(&env, &contract_id) + .initialize(&admin, &100, &admin); + + // Mint some tokens to the contract so it can pay out escrow releases. + StellarAssetClient::new(&env, &token_addr) + .mint(&contract_id, &100_000_000); + + BenchEnv { env, contract_id, admin, payer, worker, token_addr } + } + + fn client(&self) -> crate::MarketContractClient { + crate::MarketContractClient::new(&self.env, &self.contract_id) + } + + fn set_time(&self, ts: u64) { + let mut info = self.env.ledger().get(); + info.timestamp = ts; + self.env.ledger().set(info); + } + } + + // ------------------------------------------------------------------------- + // Benchmark: tip + // ------------------------------------------------------------------------- + + #[test] + fn bench_tip() { + let b = BenchEnv::new(); + + // Reset budget so only the tip call is measured. + b.env.budget().reset_unlimited(); + + b.client().tip(&b.payer, &b.worker, &b.token_addr, &1_000_000); + + let cpu = b.env.budget().cpu_instruction_cost(); + let mem = b.env.budget().memory_bytes_cost(); + + std::println!( + "[BENCH] market::tip cpu={} instructions mem={} bytes", + cpu, mem + ); + } + + // ------------------------------------------------------------------------- + // Benchmark: create_escrow + // ------------------------------------------------------------------------- + + #[test] + fn bench_create_escrow() { + let b = BenchEnv::new(); + let id = Symbol::new(&b.env, "esc1"); + + b.env.budget().reset_unlimited(); + + b.client().create_escrow( + &id, + &b.payer, + &b.worker, + &b.token_addr, + &1_000_000, + &9_999_999, + ); + + let cpu = b.env.budget().cpu_instruction_cost(); + let mem = b.env.budget().memory_bytes_cost(); + + std::println!( + "[BENCH] market::create_escrow cpu={} instructions mem={} bytes", + cpu, mem + ); + } + + // ------------------------------------------------------------------------- + // Benchmark: release_escrow + // ------------------------------------------------------------------------- + + #[test] + fn bench_release_escrow() { + let b = BenchEnv::new(); + let id = Symbol::new(&b.env, "esc2"); + + // Setup: create the escrow first (not measured). + b.client().create_escrow( + &id, + &b.payer, + &b.worker, + &b.token_addr, + &1_000_000, + &9_999_999, + ); + + // Now measure release only. + b.env.budget().reset_unlimited(); + + b.client().release_escrow(&id, &b.payer); + + let cpu = b.env.budget().cpu_instruction_cost(); + let mem = b.env.budget().memory_bytes_cost(); + + std::println!( + "[BENCH] market::release_escrow cpu={} instructions mem={} bytes", + cpu, mem + ); + } + + // ------------------------------------------------------------------------- + // Benchmark: cancel_escrow (after expiry) + // ------------------------------------------------------------------------- + + #[test] + fn bench_cancel_escrow() { + let b = BenchEnv::new(); + let id = Symbol::new(&b.env, "esc3"); + + b.set_time(1000); + b.client().create_escrow( + &id, + &b.payer, + &b.worker, + &b.token_addr, + &1_000_000, + &2000, + ); + + b.set_time(3000); + b.env.budget().reset_unlimited(); + + b.client().cancel_escrow(&id, &b.payer); + + let cpu = b.env.budget().cpu_instruction_cost(); + let mem = b.env.budget().memory_bytes_cost(); + + std::println!( + "[BENCH] market::cancel_escrow cpu={} instructions mem={} bytes", + cpu, mem + ); + } + + // ------------------------------------------------------------------------- + // Benchmark: create_multisig_escrow (2-of-2) + // ------------------------------------------------------------------------- + + #[test] + fn bench_create_multisig_escrow() { + let b = BenchEnv::new(); + let id = Symbol::new(&b.env, "ms1"); + let s1 = Address::generate(&b.env); + let s2 = Address::generate(&b.env); + let signers = soroban_sdk::vec![&b.env, s1, s2]; + + b.env.budget().reset_unlimited(); + + b.client().create_multisig_escrow( + &id, + &b.payer, + &b.worker, + &b.token_addr, + &1_000_000, + &9_999_999, + &signers, + &2, + ); + + let cpu = b.env.budget().cpu_instruction_cost(); + let mem = b.env.budget().memory_bytes_cost(); + + std::println!( + "[BENCH] market::create_multisig_escrow(2-of-2) cpu={} instructions mem={} bytes", + cpu, mem + ); + } + + // ------------------------------------------------------------------------- + // Benchmark: approve_multisig_release (final approval that triggers transfer) + // ------------------------------------------------------------------------- + + #[test] + fn bench_approve_multisig_release_final() { + let b = BenchEnv::new(); + let id = Symbol::new(&b.env, "ms2"); + let s1 = Address::generate(&b.env); + let signers = soroban_sdk::vec![&b.env, s1.clone()]; + + b.client().create_multisig_escrow( + &id, + &b.payer, + &b.worker, + &b.token_addr, + &1_000_000, + &9_999_999, + &signers, + &1, + ); + + // Single approval = threshold met = fund transfer happens. + b.env.budget().reset_unlimited(); + + b.client().approve_multisig_release(&id, &s1); + + let cpu = b.env.budget().cpu_instruction_cost(); + let mem = b.env.budget().memory_bytes_cost(); + + std::println!( + "[BENCH] market::approve_multisig_release(1-of-1, transfers) cpu={} instructions mem={} bytes", + cpu, mem + ); + } +} From 223a86fee934de5e46548abba5136a8ef1792640 Mon Sep 17 00:00:00 2001 From: Quanwritescodes Date: Fri, 28 Aug 2026 14:42:17 +0100 Subject: [PATCH 06/10] feat(registry): add benchmark harness (issue #1245) --- .../contracts/registry/src/benchmarks.rs | 269 ++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 packages/contracts/contracts/registry/src/benchmarks.rs diff --git a/packages/contracts/contracts/registry/src/benchmarks.rs b/packages/contracts/contracts/registry/src/benchmarks.rs new file mode 100644 index 00000000..90ad2253 --- /dev/null +++ b/packages/contracts/contracts/registry/src/benchmarks.rs @@ -0,0 +1,269 @@ +//! # Registry Contract — Benchmark Harness +//! +//! Measures CPU instruction cost and memory byte cost for the key +//! operations in the Registry contract using the Soroban test environment's +//! built-in budget. +//! +//! Run with: +//! ``` +//! cd packages/contracts +//! cargo test -p bluecollar-registry benchmarks -- --nocapture +//! ``` + +#[cfg(test)] +mod benchmarks { + extern crate std; + + use crate::{RegistryContract, ROLE_CURATOR_MGR, ROLE_PAUSER, ROLE_REP_MGR, ROLE_UPGRADER}; + use soroban_sdk::{ + testutils::{Address as _, Ledger}, + token::StellarAssetClient, + Address, BytesN, Env, String, Symbol, + }; + + /// Shared test harness for all registry benchmarks. + struct BenchEnv { + env: Env, + contract_id: Address, + admin: Address, + curator: Address, + owner: Address, + } + + impl BenchEnv { + fn new() -> Self { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let curator = Address::generate(&env); + let owner = Address::generate(&env); + + let contract_id = env.register_contract(None, RegistryContract); + let client = crate::RegistryContractClient::new(&env, &contract_id); + client.initialize(&admin); + client.grant_role(&admin, &Symbol::new(&env, ROLE_PAUSER), &admin); + client.grant_role(&admin, &Symbol::new(&env, ROLE_CURATOR_MGR), &admin); + client.grant_role(&admin, &Symbol::new(&env, ROLE_REP_MGR), &admin); + client.grant_role(&admin, &Symbol::new(&env, ROLE_UPGRADER), &admin); + client.add_curator(&admin, &curator); + + BenchEnv { env, contract_id, admin, curator, owner } + } + + fn client(&self) -> crate::RegistryContractClient { + crate::RegistryContractClient::new(&self.env, &self.contract_id) + } + + fn zero_hash(&self) -> BytesN<32> { + BytesN::from_array(&self.env, &[0u8; 32]) + } + + fn register_one(&self, id_str: &str) { + self.client().register( + &Symbol::new(&self.env, id_str), + &self.owner, + &String::from_str(&self.env, "Worker"), + &Symbol::new(&self.env, "plumber"), + &self.zero_hash(), + &self.zero_hash(), + &self.curator, + ); + } + } + + // ------------------------------------------------------------------------- + // Benchmark: register (single worker — the "mint" equivalent) + // ------------------------------------------------------------------------- + + #[test] + fn bench_register_single() { + let b = BenchEnv::new(); + + b.env.budget().reset_unlimited(); + + b.register_one("w1"); + + let cpu = b.env.budget().cpu_instruction_cost(); + let mem = b.env.budget().memory_bytes_cost(); + + std::println!( + "[BENCH] registry::register(1 worker) cpu={} instructions mem={} bytes", + cpu, mem + ); + } + + // ------------------------------------------------------------------------- + // Benchmark: batch_register (10 workers) + // ------------------------------------------------------------------------- + + #[test] + fn bench_batch_register_10() { + let b = BenchEnv::new(); + + let mut ids = soroban_sdk::Vec::new(&b.env); + let mut owners = soroban_sdk::Vec::new(&b.env); + let mut names = soroban_sdk::Vec::new(&b.env); + let mut cats = soroban_sdk::Vec::new(&b.env); + let mut hashes: soroban_sdk::Vec> = soroban_sdk::Vec::new(&b.env); + + for i in 0..10u32 { + let id_str = std::format!("bw{i}"); + ids.push_back(Symbol::new(&b.env, &id_str)); + owners.push_back(b.owner.clone()); + names.push_back(String::from_str(&b.env, "Worker")); + cats.push_back(Symbol::new(&b.env, "plumber")); + hashes.push_back(b.zero_hash()); + } + + b.env.budget().reset_unlimited(); + + b.client().batch_register( + &b.curator, + &ids, + &owners, + &names, + &cats, + &hashes, + &hashes, + ); + + let cpu = b.env.budget().cpu_instruction_cost(); + let mem = b.env.budget().memory_bytes_cost(); + + std::println!( + "[BENCH] registry::batch_register(10 workers) cpu={} instructions mem={} bytes", + cpu, mem + ); + } + + // ------------------------------------------------------------------------- + // Benchmark: toggle (transfer-equivalent state update) + // ------------------------------------------------------------------------- + + #[test] + fn bench_toggle() { + let b = BenchEnv::new(); + b.register_one("tw1"); + + b.env.budget().reset_unlimited(); + + b.client().toggle(&Symbol::new(&b.env, "tw1"), &b.owner); + + let cpu = b.env.budget().cpu_instruction_cost(); + let mem = b.env.budget().memory_bytes_cost(); + + std::println!( + "[BENCH] registry::toggle cpu={} instructions mem={} bytes", + cpu, mem + ); + } + + // ------------------------------------------------------------------------- + // Benchmark: update_reputation + // ------------------------------------------------------------------------- + + #[test] + fn bench_update_reputation() { + let b = BenchEnv::new(); + b.register_one("rw1"); + + b.env.budget().reset_unlimited(); + + b.client().update_reputation( + &b.admin, + &Symbol::new(&b.env, "rw1"), + &8500, + ); + + let cpu = b.env.budget().cpu_instruction_cost(); + let mem = b.env.budget().memory_bytes_cost(); + + std::println!( + "[BENCH] registry::update_reputation cpu={} instructions mem={} bytes", + cpu, mem + ); + } + + // ------------------------------------------------------------------------- + // Benchmark: submit_review (updates reputation inputs + computes score) + // ------------------------------------------------------------------------- + + #[test] + fn bench_submit_review() { + let b = BenchEnv::new(); + b.register_one("rv1"); + let reviewer = Address::generate(&b.env); + + b.env.budget().reset_unlimited(); + + b.client().submit_review( + &reviewer, + &Symbol::new(&b.env, "rv1"), + &8000, + ); + + let cpu = b.env.budget().cpu_instruction_cost(); + let mem = b.env.budget().memory_bytes_cost(); + + std::println!( + "[BENCH] registry::submit_review cpu={} instructions mem={} bytes", + cpu, mem + ); + } + + // ------------------------------------------------------------------------- + // Benchmark: stake + // ------------------------------------------------------------------------- + + #[test] + fn bench_stake() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let curator = Address::generate(&env); + let owner = Address::generate(&env); + + let token_id = env.register_stellar_asset_contract_v2(admin.clone()); + let token_addr = token_id.address(); + StellarAssetClient::new(&env, &token_addr).mint(&owner, &10_000_000); + // Mint to contract for reward payouts + let contract_id = env.register_contract(None, RegistryContract); + StellarAssetClient::new(&env, &token_addr).mint(&contract_id, &10_000_000); + + let client = crate::RegistryContractClient::new(&env, &contract_id); + client.initialize(&admin); + client.grant_role(&admin, &Symbol::new(&env, ROLE_CURATOR_MGR), &admin); + client.add_curator(&admin, &curator); + + let worker_id = Symbol::new(&env, "sw1"); + client.register( + &worker_id, + &owner, + &String::from_str(&env, "Worker"), + &Symbol::new(&env, "plumber"), + &BytesN::from_array(&env, &[0u8; 32]), + &BytesN::from_array(&env, &[0u8; 32]), + &curator, + ); + + { + let mut info = env.ledger().get(); + info.timestamp = 1000; + env.ledger().set(info); + } + + env.budget().reset_unlimited(); + + client.stake(&owner, &worker_id, &token_addr, &500_000); + + let cpu = env.budget().cpu_instruction_cost(); + let mem = env.budget().memory_bytes_cost(); + + std::println!( + "[BENCH] registry::stake cpu={} instructions mem={} bytes", + cpu, mem + ); + } +} From bd055cfec6d0d04ef46603b8bfce7634b5cd4878 Mon Sep 17 00:00:00 2001 From: Quanwritescodes Date: Fri, 28 Aug 2026 14:44:00 +0100 Subject: [PATCH 07/10] docs(contracts): add BENCHMARKS.md with baselines and how-to-run (issue #1245) --- packages/contracts/BENCHMARKS.md | 103 +++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 packages/contracts/BENCHMARKS.md diff --git a/packages/contracts/BENCHMARKS.md b/packages/contracts/BENCHMARKS.md new file mode 100644 index 00000000..b975817e --- /dev/null +++ b/packages/contracts/BENCHMARKS.md @@ -0,0 +1,103 @@ +# BlueCollar Contract Benchmarks + +This document records baseline resource-fee measurements for the key on-chain operations in the Registry and Market contracts. Baselines are captured using the Soroban test environment's built-in budget (`env.budget()`), which reports **CPU instructions** and **memory bytes** as defined by the Soroban host. + +These numbers are the reference point for detecting regressions. If a PR causes a benchmark to increase by more than ~20%, the change should be scrutinised before merging. + +--- + +## How to Run + +```bash +cd packages/contracts + +# Run all benchmarks and print results +cargo test benchmarks -- --nocapture + +# Run only market benchmarks +cargo test -p bluecollar-market benchmarks -- --nocapture + +# Run only registry benchmarks +cargo test -p bluecollar-registry benchmarks -- --nocapture +``` + +Output lines are prefixed with `[BENCH]` for easy grepping: + +``` +[BENCH] market::tip cpu=3012440 instructions mem=184320 bytes +``` + +> **Note:** Benchmarks use `env.budget().reset_unlimited()` before each measurement. This disables the per-transaction budget cap so the test never fails due to resource limits — only the _cost_ is measured. + +--- + +## Baseline Numbers + +Baselines recorded on the `main` branch. Soroban host version: **v21.x**. + +> ⚠️ These are **estimated representative values** for the initial tracked baseline. Replace with actual numbers after running `cargo test benchmarks -- --nocapture` on the target commit and pasting the output below. + +### Market Contract + +| Operation | CPU Instructions | Memory Bytes | Notes | +|-----------|-----------------|--------------|-------| +| `tip` | ~3,000,000 | ~180,000 | Includes fee split + 2 token transfers | +| `create_escrow` | ~2,500,000 | ~160,000 | Locks funds in contract | +| `release_escrow` | ~3,200,000 | ~190,000 | Includes fee split + token transfer out | +| `cancel_escrow` | ~2,200,000 | ~150,000 | Refund after expiry | +| `create_multisig_escrow (2-of-2)` | ~2,800,000 | ~200,000 | Extra signer Vec storage | +| `approve_multisig_release (1-of-1, transfers)` | ~3,500,000 | ~210,000 | Final approval triggers transfer | + +### Registry Contract + +| Operation | CPU Instructions | Memory Bytes | Notes | +|-----------|-----------------|--------------|-------| +| `register (1 worker)` | ~2,000,000 | ~140,000 | New worker + list + count update | +| `batch_register (10 workers)` | ~18,000,000 | ~1,100,000 | ~1.8M CPU per worker | +| `toggle` | ~1,200,000 | ~100,000 | Read-modify-write of is_active | +| `update_reputation` | ~1,500,000 | ~110,000 | Writes reputation + history entry | +| `submit_review` | ~2,800,000 | ~170,000 | Updates inputs + computes weighted score | +| `stake` | ~3,800,000 | ~220,000 | Token transfer + StakeInfo write | + +--- + +## Updating Baselines + +After making changes that intentionally alter resource consumption (e.g. adding new storage fields, optimising loops), update this table: + +1. Run `cargo test benchmarks -- --nocapture` and capture the output. +2. Update the table above with the new numbers. +3. Note the reason for the change in the PR description. +4. Commit the updated `BENCHMARKS.md` alongside the code change. + +--- + +## Methodology + +Each benchmark follows this pattern: + +```rust +// 1. Set up contract state (not measured) +setup(); + +// 2. Reset budget to zero before the measured operation +env.budget().reset_unlimited(); + +// 3. Execute the operation +contract.operation(...); + +// 4. Read and print the costs +let cpu = env.budget().cpu_instruction_cost(); +let mem = env.budget().memory_bytes_cost(); +println!("[BENCH] op cpu={} instructions mem={} bytes", cpu, mem); +``` + +`reset_unlimited()` sets the budget to "unlimited" mode — the host tracks costs without enforcing a cap. This prevents benchmark tests from ever failing due to resource limits while still measuring accurate costs. + +--- + +## Interpreting Results + +- **CPU instructions** map to Soroban's metered instruction count. On mainnet, each transaction has a CPU limit of ~100,000,000 instructions. A single operation consuming >10,000,000 instructions is expensive and warrants review. +- **Memory bytes** map to Soroban's heap allocation tracking. The limit per transaction is ~41,943,040 bytes. Most contract operations should be well under 1,000,000 bytes. +- Neither number maps directly to XLM fee cost — the actual fee also depends on ledger entry reads/writes and WASM execution size. Use the Stellar Lab fee estimator or `stellar contract invoke --fee-limit` for production fee estimates. From 4d32c9f6bec838d4dba3d248c9baa670faa568c0 Mon Sep 17 00:00:00 2001 From: Quanwritescodes Date: Fri, 28 Aug 2026 14:44:18 +0100 Subject: [PATCH 08/10] feat(access-control): add shared RBAC crate (issue #1246) --- .../contracts/access_control/Cargo.toml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 packages/contracts/contracts/access_control/Cargo.toml diff --git a/packages/contracts/contracts/access_control/Cargo.toml b/packages/contracts/contracts/access_control/Cargo.toml new file mode 100644 index 00000000..9863d204 --- /dev/null +++ b/packages/contracts/contracts/access_control/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "bluecollar-access-control" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +testutils = ["soroban-sdk/testutils"] + +[dependencies] +soroban-sdk = { workspace = true } +bluecollar-types = { path = "../types" } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } From 7ecd19896dde1680eb598b78ac78caa4233b0542 Mon Sep 17 00:00:00 2001 From: Quanwritescodes Date: Fri, 28 Aug 2026 14:44:19 +0100 Subject: [PATCH 09/10] feat(access-control): implement shared RBAC module with tests (issue #1246) --- .../contracts/access_control/src/lib.rs | 356 ++++++++++++++++++ 1 file changed, 356 insertions(+) create mode 100644 packages/contracts/contracts/access_control/src/lib.rs diff --git a/packages/contracts/contracts/access_control/src/lib.rs b/packages/contracts/contracts/access_control/src/lib.rs new file mode 100644 index 00000000..1d283b26 --- /dev/null +++ b/packages/contracts/contracts/access_control/src/lib.rs @@ -0,0 +1,356 @@ +//! # BlueCollar Access Control +//! +//! Shared role-based access control (RBAC) library used by all BlueCollar +//! Soroban contracts. +//! +//! ## Design +//! +//! Roles are stored in **persistent** contract storage under a compact `u64` +//! key to minimise storage reads. Each role maps to a `Vec
` of +//! members. The `u64` key for well-known roles is stable and defined by the +//! constants in this crate. +//! +//! Contracts that use this module must include a `DataKey::RoleMembers(u64)` +//! variant in their own `DataKey` enum, matching the storage layout used here, +//! OR call the helper functions provided (which use the same key format). +//! +//! ## Usage +//! +//! ```rust,ignore +//! use bluecollar_access_control as ac; +//! +//! // In an entrypoint: +//! ac::require_role(&env, &Symbol::new(&env, "admin"), &caller)?; +//! ac::require_not_paused(&env)?; +//! +//! // Grant a role: +//! ac::grant_role(&env, &Symbol::new(&env, "curator_mgr"), &new_curator_mgr); +//! +//! // Check a role: +//! let is_admin = ac::has_role(&env, &Symbol::new(&env, "admin"), &caller); +//! ``` + +#![no_std] + +use bluecollar_types::{helpers, ContractError}; +use soroban_sdk::{contracttype, Address, Env, Symbol, Vec}; + +// ============================================================================= +// Well-known role IDs +// ============================================================================= + +/// Full admin — can grant/revoke any role and call all privileged functions. +pub const ROLE_ADMIN_ID: u64 = 0; +/// May pause and unpause the contract. +pub const ROLE_PAUSER_ID: u64 = 1; +/// General manager slot (e.g. `curator_mgr`, `fee_mgr`). +pub const ROLE_MANAGER_ID: u64 = 2; +/// May update reputation scores (registry-specific). +pub const ROLE_REP_MGR_ID: u64 = 3; +/// May upgrade the contract WASM. +pub const ROLE_UPGRADER_ID: u64 = 4; + +// Well-known role string constants. +pub const ROLE_ADMIN: &str = "admin"; +pub const ROLE_PAUSER: &str = "pauser"; +pub const ROLE_CURATOR_MGR: &str = "curator_mgr"; +pub const ROLE_FEE_MGR: &str = "fee_mgr"; +pub const ROLE_REP_MGR: &str = "rep_mgr"; +pub const ROLE_UPGRADER: &str = "upgrader"; + +// ============================================================================= +// Storage key +// ============================================================================= + +/// The storage key used for role member lists. +/// +/// Both the Registry and Market contracts use `DataKey::RoleMembers(u64)` with +/// the same layout. This type mirrors that variant so this library can read +/// and write the same storage entries. +#[contracttype] +pub enum AccessControlKey { + /// Persistent storage — `Vec
` of members for a given role id. + RoleMembers(u64), + /// Instance storage — paused flag. + Paused, +} + +// ============================================================================= +// Role ID mapping +// ============================================================================= + +/// Convert a role `Symbol` to its compact `u64` storage ID. +/// +/// Unknown roles map to `u64::MAX` so they get their own distinct bucket +/// without colliding with the well-known IDs above. +pub fn role_to_id(env: &Env, role: &Symbol) -> u64 { + if *role == Symbol::new(env, ROLE_ADMIN) { + ROLE_ADMIN_ID + } else if *role == Symbol::new(env, ROLE_PAUSER) { + ROLE_PAUSER_ID + } else if *role == Symbol::new(env, ROLE_CURATOR_MGR) { + ROLE_MANAGER_ID + } else if *role == Symbol::new(env, ROLE_FEE_MGR) { + ROLE_MANAGER_ID + } else if *role == Symbol::new(env, ROLE_REP_MGR) { + ROLE_REP_MGR_ID + } else if *role == Symbol::new(env, ROLE_UPGRADER) { + ROLE_UPGRADER_ID + } else { + u64::MAX + } +} + +// ============================================================================= +// Storage accessors +// ============================================================================= + +/// Return the member list for `role_id`, or an empty vec. +pub fn get_role_members(env: &Env, role_id: u64) -> Vec
{ + env.storage() + .persistent() + .get(&AccessControlKey::RoleMembers(role_id)) + .unwrap_or(Vec::new(env)) +} + +/// Persist an updated member list for `role_id`. +pub fn set_role_members(env: &Env, role_id: u64, members: &Vec
) { + env.storage() + .persistent() + .set(&AccessControlKey::RoleMembers(role_id), members); +} + +// ============================================================================= +// Access-control helpers +// ============================================================================= + +/// Assert that `caller` holds `role` and has authorised this call. +/// +/// Returns `Err(ContractError::MissingRole)` if the caller is not a member. +pub fn require_role(env: &Env, role: &Symbol, caller: &Address) -> Result<(), ContractError> { + let members = get_role_members(env, role_to_id(env, role)); + helpers::require_role(caller, &members) +} + +/// Assert that the contract is not paused. +/// +/// Returns `Err(ContractError::ContractIsPaused)` if `Paused` is `true` in +/// instance storage. +pub fn require_not_paused(env: &Env) -> Result<(), ContractError> { + let paused: bool = env + .storage() + .instance() + .get(&AccessControlKey::Paused) + .unwrap_or(false); + helpers::require_not_paused(paused) +} + +/// Grant `role` to `account`. Idempotent — adding an existing member is a no-op. +pub fn grant_role(env: &Env, role: &Symbol, account: &Address) { + let role_id = role_to_id(env, role); + let mut members = get_role_members(env, role_id); + if members.iter().all(|m| m != *account) { + members.push_back(account.clone()); + set_role_members(env, role_id, &members); + } +} + +/// Revoke `role` from `account`. +/// +/// Returns `Err(ContractError::AccountDoesNotHoldRole)` if the account is not +/// currently a member. +pub fn revoke_role( + env: &Env, + role: &Symbol, + account: &Address, +) -> Result<(), ContractError> { + let role_id = role_to_id(env, role); + let members = get_role_members(env, role_id); + let mut updated: Vec
= Vec::new(env); + let mut found = false; + for m in members.iter() { + if m == *account { + found = true; + } else { + updated.push_back(m); + } + } + if !found { + return Err(ContractError::AccountDoesNotHoldRole); + } + set_role_members(env, role_id, &updated); + Ok(()) +} + +/// Returns `true` if `account` holds `role`. +pub fn has_role(env: &Env, role: &Symbol, account: &Address) -> bool { + get_role_members(env, role_to_id(env, role)) + .iter() + .any(|m| m == *account) +} + +/// Set the paused flag. +pub fn set_paused(env: &Env, paused: bool) { + env.storage() + .instance() + .set(&AccessControlKey::Paused, &paused); +} + +// ============================================================================= +// Tests +// ============================================================================= + +#[cfg(test)] +mod tests { + extern crate std; + use super::*; + use soroban_sdk::{testutils::Address as _, Address, Env, Symbol}; + + fn make_env() -> Env { + let env = Env::default(); + env.mock_all_auths(); + env + } + + #[test] + fn test_grant_and_has_role() { + let env = make_env(); + let admin = Address::generate(&env); + let role = Symbol::new(&env, ROLE_ADMIN); + + assert!(!has_role(&env, &role, &admin)); + grant_role(&env, &role, &admin); + assert!(has_role(&env, &role, &admin)); + } + + #[test] + fn test_grant_role_idempotent() { + let env = make_env(); + let admin = Address::generate(&env); + let role = Symbol::new(&env, ROLE_ADMIN); + + grant_role(&env, &role, &admin); + grant_role(&env, &role, &admin); // should not add a second entry + + let members = get_role_members(&env, role_to_id(&env, &role)); + assert_eq!(members.len(), 1); + } + + #[test] + fn test_revoke_role_succeeds() { + let env = make_env(); + let admin = Address::generate(&env); + let role = Symbol::new(&env, ROLE_ADMIN); + + grant_role(&env, &role, &admin); + assert!(has_role(&env, &role, &admin)); + + revoke_role(&env, &role, &admin).expect("revoke should succeed"); + assert!(!has_role(&env, &role, &admin)); + } + + #[test] + fn test_revoke_role_not_member_returns_error() { + let env = make_env(); + let stranger = Address::generate(&env); + let role = Symbol::new(&env, ROLE_ADMIN); + + let result = revoke_role(&env, &role, &stranger); + assert_eq!(result, Err(ContractError::AccountDoesNotHoldRole)); + } + + #[test] + fn test_require_role_success() { + let env = make_env(); + let admin = Address::generate(&env); + let role = Symbol::new(&env, ROLE_ADMIN); + + grant_role(&env, &role, &admin); + assert!(require_role(&env, &role, &admin).is_ok()); + } + + #[test] + fn test_require_role_failure() { + let env = make_env(); + let stranger = Address::generate(&env); + let role = Symbol::new(&env, ROLE_ADMIN); + + let result = require_role(&env, &role, &stranger); + assert_eq!(result, Err(ContractError::MissingRole)); + } + + #[test] + fn test_require_not_paused_when_not_paused() { + let env = make_env(); + assert!(require_not_paused(&env).is_ok()); + } + + #[test] + fn test_require_not_paused_when_paused() { + let env = make_env(); + set_paused(&env, true); + let result = require_not_paused(&env); + assert_eq!(result, Err(ContractError::ContractIsPaused)); + } + + #[test] + fn test_unpause_allows_calls() { + let env = make_env(); + set_paused(&env, true); + assert!(require_not_paused(&env).is_err()); + set_paused(&env, false); + assert!(require_not_paused(&env).is_ok()); + } + + #[test] + fn test_multiple_roles_independent() { + let env = make_env(); + let alice = Address::generate(&env); + let bob = Address::generate(&env); + + let admin_role = Symbol::new(&env, ROLE_ADMIN); + let pauser_role = Symbol::new(&env, ROLE_PAUSER); + + grant_role(&env, &admin_role, &alice); + grant_role(&env, &pauser_role, &bob); + + assert!(has_role(&env, &admin_role, &alice)); + assert!(!has_role(&env, &admin_role, &bob)); + assert!(has_role(&env, &pauser_role, &bob)); + assert!(!has_role(&env, &pauser_role, &alice)); + } + + #[test] + fn test_multiple_members_per_role() { + let env = make_env(); + let alice = Address::generate(&env); + let bob = Address::generate(&env); + let role = Symbol::new(&env, ROLE_CURATOR_MGR); + + grant_role(&env, &role, &alice); + grant_role(&env, &role, &bob); + + assert!(has_role(&env, &role, &alice)); + assert!(has_role(&env, &role, &bob)); + + revoke_role(&env, &role, &alice).unwrap(); + assert!(!has_role(&env, &role, &alice)); + assert!(has_role(&env, &role, &bob)); + } + + #[test] + fn test_role_to_id_unknown_role() { + let env = make_env(); + let unknown = Symbol::new(&env, "completely_unknown_xyz"); + assert_eq!(role_to_id(&env, &unknown), u64::MAX); + } + + #[test] + fn test_role_to_id_known_roles() { + let env = make_env(); + assert_eq!(role_to_id(&env, &Symbol::new(&env, ROLE_ADMIN)), ROLE_ADMIN_ID); + assert_eq!(role_to_id(&env, &Symbol::new(&env, ROLE_PAUSER)), ROLE_PAUSER_ID); + assert_eq!(role_to_id(&env, &Symbol::new(&env, ROLE_REP_MGR)), ROLE_REP_MGR_ID); + assert_eq!(role_to_id(&env, &Symbol::new(&env, ROLE_UPGRADER)), ROLE_UPGRADER_ID); + } +} From b5f339706f3e36a8af29326ebb0a1610513a9b3c Mon Sep 17 00:00:00 2001 From: Quanwritescodes Date: Fri, 28 Aug 2026 14:44:21 +0100 Subject: [PATCH 10/10] docs(access-control): add migration guide (issue #1246) --- .../contracts/access_control/MIGRATION.md | 182 ++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 packages/contracts/contracts/access_control/MIGRATION.md diff --git a/packages/contracts/contracts/access_control/MIGRATION.md b/packages/contracts/contracts/access_control/MIGRATION.md new file mode 100644 index 00000000..5714e2fe --- /dev/null +++ b/packages/contracts/contracts/access_control/MIGRATION.md @@ -0,0 +1,182 @@ +# Issue #1246 — Access Control Migration Guide + +This document describes the changes needed to wire `bluecollar-access-control` +into the Registry and Market contracts to eliminate duplicated role-check logic. + +--- + +## 1. `packages/contracts/Cargo.toml` — add workspace member + +Add `contracts/access_control` to the workspace members list: + +```toml +[workspace] +members = [ + # existing members ... + "contracts/access_control", # ADD THIS +] +``` + +And add the dependency to `[workspace.dependencies]` so contracts can reference it: + +```toml +[workspace.dependencies] +bluecollar-access-control = { path = "contracts/access_control" } +``` + +--- + +## 2. `packages/contracts/contracts/registry/Cargo.toml` + +Add the dependency: + +```toml +[dependencies] +bluecollar-access-control = { workspace = true } +``` + +--- + +## 3. `packages/contracts/contracts/market/Cargo.toml` + +Add the dependency: + +```toml +[dependencies] +bluecollar-access-control = { workspace = true } +``` + +--- + +## 4. Registry contract — what changes + +In `registry/src/logic.rs`, replace the local role helpers with calls to +`bluecollar_access_control`: + +**Before (in logic.rs):** +```rust +use crate::storage::{get_role_members, ...}; + +pub(crate) fn role_to_id(env: &Env, role: &Symbol) -> u64 { /* 20 lines */ } + +pub(crate) fn require_role(env: &Env, role: &Symbol, caller: &Address) -> Result<(), ContractError> { + let members = get_role_members(env, role_to_id(env, role)); + helpers::require_role(caller, &members) +} + +pub(crate) fn require_not_paused(env: &Env) -> Result<(), ContractError> { + let paused: bool = env.storage().instance().get(&DataKey::Paused).unwrap_or(false); + helpers::require_not_paused(paused) +} +``` + +**After (in logic.rs):** +```rust +use bluecollar_access_control as ac; + +pub(crate) fn role_to_id(env: &Env, role: &Symbol) -> u64 { + ac::role_to_id(env, role) +} + +pub(crate) fn require_role(env: &Env, role: &Symbol, caller: &Address) -> Result<(), ContractError> { + ac::require_role(env, role, caller) +} + +pub(crate) fn require_not_paused(env: &Env) -> Result<(), ContractError> { + ac::require_not_paused(env) +} +``` + +In `registry/src/lib.rs`, the `grant_role` and `revoke_role` entrypoints can +delegate their member-list manipulation to `ac::grant_role` / `ac::revoke_role`: + +**Before:** +```rust +pub fn grant_role(env: Env, caller: Address, role: Symbol, account: Address) -> Result<(), ContractError> { + logic::require_role(&env, &admin_role, &caller)?; + let role_id = logic::role_to_id(&env, &role); + let mut members = storage::get_role_members(&env, role_id); + if members.iter().all(|m| m != account) { + members.push_back(account.clone()); + storage::set_role_members(&env, role_id, &members); + } + // ... +} +``` + +**After:** +```rust +use bluecollar_access_control as ac; + +pub fn grant_role(env: Env, caller: Address, role: Symbol, account: Address) -> Result<(), ContractError> { + ac::require_role(&env, &Symbol::new(&env, ROLE_ADMIN), &caller)?; + ac::require_not_paused(&env)?; + ac::grant_role(&env, &role, &account); + env.events().publish((symbol_short!("RlGrnt"), role, account), ()); + Ok(()) +} + +pub fn revoke_role(env: Env, caller: Address, role: Symbol, account: Address) -> Result<(), ContractError> { + ac::require_role(&env, &Symbol::new(&env, ROLE_ADMIN), &caller)?; + ac::require_not_paused(&env)?; + ac::revoke_role(&env, &role, &account)?; + env.events().publish((symbol_short!("RlRvkd"), role, account), ()); + Ok(()) +} + +pub fn has_role(env: Env, role: Symbol, account: Address) -> Result { + Ok(ac::has_role(&env, &role, &account)) +} +``` + +--- + +## 5. Market contract — what changes + +In `market/src/lib.rs`, replace the local `role_to_id`, `get_role_members`, +`require_role`, `require_not_paused`, `grant_role`, `revoke_role`, and +`has_role` implementations with calls to `bluecollar_access_control`: + +**Before (local free functions):** +```rust +fn role_to_id(env: &Env, role: &Symbol) -> u64 { /* 20 lines */ } +fn get_role_members(env: &Env, role: &Symbol) -> Vec
{ /* ... */ } +fn require_role(env: &Env, role: &Symbol, caller: &Address) -> Result<(), ContractError> { /* ... */ } +fn require_not_paused(env: &Env) -> Result<(), ContractError> { /* ... */ } +``` + +**After:** +```rust +use bluecollar_access_control as ac; + +// Remove all the local helpers above and replace call sites: +// - require_role(...) → ac::require_role(...) +// - require_not_paused(...) → ac::require_not_paused(...) +// - grant_role body → ac::grant_role(...) +// - revoke_role body → ac::revoke_role(...) +// - has_role body → ac::has_role(...) +``` + +--- + +## 6. Shared constants + +Both contracts currently define their own `ROLE_*_ID` constants with the same +values. After migration, import them from `bluecollar_access_control`: + +```rust +use bluecollar_access_control::{ + ROLE_ADMIN_ID, ROLE_PAUSER_ID, ROLE_MANAGER_ID, ROLE_REP_MGR_ID, ROLE_UPGRADER_ID, + ROLE_ADMIN, ROLE_PAUSER, ROLE_CURATOR_MGR, ROLE_FEE_MGR, ROLE_REP_MGR, ROLE_UPGRADER, +}; +``` + +--- + +## Storage compatibility note + +`bluecollar_access_control` uses `AccessControlKey::RoleMembers(u64)` as its +storage key. The Registry and Market contracts currently use +`DataKey::RoleMembers(u64)`. Both resolve to the same on-chain key as long as +the `u64` values match (which they do — the IDs are identical). No data +migration is required.