From df59a2feb7b36075c498cc5d8ea8d03e1b1505df Mon Sep 17 00:00:00 2001 From: adeboladee <138445576+adeboladee@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:42:33 +0000 Subject: [PATCH] feat(recipient): add cargo-fuzz target for malformed inputs - Add recipient registry contract (init, register, update, remove, views) - Implement require_auth on all state-changing entrypoints - Overflow-safe math with checked_add/checked_sub - NatSpec-style rustdoc on every public fn - 21 unit tests covering happy paths, auth gates, edge cases - cargo-fuzz target in contracts/recipient/fuzz/targets/main.rs hammering 7 operations with malformed inputs and invariant checks - Closes #911 --- Cargo.toml | 2 + contracts/recipient/Cargo.toml | 15 + contracts/recipient/fuzz/Cargo.toml | 20 ++ contracts/recipient/fuzz/targets/main.rs | 388 +++++++++++++++++++++++ contracts/recipient/src/errors.rs | 26 ++ contracts/recipient/src/events.rs | 76 +++++ contracts/recipient/src/lib.rs | 382 ++++++++++++++++++++++ contracts/recipient/src/test.rs | 258 +++++++++++++++ 8 files changed, 1167 insertions(+) create mode 100644 contracts/recipient/Cargo.toml create mode 100644 contracts/recipient/fuzz/Cargo.toml create mode 100644 contracts/recipient/fuzz/targets/main.rs create mode 100644 contracts/recipient/src/errors.rs create mode 100644 contracts/recipient/src/events.rs create mode 100644 contracts/recipient/src/lib.rs create mode 100644 contracts/recipient/src/test.rs diff --git a/Cargo.toml b/Cargo.toml index 944f4281..5f1dc57e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,8 @@ members = [ "contracts/errors", "contracts/registry", "contracts/hot", + "contracts/recipient", + "contracts/recipient/fuzz", ] default-members = [ "contracts/revenue_pool", diff --git a/contracts/recipient/Cargo.toml b/contracts/recipient/Cargo.toml new file mode 100644 index 00000000..06f2487d --- /dev/null +++ b/contracts/recipient/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "callora-recipient" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib", "rlib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/recipient/fuzz/Cargo.toml b/contracts/recipient/fuzz/Cargo.toml new file mode 100644 index 00000000..8d5b92e7 --- /dev/null +++ b/contracts/recipient/fuzz/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "callora-recipient-fuzz" +version = "0.0.0" +publish = false +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" +arbitrary = { version = "1", features = ["derive"] } +soroban-sdk = { workspace = true, features = ["testutils"] } +callora-recipient = { path = ".." } + +[[bin]] +name = "main" +path = "targets/main.rs" +test = false +doc = false diff --git a/contracts/recipient/fuzz/targets/main.rs b/contracts/recipient/fuzz/targets/main.rs new file mode 100644 index 00000000..767ee817 --- /dev/null +++ b/contracts/recipient/fuzz/targets/main.rs @@ -0,0 +1,388 @@ +//! Fuzz target: comprehensive recipient-registry state-machine fuzzer. +//! +//! Exercises the full public surface of [`CalloraRecipient`] by parsing a raw +//! byte stream into a sequence of typed operations and verifying that key +//! invariants hold after every call. +//! +//! # Invariants checked +//! +//! 1. **Admin invariance** — `get_admin()` never changes after initialization. +//! 2. **Count conservation** — `get_recipient_count()` equals the number of +//! currently registered recipients; it increases by exactly 1 on every +//! successful `register_recipient` and decreases by exactly 1 on every +//! successful `remove_recipient`. +//! 3. **Existence consistency** — after a successful `register_recipient`, +//! `has_recipient(name)` returns `true` and `get_recipient(name)` returns +//! the registered address. After a successful `remove_recipient`, +//! `has_recipient(name)` returns `false`. +//! 4. **Auth gate** — every state-changing entrypoint must return an error +//! when called without authentication (i.e. after `env.set_auths(&[])`). +//! 5. **No uncontrolled panics** — the contract must never abort the process +//! with a panic that is not caught through the normal Soroban error path or +//! `std::panic::catch_unwind`. +//! +//! # Wire format +//! +//! The fuzzer byte stream is sliced into **3-byte operation tokens**: +//! +//! ```text +//! byte 0: operation discriminant (mod NUM_OPS) +//! bytes 1-2: big-endian u16 operand (used as name seed / address seed) +//! ``` +//! +//! # Running +//! +//! ```bash +//! cargo fuzz run main +//! ``` + +#![no_main] + +extern crate std; + +use libfuzzer_sys::fuzz_target; +use soroban_sdk::testutils::Address as _; +use soroban_sdk::{Address, Env, InvokeError}; + +use callora_recipient::{CalloraRecipient, CalloraRecipientClient}; + +// --------------------------------------------------------------------------- +// Configuration constants +// --------------------------------------------------------------------------- + +/// Bytes consumed per operation token. +const BYTES_PER_OP: usize = 3; + +/// Total number of distinct operation discriminants. +const NUM_OPS: u8 = 7; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Register the recipient contract and return its address alongside a typed +/// client. +fn create_recipient(env: &Env) -> (Address, CalloraRecipientClient<'_>) { + let addr = env.register(CalloraRecipient, ()); + (addr.clone(), CalloraRecipientClient::new(env, &addr)) +} + +/// Returns `true` when a `catch_unwind`-wrapped Soroban `try_*` call +/// completed without a panic **and** the contract returned `Ok(())`. +/// +/// Soroban SDK v22 `try_*` methods return +/// `Result, Result>`. +/// Wrapped in `catch_unwind` the outermost layer adds +/// `Result<..., Box>`. +/// +/// A logical success is `Ok(Ok(Ok(())))`. +fn is_try_success( + result: &std::result::Result< + std::result::Result< + Result<(), soroban_sdk::ConversionError>, + Result, + >, + Box, + >, +) -> bool { + matches!(result, Ok(Ok(Ok(())))) +} + +/// Derive a deterministic recipient name from a u16 seed. +/// +/// Names are 1–8 bytes, well within the max name length. +fn seed_name(env: &Env, seed: u16) -> soroban_sdk::String { + let bytes = seed.to_be_bytes(); + let len = ((seed % 7) + 1) as usize; // 1–8 chars + let slice = &bytes[..len.min(bytes.len())]; + soroban_sdk::String::from_bytes(env, slice) +} + +/// Derive a unique Address from a seed. +fn seed_address(env: &Env, _seed: u16) -> Address { + Address::generate(env) +} + +// --------------------------------------------------------------------------- +// Fuzz entry-point +// --------------------------------------------------------------------------- + +fuzz_target!(|data: &[u8]| { + // Need at least one operation token. + if data.len() < BYTES_PER_OP { + return; + } + + let env = Env::default(); + env.mock_all_auths(); + + // --- Static participants ------------------------------------------------ + let admin = Address::generate(&env); + let outsider = Address::generate(&env); + + // --- Contract ----------------------------------------------------------- + let (_contract_addr, client) = create_recipient(&env); + + // Initialize. + client.init(&admin); + + // --- Tracked state ------------------------------------------------------ + // Maps operand (name seed) → registered Address. We use the raw u16 operand + // as the key since SorobanString does not implement Hash. + let mut registered: std::collections::HashMap = + std::collections::HashMap::new(); + + // ----------------------------------------------------------------------- + // Main operation loop + // ----------------------------------------------------------------------- + for chunk in data.chunks(BYTES_PER_OP) { + if chunk.len() < BYTES_PER_OP { + break; + } + + let op = chunk[0] % NUM_OPS; + let operand = u16::from_be_bytes([chunk[1], chunk[2]]); + + match op { + // ---------------------------------------------------------------- + // 0 — register_recipient(admin, name, address) + // ---------------------------------------------------------------- + 0 => { + let rname = seed_name(&env, operand); + let raddr = seed_address(&env, operand); + let count_before = client.get_recipient_count(); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + client.try_register_recipient(&admin, &rname, &raddr) + })); + + if is_try_success(&result) { + // Success: recipient must now exist. + assert!( + client.has_recipient(&rname), + "register succeeded but has_recipient is false" + ); + let record = client.get_recipient(&rname); + assert_eq!(record.address, raddr, "registered address mismatch"); + assert_eq!( + client.get_recipient_count(), + count_before + 1, + "count did not increase by 1 after register" + ); + registered.insert(operand, raddr); + } else { + // Rejected (likely AlreadyRegistered): count unchanged. + assert_eq!( + client.get_recipient_count(), + count_before, + "rejected register mutated count" + ); + } + } + + // ---------------------------------------------------------------- + // 1 — update_recipient(admin, name, new_address) + // ---------------------------------------------------------------- + 1 => { + let rname = seed_name(&env, operand); + let new_addr = seed_address(&env, operand.wrapping_add(0xA0A0)); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + client.try_update_recipient(&admin, &rname, &new_addr) + })); + + if is_try_success(&result) { + assert!( + client.has_recipient(&rname), + "update succeeded but has_recipient is false" + ); + let record = client.get_recipient(&rname); + assert_eq!(record.address, new_addr, "updated address mismatch"); + registered.insert(operand, new_addr); + } + // If rejected (NotFound), tracked state is unchanged. + } + + // ---------------------------------------------------------------- + // 2 — remove_recipient(admin, name) + // ---------------------------------------------------------------- + 2 => { + let rname = seed_name(&env, operand); + let count_before = client.get_recipient_count(); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + client.try_remove_recipient(&admin, &rname) + })); + + if is_try_success(&result) { + assert!( + !client.has_recipient(&rname), + "remove succeeded but has_recipient is still true" + ); + assert_eq!( + client.get_recipient_count(), + count_before - 1, + "count did not decrease by 1 after remove" + ); + registered.remove(&operand); + } else { + // Rejected (NotFound): count unchanged. + assert_eq!( + client.get_recipient_count(), + count_before, + "rejected remove mutated count" + ); + } + } + + // ---------------------------------------------------------------- + // 3 — view-only calls: get_recipient / has_recipient / get_admin / + // get_recipient_count — must not mutate state + // ---------------------------------------------------------------- + 3 => { + let count_before = client.get_recipient_count(); + let rname = seed_name(&env, operand); + + let _ = client.get_admin(); + let _ = client.get_recipient_count(); + let _ = client.has_recipient(&rname); + let _ = client.get_recipient(&rname); + + assert_eq!( + client.get_recipient_count(), + count_before, + "view calls mutated recipient count" + ); + } + + // ---------------------------------------------------------------- + // 4 — unauthorised register_recipient attempt (auth gate) + // ---------------------------------------------------------------- + 4 => { + let rname = seed_name(&env, operand); + let raddr = seed_address(&env, operand); + let count_before = client.get_recipient_count(); + + env.set_auths(&[]); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + client.try_register_recipient(&outsider, &rname, &raddr) + })); + env.mock_all_auths(); + + assert!( + !is_try_success(&result), + "unauthenticated register unexpectedly succeeded" + ); + assert_eq!( + client.get_recipient_count(), + count_before, + "unauthenticated register mutated count" + ); + } + + // ---------------------------------------------------------------- + // 5 — unauthorised update_recipient attempt (auth gate) + // ---------------------------------------------------------------- + 5 => { + let rname = seed_name(&env, operand); + let new_addr = seed_address(&env, operand); + let count_before = client.get_recipient_count(); + let existed_before = client.has_recipient(&rname); + + env.set_auths(&[]); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + client.try_update_recipient(&outsider, &rname, &new_addr) + })); + env.mock_all_auths(); + + assert!( + !is_try_success(&result), + "unauthenticated update unexpectedly succeeded" + ); + assert_eq!( + client.get_recipient_count(), + count_before, + "unauthenticated update mutated count" + ); + assert_eq!( + client.has_recipient(&rname), + existed_before, + "unauthenticated update changed recipient existence" + ); + } + + // ---------------------------------------------------------------- + // 6 — unauthorised remove_recipient attempt (auth gate) + // ---------------------------------------------------------------- + 6 => { + let rname = seed_name(&env, operand); + let count_before = client.get_recipient_count(); + let existed_before = client.has_recipient(&rname); + + env.set_auths(&[]); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + client.try_remove_recipient(&outsider, &rname) + })); + env.mock_all_auths(); + + assert!( + !is_try_success(&result), + "unauthenticated remove unexpectedly succeeded" + ); + assert_eq!( + client.get_recipient_count(), + count_before, + "unauthenticated remove mutated count" + ); + assert_eq!( + client.has_recipient(&rname), + existed_before, + "unauthenticated remove changed recipient existence" + ); + } + + _ => unreachable!("op discriminant outside NUM_OPS range"), + } + + // ------------------------------------------------------------------- + // Post-step global invariants + // ------------------------------------------------------------------- + + // I1: admin never changes. + assert_eq!( + client.get_admin(), + admin, + "invariant I1: admin changed after op {op}" + ); + + // I2: count consistency with tracked state. + let actual_count = client.get_recipient_count(); + assert_eq!( + actual_count as usize, + registered.len(), + "invariant I2: count ({actual_count}) != tracked len ({}) after op {op}", + registered.len() + ); + + // I3: every tracked name must exist in the contract. + for &operand_key in registered.keys() { + let rname = seed_name(&env, operand_key); + assert!( + client.has_recipient(&rname), + "invariant I3: tracked name not found in contract after op {op}" + ); + } + } + + // ----------------------------------------------------------------------- + // Terminal invariant: tracked count matches contract count. + // ----------------------------------------------------------------------- + let final_count = client.get_recipient_count(); + assert_eq!( + final_count as usize, + registered.len(), + "terminal: count={} tracked={}", + final_count, + registered.len() + ); +}); diff --git a/contracts/recipient/src/errors.rs b/contracts/recipient/src/errors.rs new file mode 100644 index 00000000..03adebd6 --- /dev/null +++ b/contracts/recipient/src/errors.rs @@ -0,0 +1,26 @@ +use soroban_sdk::contracterror; + +/// Stable, machine-readable error codes for the Recipient Registry contract. +/// +/// Numeric discriminants are part of the contract interface and must remain +/// stable. Callers and indexers may branch on these `u32` codes instead of +/// parsing panic strings. +#[contracterror] +#[derive(Clone, Copy, Debug, PartialEq, PartialOrd, Ord, Eq)] +#[repr(u32)] +pub enum RecipientError { + /// Contract has not been initialized yet (code 1). + NotInitialized = 1, + /// `init` was called more than once (code 2). + AlreadyInitialized = 2, + /// Caller is not the configured admin (code 3). + Unauthorized = 3, + /// A recipient with the given name is already registered (code 4). + AlreadyRegistered = 4, + /// No recipient exists with the given name (code 5). + NotFound = 5, + /// The recipient name is empty or exceeds the maximum length (code 6). + InvalidName = 6, + /// Arithmetic overflow detected (code 7). + Overflow = 7, +} diff --git a/contracts/recipient/src/events.rs b/contracts/recipient/src/events.rs new file mode 100644 index 00000000..ecd8cd78 --- /dev/null +++ b/contracts/recipient/src/events.rs @@ -0,0 +1,76 @@ +//! Event topic Symbol constructors for the Recipient Registry contract. +//! +//! This module centralizes all event topic strings into dedicated functions, +//! ensuring byte-identity and preventing accidental topic name drift. + +use soroban_sdk::{Env, Symbol}; + +/// Returns the Symbol for the `"init"` event topic. +/// +/// Emitted when the recipient registry is first initialized with an admin. +pub fn event_init(env: &Env) -> Symbol { + Symbol::new(env, "init") +} + +/// Returns the Symbol for the `"recipient_registered"` event topic. +/// +/// Emitted when a new named recipient address is registered by the admin. +pub fn event_recipient_registered(env: &Env) -> Symbol { + Symbol::new(env, "recipient_registered") +} + +/// Returns the Symbol for the `"recipient_updated"` event topic. +/// +/// Emitted when an existing recipient's address is updated by the admin. +pub fn event_recipient_updated(env: &Env) -> Symbol { + Symbol::new(env, "recipient_updated") +} + +/// Returns the Symbol for the `"recipient_removed"` event topic. +/// +/// Emitted when an existing recipient is removed from the registry. +pub fn event_recipient_removed(env: &Env) -> Symbol { + Symbol::new(env, "recipient_removed") +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Snapshot: proves `event_init` maps to exactly the bytes for `"init"`. + #[test] + fn test_event_init_bytes() { + let env = Env::default(); + assert_eq!(event_init(&env), Symbol::new(&env, "init")); + } + + /// Snapshot: proves `event_recipient_registered` maps to the expected bytes. + #[test] + fn test_event_recipient_registered_bytes() { + let env = Env::default(); + assert_eq!( + event_recipient_registered(&env), + Symbol::new(&env, "recipient_registered") + ); + } + + /// Snapshot: proves `event_recipient_updated` maps to the expected bytes. + #[test] + fn test_event_recipient_updated_bytes() { + let env = Env::default(); + assert_eq!( + event_recipient_updated(&env), + Symbol::new(&env, "recipient_updated") + ); + } + + /// Snapshot: proves `event_recipient_removed` maps to the expected bytes. + #[test] + fn test_event_recipient_removed_bytes() { + let env = Env::default(); + assert_eq!( + event_recipient_removed(&env), + Symbol::new(&env, "recipient_removed") + ); + } +} diff --git a/contracts/recipient/src/lib.rs b/contracts/recipient/src/lib.rs new file mode 100644 index 00000000..91c354ad --- /dev/null +++ b/contracts/recipient/src/lib.rs @@ -0,0 +1,382 @@ +#![no_std] + +//! # Callora Recipient Registry +//! +//! An on-chain registry where a privileged admin registers, updates, and +//! removes named payment recipients. Other Callora contracts (vault, +//! settlement, revenue_pool) can cross-reference this registry to validate +//! that a destination address is an approved recipient before executing +//! transfers. +//! +//! # Storage model +//! * **Instance** — admin address, total registered count. +//! * **Persistent** — per-recipient entries keyed by name. +//! +//! # Invariants +//! 1. `require_auth` is enforced on every state-changing entrypoint. +//! 2. Arithmetic is overflow-safe (`checked_add` / `checked_sub`). +//! 3. No `unwrap()` in production code paths. +//! +//! # Fuzzing +//! See `fuzz/targets/main.rs` for the `cargo-fuzz` target that hammers all +//! entrypoints with malformed and randomized inputs. + +#[cfg(test)] +extern crate std; + +pub mod errors; +pub mod events; + +pub use errors::RecipientError; + +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String}; + +/// Maximum byte length of a recipient name. +pub const MAX_NAME_LEN: u32 = 64; + +// --------------------------------------------------------------------------- +// Storage keys +// --------------------------------------------------------------------------- + +/// Instance and persistent storage keys for the Recipient Registry. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum StorageKey { + /// Instance: the current admin [`Address`]. + Admin, + /// Instance: total number of registered recipients. + RecipientCount, + /// Persistent: a registered recipient entry, keyed by name. + Recipient(String), +} + +// --------------------------------------------------------------------------- +// Data types +// --------------------------------------------------------------------------- + +/// A registered payment recipient. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct RecipientRecord { + /// Human-readable name (also used as the storage key). + pub name: String, + /// On-ledger address of the recipient. + pub address: Address, +} + +// --------------------------------------------------------------------------- +// Contract +// --------------------------------------------------------------------------- + +/// On-chain recipient registry contract. +/// +/// Maintains a mapping of human-readable names to on-ledger addresses, gated +/// behind an admin role with `require_auth` on every mutating operation. +#[contract] +pub struct CalloraRecipient; + +#[contractimpl] +impl CalloraRecipient { + // ----------------------------------------------------------------------- + // Initialization + // ----------------------------------------------------------------------- + + /// Initialize the recipient registry with an admin. + /// + /// Can only be called once. The admin is the sole address permitted to + /// register, update, or remove recipients. + /// + /// # Parameters + /// * `admin` — Address of the registry administrator; must authorize. + /// + /// # Errors + /// * [`RecipientError::AlreadyInitialized`] — `init` was called more than once. + /// + /// # Events + /// Emits `"init"` with the admin address as data. + pub fn init(env: Env, admin: Address) -> Result<(), RecipientError> { + admin.require_auth(); + if env.storage().instance().has(&StorageKey::Admin) { + return Err(RecipientError::AlreadyInitialized); + } + let inst = env.storage().instance(); + inst.set(&StorageKey::Admin, &admin); + inst.set(&StorageKey::RecipientCount, &0u32); + env.events() + .publish((events::event_init(&env), admin), ()); + Ok(()) + } + + // ----------------------------------------------------------------------- + // Internal helpers + // ----------------------------------------------------------------------- + + /// Read the admin address from instance storage. + fn admin(env: &Env) -> Result { + env.storage() + .instance() + .get(&StorageKey::Admin) + .ok_or(RecipientError::NotInitialized) + } + + /// Verify that `caller` is the current admin, consuming auth. + fn require_admin(env: &Env, caller: &Address) -> Result { + caller.require_auth(); + let admin = Self::admin(env)?; + if caller != &admin { + return Err(RecipientError::Unauthorized); + } + Ok(admin) + } + + /// Validate that a recipient name is non-empty and within length bounds. + fn validate_name(name: &String) -> Result<(), RecipientError> { + if name.is_empty() || name.len() > MAX_NAME_LEN { + return Err(RecipientError::InvalidName); + } + Ok(()) + } + + // ----------------------------------------------------------------------- + // State-changing entrypoints + // ----------------------------------------------------------------------- + + /// Register a new named recipient. + /// + /// The name must be unique, non-empty, and at most [`MAX_NAME_LEN`] bytes. + /// The caller must be the admin and must authorize. + /// + /// # Parameters + /// * `caller` — Must be the current admin; must authorize. + /// * `name` — Unique human-readable identifier for the recipient. + /// * `address` — On-ledger address of the recipient. + /// + /// # Errors + /// * [`RecipientError::Unauthorized`] — caller is not the admin. + /// * [`RecipientError::AlreadyRegistered`] — a recipient with this name exists. + /// * [`RecipientError::InvalidName`] — name is empty or too long. + /// + /// # Events + /// Emits `"recipient_registered"` with the name as topic and the record as data. + pub fn register_recipient( + env: Env, + caller: Address, + name: String, + address: Address, + ) -> Result<(), RecipientError> { + Self::require_admin(&env, &caller)?; + Self::validate_name(&name)?; + + let key = StorageKey::Recipient(name.clone()); + if env.storage().persistent().has(&key) { + return Err(RecipientError::AlreadyRegistered); + } + + let record = RecipientRecord { + name: name.clone(), + address, + }; + env.storage().persistent().set(&key, &record); + + let count: u32 = env + .storage() + .instance() + .get(&StorageKey::RecipientCount) + .ok_or(RecipientError::NotInitialized)?; + env.storage().instance().set( + &StorageKey::RecipientCount, + &count.checked_add(1).ok_or(RecipientError::Overflow)?, + ); + + env.events().publish( + (events::event_recipient_registered(&env), name), + record, + ); + Ok(()) + } + + /// Update the address of an existing recipient. + /// + /// The recipient must already be registered. The caller must be the admin + /// and must authorize. + /// + /// # Parameters + /// * `caller` — Must be the current admin; must authorize. + /// * `name` — Name of the recipient to update. + /// * `new_address` — Replacement on-ledger address. + /// + /// # Errors + /// * [`RecipientError::Unauthorized`] — caller is not the admin. + /// * [`RecipientError::NotFound`] — no recipient with this name exists. + /// + /// # Events + /// Emits `"recipient_updated"` with the name as topic and the new record as data. + pub fn update_recipient( + env: Env, + caller: Address, + name: String, + new_address: Address, + ) -> Result<(), RecipientError> { + Self::require_admin(&env, &caller)?; + + let key = StorageKey::Recipient(name.clone()); + if !env.storage().persistent().has(&key) { + return Err(RecipientError::NotFound); + } + + let record = RecipientRecord { + name: name.clone(), + address: new_address, + }; + env.storage().persistent().set(&key, &record); + + env.events().publish( + (events::event_recipient_updated(&env), name), + record, + ); + Ok(()) + } + + /// Remove a registered recipient by name. + /// + /// The recipient must exist. The caller must be the admin and must + /// authorize. The recipient count is decremented with overflow-safe math. + /// + /// # Parameters + /// * `caller` — Must be the current admin; must authorize. + /// * `name` — Name of the recipient to remove. + /// + /// # Errors + /// * [`RecipientError::Unauthorized`] — caller is not the admin. + /// * [`RecipientError::NotFound`] — no recipient with this name exists. + /// + /// # Events + /// Emits `"recipient_removed"` with the name as topic. + pub fn remove_recipient( + env: Env, + caller: Address, + name: String, + ) -> Result<(), RecipientError> { + Self::require_admin(&env, &caller)?; + + let key = StorageKey::Recipient(name.clone()); + if !env.storage().persistent().has(&key) { + return Err(RecipientError::NotFound); + } + + env.storage().persistent().remove(&key); + + let count: u32 = env + .storage() + .instance() + .get(&StorageKey::RecipientCount) + .ok_or(RecipientError::NotInitialized)?; + env.storage().instance().set( + &StorageKey::RecipientCount, + &count.checked_sub(1).ok_or(RecipientError::Overflow)?, + ); + + env.events() + .publish((events::event_recipient_removed(&env), name), ()); + Ok(()) + } + + // ----------------------------------------------------------------------- + // View-only entrypoints + // ----------------------------------------------------------------------- + + /// Return the admin address. + /// + /// # Errors + /// * [`RecipientError::NotInitialized`] — contract was never initialized. + pub fn get_admin(env: Env) -> Result { + Self::admin(&env) + } + + /// Return the registered recipient record for `name`, or + /// [`RecipientError::NotFound`]. + /// + /// Pure view: no auth, no storage writes. + pub fn get_recipient( + env: Env, + name: String, + ) -> Result { + Self::admin(&env)?; // ensure initialized + Self::validate_name(&name)?; + env.storage() + .persistent() + .get(&StorageKey::Recipient(name)) + .ok_or(RecipientError::NotFound) + } + + /// Return whether a recipient with the given `name` is registered. + /// + /// Pure view: no auth, no storage writes. + pub fn has_recipient(env: Env, name: String) -> Result { + Self::admin(&env)?; // ensure initialized + Self::validate_name(&name)?; + Ok(env + .storage() + .persistent() + .has(&StorageKey::Recipient(name))) + } + + /// Return the total number of registered recipients. + /// + /// Pure view: no auth, no storage writes. + pub fn get_recipient_count(env: Env) -> Result { + if !env.storage().instance().has(&StorageKey::Admin) { + return Err(RecipientError::NotInitialized); + } + Ok(env + .storage() + .instance() + .get(&StorageKey::RecipientCount) + .ok_or(RecipientError::NotInitialized)?) + } +} + +// --------------------------------------------------------------------------- +// Test modules +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod test; + +#[cfg(test)] +mod rustdoc_tests { + /// Verify that every public function has a `///` Rustdoc comment. + #[test] + fn every_public_fn_in_lib_has_rustdoc() { + let source = include_str!("lib.rs") + .split("// ---------------------------------------------------------------------------\n// Test modules") + .next() + .expect("lib.rs contains test module marker"); + let lines: std::vec::Vec<&str> = source.lines().collect(); + + for (idx, line) in lines.iter().enumerate() { + let trimmed = line.trim_start(); + if !(trimmed.starts_with("pub fn ") + || trimmed.starts_with("pub(crate) fn ") + || trimmed.starts_with("pub(super) fn ")) + { + continue; + } + + let has_rustdoc = lines[..idx] + .iter() + .rev() + .map(|candidate| candidate.trim_start()) + .find(|candidate| !candidate.is_empty()) + .map(|candidate| candidate.starts_with("///")) + .unwrap_or(false); + + assert!( + has_rustdoc, + "public function on line {} is missing /// rustdoc: {}", + idx + 1, + trimmed + ); + } + } +} diff --git a/contracts/recipient/src/test.rs b/contracts/recipient/src/test.rs new file mode 100644 index 00000000..74fefe15 --- /dev/null +++ b/contracts/recipient/src/test.rs @@ -0,0 +1,258 @@ +use crate::{CalloraRecipient, CalloraRecipientClient, RecipientError}; +use soroban_sdk::testutils::Address as _; +use soroban_sdk::{Address, Env, String as SorobanString}; + +/// Helper to create an Env and register the contract, returning (env, admin, client). +fn setup() -> (Env, Address, CalloraRecipientClient<'static>) { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let contract_addr = env.register(CalloraRecipient, ()); + let client = CalloraRecipientClient::new(&env, &contract_addr); + (env, admin, client) +} + +/// Helper to create a SorobanString from a &str. +fn name(env: &Env, s: &str) -> SorobanString { + SorobanString::from_bytes(env, s.as_bytes()) +} + +// --------------------------------------------------------------------------- +// Initialization tests +// --------------------------------------------------------------------------- + +#[test] +fn init_sets_admin_and_count() { + let (_env, admin, client) = setup(); + client.init(&admin); + assert_eq!(client.get_admin(), admin); + assert_eq!(client.get_recipient_count(), 0u32); +} + +#[test] +fn init_double_init_rejected() { + let (_env, admin, client) = setup(); + client.init(&admin); + let err = client.try_init(&admin); + assert_eq!(err, Err(Ok(RecipientError::AlreadyInitialized))); +} + +#[test] +fn init_requires_auth() { + let env = Env::default(); + let admin = Address::generate(&env); + let contract_addr = env.register(CalloraRecipient, ()); + let client = CalloraRecipientClient::new(&env, &contract_addr); + + // Without mock_all_auths, require_auth should fail. + let err = client.try_init(&admin); + assert!(err.is_err()); +} + +// --------------------------------------------------------------------------- +// register_recipient tests +// --------------------------------------------------------------------------- + +#[test] +fn register_recipient_happy_path() { + let (env, admin, client) = setup(); + client.init(&admin); + + let addr = Address::generate(&env); + let recipient_name = name(&env, "treasury"); + + client.register_recipient(&admin, &recipient_name, &addr); + + assert!(client.has_recipient(&recipient_name)); + let record = client.get_recipient(&recipient_name); + assert_eq!(record.address, addr); + assert_eq!(record.name, recipient_name); + assert_eq!(client.get_recipient_count(), 1u32); +} + +#[test] +fn register_recipient_duplicate_rejected() { + let (env, admin, client) = setup(); + client.init(&admin); + + let addr = Address::generate(&env); + let addr2 = Address::generate(&env); + let recipient_name = name(&env, "ops"); + + client.register_recipient(&admin, &recipient_name, &addr); + let err = client.try_register_recipient(&admin, &recipient_name, &addr2); + assert_eq!(err, Err(Ok(RecipientError::AlreadyRegistered))); +} + +#[test] +fn register_recipient_empty_name_rejected() { + let (env, admin, client) = setup(); + client.init(&admin); + + let addr = Address::generate(&env); + let empty_name = name(&env, ""); + + let err = client.try_register_recipient(&admin, &empty_name, &addr); + assert_eq!(err, Err(Ok(RecipientError::InvalidName))); +} + +#[test] +fn register_recipient_unauthorized() { + let env = Env::default(); + let admin = Address::generate(&env); + let contract_addr = env.register(CalloraRecipient, ()); + let client = CalloraRecipientClient::new(&env, &contract_addr); + + env.mock_all_auths(); + client.init(&admin); + + let outsider = Address::generate(&env); + let addr = Address::generate(&env); + let recipient_name = name(&env, "x"); + + // Clear mock auths so outsider's require_auth fails. + env.set_auths(&[]); + let err = client.try_register_recipient(&outsider, &recipient_name, &addr); + assert!(err.is_err()); +} + +// --------------------------------------------------------------------------- +// update_recipient tests +// --------------------------------------------------------------------------- + +#[test] +fn update_recipient_happy_path() { + let (env, admin, client) = setup(); + client.init(&admin); + + let addr1 = Address::generate(&env); + let addr2 = Address::generate(&env); + let recipient_name = name(&env, "vendor"); + + client.register_recipient(&admin, &recipient_name, &addr1); + client.update_recipient(&admin, &recipient_name, &addr2); + + let record = client.get_recipient(&recipient_name); + assert_eq!(record.address, addr2); + assert_eq!(client.get_recipient_count(), 1u32); +} + +#[test] +fn update_recipient_not_found() { + let (env, admin, client) = setup(); + client.init(&admin); + + let addr = Address::generate(&env); + let missing = name(&env, "nope"); + + let err = client.try_update_recipient(&admin, &missing, &addr); + assert_eq!(err, Err(Ok(RecipientError::NotFound))); +} + +// --------------------------------------------------------------------------- +// remove_recipient tests +// --------------------------------------------------------------------------- + +#[test] +fn remove_recipient_happy_path() { + let (env, admin, client) = setup(); + client.init(&admin); + + let addr = Address::generate(&env); + let recipient_name = name(&env, "temp"); + + client.register_recipient(&admin, &recipient_name, &addr); + assert_eq!(client.get_recipient_count(), 1u32); + + client.remove_recipient(&admin, &recipient_name); + assert!(!client.has_recipient(&recipient_name)); + assert_eq!(client.get_recipient_count(), 0u32); +} + +#[test] +fn remove_recipient_not_found() { + let (env, admin, client) = setup(); + client.init(&admin); + + let missing = name(&env, "ghost"); + let err = client.try_remove_recipient(&admin, &missing); + assert_eq!(err, Err(Ok(RecipientError::NotFound))); +} + +// --------------------------------------------------------------------------- +// View-only tests +// --------------------------------------------------------------------------- + +#[test] +fn get_recipient_not_found() { + let (env, admin, client) = setup(); + client.init(&admin); + + let missing = name(&env, "nobody"); + let err = client.try_get_recipient(&missing); + assert_eq!(err, Err(Ok(RecipientError::NotFound))); +} + +#[test] +fn has_recipient_returns_false_for_missing() { + let (env, admin, client) = setup(); + client.init(&admin); + + let missing = name(&env, "nope"); + assert!(!client.has_recipient(&missing)); +} + +#[test] +fn get_recipient_count_starts_at_zero() { + let (_env, admin, client) = setup(); + client.init(&admin); + assert_eq!(client.get_recipient_count(), 0u32); +} + +#[test] +fn uninitialized_contract_returns_not_initialized() { + let env = Env::default(); + let contract_addr = env.register(CalloraRecipient, ()); + let client = CalloraRecipientClient::new(&env, &contract_addr); + + let err = client.try_get_admin(); + assert_eq!(err, Err(Ok(RecipientError::NotInitialized))); +} + +// --------------------------------------------------------------------------- +// Rustdoc coverage test +// --------------------------------------------------------------------------- + +#[test] +fn every_public_fn_in_lib_has_rustdoc() { + let source = include_str!("lib.rs") + .split("// ---------------------------------------------------------------------------\n// Test modules") + .next() + .expect("lib.rs contains test module marker"); + let lines: std::vec::Vec<&str> = source.lines().collect(); + + for (idx, line) in lines.iter().enumerate() { + let trimmed = line.trim_start(); + if !(trimmed.starts_with("pub fn ") + || trimmed.starts_with("pub(crate) fn ") + || trimmed.starts_with("pub(super) fn ")) + { + continue; + } + + let has_rustdoc = lines[..idx] + .iter() + .rev() + .map(|candidate| candidate.trim_start()) + .find(|candidate| !candidate.is_empty()) + .map(|candidate| candidate.starts_with("///")) + .unwrap_or(false); + + assert!( + has_rustdoc, + "public function on line {} is missing /// rustdoc: {}", + idx + 1, + trimmed + ); + } +}