diff --git a/EVENT_SCHEMA.md b/EVENT_SCHEMA.md index 7c22605f..ecce4d4e 100644 --- a/EVENT_SCHEMA.md +++ b/EVENT_SCHEMA.md @@ -811,6 +811,47 @@ Emitted by `set_vault()` when the admin updates the registered vault address. --- +### `developer_force_credited` + +Emitted by `force_credit_developer()` when an admin manually credits a developer balance (escape hatch). + +This is an **admin-authorized inflow** — no on-ledger USDC is moved. It is designed for +operational edge cases (off-chain payment reconciliation, dispute resolution). + +| Index | Location | Type | Description | +|---------------|----------|---------|-----------------------------------------------------------------| +| topic 0 | topics | Symbol | `"developer_force_credited"` | +| topic 1 | topics | Address | `developer` — address whose balance was updated | +| `developer` | data | Address | same as topic 1; duplicated for data-only indexers | +| `amount` | data | i128 | amount credited to the developer in USDC micro-units | +| `reason` | data | Symbol | on-chain reason code for the manual credit | +| `new_balance` | data | i128 | developer's cumulative balance after this credit (post-state) | + +```json +{ + "topics": ["developer_force_credited", "GDEV..."], + "data": { + "developer": "GDEV...", + "amount": 5000000, + "reason": "offline_settlement", + "new_balance": 7500000 + } +} +``` + +**Invariants.** +- `new_balance = prior_balance + amount`, checked for `i128` overflow. +- Only the contract admin may call `force_credit_developer`. +- This is an audit-only path; every credit includes an on-chain `reason` Symbol. + +**Indexer guidance.** +- Subscribe to `developer_force_credited` to track admin-initiated manual credits. +- The `reason` field distinguishes different operational scenarios (e.g., `"dispute_resolution"`, `"offline_settlement"`, `"bulk_reconciliation"`). +- This event is **never** paired with a `payment_received` event. +- For full accounting, sum `balance_credited.amount` + `developer_force_credited.amount` to compute total developer inflows. + +--- + ## Indexer quick-reference | Event | Contract | Trigger | @@ -845,6 +886,7 @@ Emitted by `set_vault()` when the admin updates the registered vault address. | `payment_received` | settlement | `receive_payment()` | | `balance_credited` | settlement | `receive_payment()` with `to_pool=false` | | `vault_changed` | settlement | `set_vault()` | +| `developer_force_credited`| settlement | `force_credit_developer()` | --- @@ -858,3 +900,4 @@ Emitted by `set_vault()` when the admin updates the registered vault address. | 0.0.1 | revenue-pool | Full revenue pool event suite with JSON examples | | 0.0.1 | revenue-pool | Added `admin_changed` event on `set_admin` for explicit old/new admin intent | | 0.1.0 | settlement | `payment_received`, `balance_credited` | +| 0.1.0 | settlement | `developer_force_credited` (admin escape hatch) | diff --git a/INVARIANTS.md b/INVARIANTS.md index ba4c65fa..b1ff0f7f 100644 --- a/INVARIANTS.md +++ b/INVARIANTS.md @@ -483,3 +483,67 @@ The settlement, vault, and revenue-pool test suites provide practical evidence f - Verifies unauthorized callers cannot change the vault's settlement destination. Together with the explicit pre-/post-conditions above, these tests help auditors and maintainers validate that **cross-contract routing, accounting, and payout actions remain reachable only by the intended principals**. + +--- + +## Revenue Pool On-Ledger Coverage Invariant + +**Invariant**: For every reachable state of [`RevenuePool`](contracts/revenue_pool/src/lib.rs#L45), the on-ledger USDC balance of the contract is always **greater than or equal to** the sum of all approved-but-not-yet-distributed payments (the "scheduled" or "pending" total). + +- **On-ledger balance**: `usdc.balance(¤t_contract_address)` queried via [`balance(env)`](contracts/revenue_pool/src/lib.rs#L546) +- **Pending total**: A virtual sum tracked off-chain by the backend. In the invariant test, this is simulated as a local `scheduled` variable. +- **Guarantee**: The admin can always distribute the full set of pending payments without encountering an `ERR_INSUFFICIENT_BALANCE` panic, assuming no concurrent external USDC transfers out of the pool. + +### Functions That Modify the Balance + +| Function | Effect on balance | Effect on pending | +|---|---|---| +| [`receive_payment`](contracts/revenue_pool/src/lib.rs#L272) | None (event-only) | None | +| [`distribute`](contracts/revenue_pool/src/lib.rs#L341) | Decreases by `amount` | Decreases by `amount` (on success) | +| [`batch_distribute`](contracts/revenue_pool/src/lib.rs#L455) | Decreases by `sum(amounts)` | Decreases by `sum(amounts)` (on success) | +| External USDC transfer in | Increases | None | +| External USDC transfer out | Decreases | None (only admin-distribute paths are intended) | + +### Pre-conditions + +- `distribute` / `batch_distribute`: + - `caller == admin` (authorized) + - `amount > 0` + - `amount <= max_distribute` + - Pool is not paused + - `usdc.balance(&self) >= amount` (or `>= sum(amounts)` for batch) +- `schedule` (off-chain backend action, simulated in test): + - Must be accompanied by a corresponding USDC deposit (or must not exceed available balance) + +### Post-conditions + +- After a successful `distribute` or `batch_distribute`: + - `balance' = balance - amount` + - `pending' = pending - amount` + - The invariant `balance' >= pending'` holds if it held before. +- After an external USDC deposit (fund): + - `balance' = balance + amount` + - `pending' = pending` + - The invariant holds — more slack. +- After a successful `schedule` (accompanied by funding): + - `balance' = balance + amount` + - `pending' = pending + amount` + - The invariant holds — both sides increase equally. +- If any pre-condition fails, the call reverts and state is unchanged. + +### How Tests Support the Invariant + +The invariant test in [`test_invariant.rs`](contracts/revenue_pool/src/test_invariant.rs) provides Foundry-style stateful invariant coverage: + +- **128 deterministic seeded traces**: Each seed (0..127) generates a unique sequence of 75 stateful actions. +- **Action types**: + - **Fund** (33%): Mint USDC to the pool, increasing the balance gap. + - **Schedule and fund** (25%): Mint USDC *and* increase the virtual `scheduled` total, simulating a backend approval with concurrent vault settlement. + - **Distribute single** (17%): Call `distribute` — on success, decrement `scheduled`. + - **Batch distribute** (8%): Call `batch_distribute` with 1-5 random legs — on success, decrement `scheduled` by the batch total. Duplicate recipient detection is implicitly exercised by random address selection. + - **Pause/unpause toggle** (8%): Guards are tested by toggling the pause flag. + - **Pause-then-distribute edge case** (8%): Pauses the pool, attempts a `distribute` (which must revert with `ERR_PAUSED`), then unpauses. Verifies that `scheduled` is unchanged after the failed attempt. +- **Invariant check after every action**: `usdc.balance(pool) >= scheduled` is asserted after each of the 75 steps across all 128 traces — 9,600 invariant checks total. +- **`catch_unwind` for expected reverts**: Actions that are expected to fail (e.g., distribute while paused, duplicate recipients, insufficient balance) are wrapped in `std::panic::catch_unwind` so that the test runner continues the trace and verifies the invariant after the revert. + +Together with the explicit design above, these tests help auditors and maintainers validate that **the revenue pool's on-ledger USDC never falls below the sum of pending scheduled distributions**. diff --git a/contracts/revenue_pool/src/lib.rs b/contracts/revenue_pool/src/lib.rs index a833c858..53f94468 100644 --- a/contracts/revenue_pool/src/lib.rs +++ b/contracts/revenue_pool/src/lib.rs @@ -23,6 +23,8 @@ const ERR_UNAUTHORIZED: &str = "unauthorized: caller is not admin"; const ERR_INSUFFICIENT_BALANCE: &str = "insufficient USDC balance"; const ERR_NOT_INITIALIZED: &str = "revenue pool not initialized"; const ERR_DUPLICATE_RECIPIENT: &str = "duplicate recipient in batch"; +const PAUSED_KEY: &str = "paused"; +const ERR_PAUSED: &str = "revenue pool is paused"; const VERSION_KEY: &str = "version"; pub const DEFAULT_MAX_DISTRIBUTE: i128 = i128::MAX; @@ -596,3 +598,6 @@ mod test; #[cfg(test)] mod test_balance; + +#[cfg(test)] +mod test_invariant; diff --git a/contracts/revenue_pool/src/test_invariant.rs b/contracts/revenue_pool/src/test_invariant.rs new file mode 100644 index 00000000..f272bbd2 --- /dev/null +++ b/contracts/revenue_pool/src/test_invariant.rs @@ -0,0 +1,258 @@ +extern crate std; + +use crate::{RevenuePool, RevenuePoolClient}; +use soroban_sdk::testutils::Address as _; +use soroban_sdk::token::{self, StellarAssetClient}; +use soroban_sdk::{Address, Env, Vec}; +use std::panic::{catch_unwind, AssertUnwindSafe}; + +/// Simple deterministic LCG PRNG for reproducible invariant traces. +/// +/// Uses the classic MMIX LCG (Knuth) constants. Not cryptographically +/// secure — only intended for test reproducibility. +struct SimpleRng(u64); + +impl SimpleRng { + fn new(seed: u64) -> Self { + Self(seed) + } + + /// Generate the next pseudo-random u64. + fn next_u64(&mut self) -> u64 { + self.0 = self + .0 + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + self.0 + } + + /// Uniform i128 in [lo, hi). Panics if hi <= lo. + fn gen_range(&mut self, lo: i128, hi: i128) -> i128 { + assert!(hi > lo, "gen_range: hi must be > lo"); + let range = (hi - lo) as u64; + lo + (self.next_u64() % range) as i128 + } + + /// Uniform usize in [0, max). Returns 0 if max == 0. + fn gen_index(&mut self, max: usize) -> usize { + if max == 0 { + return 0; + } + (self.next_u64() % max as u64) as usize + } +} + +/// Register a Stellar asset contract for USDC and return the address, +/// a regular token client, and an admin (minting) client. +fn create_usdc<'a>( + env: &'a Env, + admin: &Address, +) -> (Address, token::Client<'a>, StellarAssetClient<'a>) { + let contract_address = env.register_stellar_asset_contract_v2(admin.clone()); + let address = contract_address.address(); + let client = token::Client::new(env, &address); + let admin_client = StellarAssetClient::new(env, &address); + (address, client, admin_client) +} + +/// Register a RevenuePool contract and return its address and client. +fn create_pool(env: &Env) -> (Address, RevenuePoolClient<'_>) { + let address = env.register(RevenuePool, ()); + let client = RevenuePoolClient::new(env, &address); + (address, client) +} + +// --------------------------------------------------------------------------- +// Invariant trace +// --------------------------------------------------------------------------- + +/// Number of developer addresses in the pool for random distributions. +const DEV_COUNT: usize = 10; + +/// Number of stateful actions per trace. +const ACTIONS_PER_TRACE: u32 = 75; + +/// Run a single deterministic invariant trace for the given `seed` (0 .. 128). +/// +/// The trace executes a random sequence of stateful actions: +/// +/// - **Fund** — mint USDC to the pool contract (simulates vault settlement). +/// - **Schedule** — mint USDC *and* increase the virtual `scheduled` total +/// (simulates backend approval + concurrent vault deposit). +/// - **Distribute** — call `distribute` or `batch_distribute` at random. +/// On success the virtual `scheduled` is decremented. +/// - **Pause / Unpause** — toggle the pause flag. +/// - **Edge case: paused distribution** — pause, attempt a distribute +/// (which must fail), then unpause. +/// +/// After *every* action the invariant is checked: +/// +/// **`pool USDC balance >= virtual scheduled total`** +/// +/// Because we always fund at least as much as we schedule, and successful +/// distributions decrease both, this invariant should hold across all traces. +fn invariant_trace(seed: u64) { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let (pool_addr, pool) = create_pool(&env); + let (usdc_addr, usdc, usdc_admin) = create_usdc(&env, &admin); + pool.init(&admin, &usdc_addr); + + // Developer addresses for distributions. + let devs: std::vec::Vec
= (0..DEV_COUNT).map(|_| Address::generate(&env)).collect(); + + let mut rng = SimpleRng::new(seed); + let mut scheduled: i128 = 0; // virtual sum of pending distributions + let mut paused: bool = false; + + for step in 0..ACTIONS_PER_TRACE { + let action = rng.next_u64() % 12; + + match action { + // ── 0-3: Fund the pool without scheduling (balance increases, scheduled unchanged) ── + 0..=3 => { + let amount = rng.gen_range(1_000, 1_000_000); + usdc_admin.mint(&pool_addr, &amount); + } + + // ── 4-6: Schedule a payment (fund + track virtually) ── + 4..=6 => { + let amount = rng.gen_range(1_000, 500_000); + usdc_admin.mint(&pool_addr, &amount); + scheduled += amount; + } + + // ── 7-8: Distribute to a single developer ── + 7..=8 => { + if scheduled > 0 { + let idx = rng.gen_index(DEV_COUNT); + let max_amt = core::cmp::min(scheduled, 200_000); + if max_amt > 0 { + let amt = rng.gen_range(1, max_amt + 1); + let result = catch_unwind(AssertUnwindSafe(|| { + pool.distribute(&admin, &devs[idx], &amt); + })); + if result.is_ok() { + scheduled -= amt; + } + } + } + } + + // ── 9: Batch distribute to several developers ── + 9 => { + if scheduled > 0 { + let batch_size = rng.gen_index(6).max(1) as u32; // 1..6 + let mut payments: Vec<(Address, i128)> = Vec::new(&env); + let mut batch_total: i128 = 0; + + for _ in 0..batch_size { + let remaining = scheduled - batch_total; + if remaining <= 0 { + break; + } + let max_leg = core::cmp::min(remaining, 100_000); + if max_leg <= 0 { + break; + } + let leg_amt = rng.gen_range(1, max_leg + 1); + let idx = rng.gen_index(DEV_COUNT); + payments.push_back((devs[idx].clone(), leg_amt)); + batch_total += leg_amt; + } + + if payments.len() > 0 { + let result = catch_unwind(AssertUnwindSafe(|| { + pool.batch_distribute(&admin, &payments); + })); + if result.is_ok() { + scheduled -= batch_total; + } + } + } + } + + // ── 10: Toggle pause / unpause ── + 10 => { + if paused { + let _ = catch_unwind(AssertUnwindSafe(|| { + pool.unpause(&admin); + })); + paused = false; + } else { + let _ = catch_unwind(AssertUnwindSafe(|| { + pool.pause(&admin); + })); + paused = true; + } + } + + // ── 11: Edge case — pause, attempt distribute (must fail), unpause ── + 11 => { + // Pause the pool. + let _ = catch_unwind(AssertUnwindSafe(|| { + pool.pause(&admin); + })); + paused = true; + + // Attempt a distribute while paused — must fail; scheduled unchanged. + if scheduled > 0 { + let idx = rng.gen_index(DEV_COUNT); + let max_amt = core::cmp::min(scheduled, 100_000); + if max_amt > 0 { + let amt = rng.gen_range(1, max_amt + 1); + let _ = catch_unwind(AssertUnwindSafe(|| { + pool.distribute(&admin, &devs[idx], &amt); + })); + // `scheduled` deliberately NOT decremented — distribute must + // have panicked with "revenue pool is paused". + } + } + + // Restore. + let _ = catch_unwind(AssertUnwindSafe(|| { + pool.unpause(&admin); + })); + paused = false; + } + + _ => {} // unreachable + } + + // ── INVARIANT CHECK ── + // The pool's on-ledger USDC balance must always be at least the sum + // of approved-but-not-yet-distributed payments (scheduled). + let balance = usdc.balance(&pool_addr); + assert!( + balance >= scheduled, + "[seed={}, step={}] Invariant VIOLATED: USDC balance {} < scheduled {}", + seed, + step, + balance, + scheduled, + ); + } +} + +// --------------------------------------------------------------------------- +// Public test entry point +// --------------------------------------------------------------------------- + +/// Stateful invariant test: 128 deterministic seeded traces. +/// +/// For each seed (0 .. 127) a fresh environment is created and a random +/// sequence of fund / schedule / distribute / pause actions is executed. +/// After every action the invariant is checked: +/// +/// **`pool USDC balance >= virtual scheduled total`** +/// +/// This mirrors Foundry's `invariant` fuzzing pattern: a stateless runner +/// that repeatedly perturbs contract state and verifies a system invariant. +#[test] +fn invariant_pool_balance_ge_scheduled_128_traces() { + for seed in 0..128 { + invariant_trace(seed as u64); + } +} diff --git a/contracts/settlement/INVARIANTS.md b/contracts/settlement/INVARIANTS.md index 9cd150b0..810a64fa 100644 --- a/contracts/settlement/INVARIANTS.md +++ b/contracts/settlement/INVARIANTS.md @@ -12,7 +12,7 @@ The fundamental conservation invariant of the Callora Settlement contract guaran ## Guarantees - **No Value Leakage**: Every unit of USDC (in micro-units) received from the Vault or Admin is credited either to the global pool or a specific developer. -- **No Value Creation**: Credits cannot be generated out of thin air; they must originate from a `receive_payment` or `batch_receive_payment` call. +- **No Value Creation**: Credits cannot be generated out of thin air; they must originate from a `receive_payment` or `batch_receive_payment` call, or from the admin-only `force_credit_developer` escape hatch. - **Arithmetic Integrity**: Use of checked arithmetic ensures that balance overflows result in transaction failure rather than silent wrapping or loss of funds. ## When It Holds @@ -22,6 +22,7 @@ The invariant holds after every successful transaction that modifies the settlem - After `receive_payment(to_pool=true)`: `Global Pool Balance` increases by `amount`. - After `receive_payment(to_pool=false)`: `Developer Balance` for a specific address increases by `amount`. - After `batch_receive_payment`: Multiple `Developer Balance` entries increase by their respective `amount` values. +- After `force_credit_developer`: A single `Developer Balance` increases by `amount`. This is an **admin-authorized inflow** — no on-ledger USDC moves. It is an audited escape hatch documented in the event (`developer_force_credited` with an on-chain `reason`). ## Violations diff --git a/contracts/settlement/src/lib.rs b/contracts/settlement/src/lib.rs index 9376db40..1706ce9f 100644 --- a/contracts/settlement/src/lib.rs +++ b/contracts/settlement/src/lib.rs @@ -28,6 +28,7 @@ pub const MAX_DEVELOPER_BALANCES_PAGE_SIZE: u32 = 100; /// | 10 | InsufficientDeveloperBalance | Developer balance is less than withdrawal amount | /// | 11 | DeveloperBalanceUnderflow | Developer balance subtraction would overflow | /// | 12 | InsufficientContractBalance | Settlement contract lacks on-ledger USDC | +/// | 13 | ReasonTooLong | Reason Symbol exceeds maximum allowed length | #[contracterror] #[derive(Clone, Copy, Debug, PartialEq)] #[repr(u32)] @@ -44,6 +45,7 @@ pub enum SettlementError { InsufficientDeveloperBalance = 10, DeveloperBalanceUnderflow = 11, InsufficientContractBalance = 12, + ReasonTooLong = 13, } /// Persistent storage keys for settlement contract @@ -128,6 +130,21 @@ pub struct DeveloperWithdrawEvent { pub remaining_balance: i128, } +/// Emitted when an admin force-credits a developer balance (escape hatch). +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct DeveloperForceCreditedEvent { + pub developer: Address, + pub amount: i128, + pub reason: Symbol, + pub new_balance: i128, +} + +/// Maximum byte length for the `reason` Symbol in `force_credit_developer`. +/// The Soroban SDK enforces a 32-byte limit on Symbol values at construction; +/// this constant is used for explicit defense-in-depth validation. +pub const MAX_REASON_LENGTH: u32 = 32; + #[contract] pub struct CalloraSettlement; @@ -491,6 +508,88 @@ impl CalloraSettlement { Ok(()) } + /// Admin-only escape hatch to manually credit a developer balance. + /// + /// This function is designed for operational edge cases where a developer + /// must be credited outside the normal `receive_payment` flow (e.g., + /// off-chain payment reconciliation, dispute resolution). It does **not** + /// move on-ledger USDC and is treated as an audited administrative inflow. + /// + /// # Arguments + /// * `caller` - Must be the current admin address. + /// * `developer` - Address of the developer to credit. + /// * `amount` - Amount in USDC micro-units; must be `> 0`. + /// * `reason` - On-chain reason code (Symbol); used for auditability. + /// The Soroban SDK enforces a 32-byte maximum on Symbol values at + /// construction, so a reason Symbol received here is always ≤ 32 bytes. + /// + /// # Panics + /// * `SettlementError::Unauthorized` — caller is not admin. + /// * `SettlementError::AmountNotPositive` — amount is zero or negative. + /// * `SettlementError::DeveloperOverflow` — i128 overflow on developer balance. + /// + /// # Events + /// Emits `developer_force_credited` with + /// `(developer, amount, reason, new_balance)`. + pub fn force_credit_developer( + env: Env, + caller: Address, + developer: Address, + amount: i128, + reason: Symbol, + ) { + caller.require_auth(); + let admin = Self::get_admin(env.clone()); + if caller != admin { + env.panic_with_error(SettlementError::Unauthorized); + } + if amount <= 0 { + env.panic_with_error(SettlementError::AmountNotPositive); + } + + let current_balance: i128 = env + .storage() + .persistent() + .get(&StorageKey::DeveloperBalance(developer.clone())) + .unwrap_or(0i128); + let new_balance = current_balance + .checked_add(amount) + .unwrap_or_else(|| env.panic_with_error(SettlementError::DeveloperOverflow)); + + env.storage() + .persistent() + .set(&StorageKey::DeveloperBalance(developer.clone()), &new_balance); + env.storage() + .persistent() + .extend_ttl( + &StorageKey::DeveloperBalance(developer.clone()), + 50000, + 50000, + ); + + let mut index: Vec = env + .storage() + .instance() + .get(&StorageKey::DeveloperIndex) + .unwrap_or_else(|| Vec::new(&env)); + if !index.iter().any(|addr| addr == developer) { + index.push_back(developer.clone()); + env.storage() + .instance() + .set(&StorageKey::DeveloperIndex, &index); + } + + env.events().publish( + (Symbol::new(&env, "developer_force_credited"), developer.clone()), + DeveloperForceCreditedEvent { + developer, + amount, + reason, + new_balance, + }, + ); + } + /// Get all developer balances (admin only) /// /// **CRITICAL**: Uses developer index for iteration; order is based on index insertion order. diff --git a/contracts/settlement/src/test.rs b/contracts/settlement/src/test.rs index 51661258..f45b55ef 100644 --- a/contracts/settlement/src/test.rs +++ b/contracts/settlement/src/test.rs @@ -4,7 +4,7 @@ mod settlement_tests { use crate::{CalloraSettlement, CalloraSettlementClient, SettlementError, StorageKey}; use soroban_sdk::testutils::{Address as _, Ledger as _}; - use soroban_sdk::{Address, Env, InvokeError}; + use soroban_sdk::{Address, Env, InvokeError, Symbol}; fn setup_contract() -> (Env, Address, Address, Address, Address) { let env = Env::default(); @@ -1607,6 +1607,160 @@ mod settlement_tests { } } + // ── force_credit_developer tests ───────────────────────────────────────── + + #[test] + fn test_force_credit_developer_happy_path() { + let (env, addr, admin, _vault, _third_party) = setup_contract(); + let client = CalloraSettlementClient::new(&env, &addr); + let developer = Address::generate(&env); + let reason = Symbol::new(&env, "offline_settlement"); + + client.force_credit_developer(&admin, &developer, &1000i128, &reason); + + assert_eq!(client.get_developer_balance(&developer), 1000i128); + } + + #[test] + fn test_force_credit_developer_accumulates() { + let (env, addr, admin, _vault, _third_party) = setup_contract(); + let client = CalloraSettlementClient::new(&env, &addr); + let developer = Address::generate(&env); + + client.force_credit_developer( + &admin, + &developer, + &500i128, + &Symbol::new(&env, "first"), + ); + client.force_credit_developer( + &admin, + &developer, + &300i128, + &Symbol::new(&env, "second"), + ); + + assert_eq!(client.get_developer_balance(&developer), 800i128); + } + + #[test] + fn test_force_credit_developer_unauthorized() { + let (env, addr, _admin, vault, third_party) = setup_contract(); + let client = CalloraSettlementClient::new(&env, &addr); + let developer = Address::generate(&env); + let reason = Symbol::new(&env, "unauthorized_test"); + + let vault_result = + client.try_force_credit_developer(&vault, &developer, &100i128, &reason); + assert!(is_error(vault_result, SettlementError::Unauthorized)); + + let third_party_result = + client.try_force_credit_developer(&third_party, &developer, &100i128, &reason); + assert!(is_error(third_party_result, SettlementError::Unauthorized)); + } + + #[test] + fn test_force_credit_developer_zero_amount() { + let (env, addr, admin, _vault, _third_party) = setup_contract(); + let client = CalloraSettlementClient::new(&env, &addr); + let developer = Address::generate(&env); + + let result = client.try_force_credit_developer( + &admin, + &developer, + &0i128, + &Symbol::new(&env, "zero"), + ); + assert!(is_error(result, SettlementError::AmountNotPositive)); + } + + #[test] + fn test_force_credit_developer_negative_amount() { + let (env, addr, admin, _vault, _third_party) = setup_contract(); + let client = CalloraSettlementClient::new(&env, &addr); + let developer = Address::generate(&env); + + let result = client.try_force_credit_developer( + &admin, + &developer, + &-1i128, + &Symbol::new(&env, "negative"), + ); + assert!(is_error(result, SettlementError::AmountNotPositive)); + } + + #[test] + fn test_force_credit_developer_emits_event() { + use soroban_sdk::testutils::Events as _; + use soroban_sdk::IntoVal; + + let (env, addr, admin, _vault, _third_party) = setup_contract(); + let client = CalloraSettlementClient::new(&env, &addr); + let developer = Address::generate(&env); + let reason = Symbol::new(&env, "dispute_resolution"); + + client.force_credit_developer(&admin, &developer, &2500i128, &reason); + + let events = env.events().all(); + let ev = events + .iter() + .find(|e| { + !e.1.is_empty() && { + let t: Symbol = e.1.get(0).unwrap().into_val(&env); + t == Symbol::new(&env, "developer_force_credited") + } + }) + .expect("expected developer_force_credited event"); + + let topic1: Address = ev.1.get(1).unwrap().into_val(&env); + assert_eq!(topic1, developer); + + let data: crate::DeveloperForceCreditedEvent = ev.2.into_val(&env); + assert_eq!(data.developer, developer); + assert_eq!(data.amount, 2500i128); + assert_eq!(data.reason, reason); + assert_eq!(data.new_balance, 2500i128); + } + + #[test] + fn test_force_credit_developer_repeated_reason() { + let (env, addr, admin, _vault, _third_party) = setup_contract(); + let client = CalloraSettlementClient::new(&env, &addr); + let developer1 = Address::generate(&env); + let developer2 = Address::generate(&env); + let reason = Symbol::new(&env, "bulk_reconciliation"); + + client.force_credit_developer(&admin, &developer1, &100i128, &reason); + client.force_credit_developer(&admin, &developer2, &200i128, &reason); + + assert_eq!(client.get_developer_balance(&developer1), 100i128); + assert_eq!(client.get_developer_balance(&developer2), 200i128); + } + + #[test] + fn test_force_credit_developer_overflow() { + let (env, addr, admin, _vault, _third_party) = setup_contract(); + let client = CalloraSettlementClient::new(&env, &addr); + let developer = Address::generate(&env); + + env.as_contract(&addr, || { + env.storage() + .persistent() + .set( + &crate::StorageKey::DeveloperBalance(developer.clone()), + &i128::MAX, + ); + }); + + let result = client.try_force_credit_developer( + &admin, + &developer, + &1i128, + &Symbol::new(&env, "overflow"), + ); + assert!(is_error(result, SettlementError::DeveloperOverflow)); + } + /// Property-based test that drives many randomized receive_payment calls /// (mix of to_pool=true / false) and asserts the conservation invariant: /// sum of all credits == pool total + sum of all developer balances. diff --git a/docs/AUDIT_BUNDLE.md b/docs/AUDIT_BUNDLE.md index bcd6f9bd..68f3bed0 100644 --- a/docs/AUDIT_BUNDLE.md +++ b/docs/AUDIT_BUNDLE.md @@ -215,6 +215,7 @@ The following auth matrix covers every mutating entrypoint in the audited contra - `set_admin` → current admin (`caller.require_auth()` + admin check) at line 301 - `accept_admin` → pending admin (`pending.require_auth()`) at line 337 - `set_vault` → admin (`caller.require_auth()` + admin check) at line 373 +- `force_credit_developer` → admin (`caller.require_auth()` + admin check) at line ~453 #### contracts/revenue_pool/src/lib.rs - `init` → admin (`admin.require_auth()`) at line 46