From b410d64b6d19cf64c337c184a65fe428ffa0929a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timot=C3=A9=C3=A9?= Date: Wed, 30 Sep 2026 04:39:28 +0100 Subject: [PATCH 1/2] fix(vault): share validate_deduct between deduct and simulate_deduct (#1115) Extract validate_deduct used by both paths (authorized caller, pause, min/max bounds, balance). Drop slippage/rate-limit/duplicate checks from the simulator, align request_id to u64, update module docs, add 256-case parity proptest. --- contracts/vault/Cargo.toml | 1 + contracts/vault/src/lib.rs | 97 ++++--- contracts/vault/src/test_simulate_parity.rs | 281 ++++++++++++++++++++ contracts/vault/src/views.rs | 143 ++++------ 4 files changed, 394 insertions(+), 128 deletions(-) create mode 100644 contracts/vault/src/test_simulate_parity.rs diff --git a/contracts/vault/Cargo.toml b/contracts/vault/Cargo.toml index 999ad419..27afadd8 100644 --- a/contracts/vault/Cargo.toml +++ b/contracts/vault/Cargo.toml @@ -15,5 +15,6 @@ callora-validators = { path = "../validators" } [dev-dependencies] soroban-sdk = { workspace = true, features = ["testutils"] } rand = "0.8" +proptest = { version = "1", default-features = false, features = ["std", "alloc"] } callora-settlement = { path = "../settlement" } callora-revenue-pool = { path = "../revenue_pool" } diff --git a/contracts/vault/src/lib.rs b/contracts/vault/src/lib.rs index f5f1a483..94e260a3 100644 --- a/contracts/vault/src/lib.rs +++ b/contracts/vault/src/lib.rs @@ -233,6 +233,66 @@ impl CalloraVault { Ok(()) } + /// Shared validation pipeline for [`deduct`] and [`crate::views::simulate_deduct`]. + /// + /// Checks, **in the same order `deduct` uses**, that: + /// 1. `caller` is the authorized deduct caller → [`VaultError::Unauthorized`]. + /// 2. The vault is not paused → [`VaultError::Paused`]. + /// 3. `amount > 0` → [`VaultError::AmountNotPositive`]. + /// 4. `amount >= min_deposit` → [`VaultError::BelowMinDeposit`]. + /// 5. `amount <= max_deduct` → [`VaultError::ExceedsMaxDeduct`]. + /// 6. Tracked balance ≥ `amount` → [`VaultError::InsufficientBalance`]. + /// + /// This function is **read-only** (no storage writes, no events, no auth). + /// `deduct` calls it before any mutation so that the validation order is + /// guaranteed identical to what `simulate_deduct` observes. + pub(crate) fn validate_deduct(env: &Env, caller: &Address, amount: i128) -> Result<(), VaultError> { + // 1. Authorized-caller check. + let auth_caller = env + .storage() + .instance() + .get::<_, Address>(&DataKey::AuthorizedCaller) + .unwrap_or_else(|| panic!("Authorized caller not set")); + if *caller != auth_caller { + return Err(VaultError::Unauthorized); + } + + // 2. Pause guard. + if env + .storage() + .instance() + .get::<_, bool>(&DataKey::Paused) + .unwrap_or(false) + { + return Err(VaultError::Paused); + } + + // 3-5. Amount bounds (positive, min_deposit, max_deduct). + let min_dep = env + .storage() + .instance() + .get::<_, i128>(&DataKey::MinDeposit) + .unwrap(); + let max_deduct = env + .storage() + .instance() + .get::<_, i128>(&DataKey::MaxDeduct) + .unwrap(); + Self::require_valid_deduct_amount(amount, min_dep, max_deduct)?; + + // 6. Balance check. + let current_bal = env + .storage() + .instance() + .get::<_, i128>(&DataKey::Balance) + .unwrap_or(0); + if current_bal < amount { + return Err(VaultError::InsufficientBalance); + } + + Ok(()) + } + /// Initialize the Callora Vault contract (one-time setup). /// /// Stores configuration in instance storage and sets the paused flag to `false`. @@ -439,23 +499,9 @@ impl CalloraVault { ) -> Result<(), VaultError> { caller.require_auth(); - let auth_caller = env - .storage() - .instance() - .get::<_, Address>(&DataKey::AuthorizedCaller) - .unwrap_or_else(|| panic!("Authorized caller not set")); + // Shared validation — same function simulate_deduct calls. + Self::validate_deduct(&env, &caller, amount)?; - if caller != auth_caller { - return Err(VaultError::Unauthorized); - } - if env - .storage() - .instance() - .get::<_, bool>(&DataKey::Paused) - .unwrap_or(false) - { - return Err(VaultError::Paused); - } let settlement_addr = Self::require_settlement(&env)?; let usdc_addr = env .storage() @@ -463,25 +509,11 @@ impl CalloraVault { .get::<_, Address>(&DataKey::UsdcToken) .ok_or(VaultError::NotInitialized)?; - let min_dep = env - .storage() - .instance() - .get::<_, i128>(&DataKey::MinDeposit) - .unwrap(); - let max_deduct = env - .storage() - .instance() - .get::<_, i128>(&DataKey::MaxDeduct) - .unwrap(); - Self::require_valid_deduct_amount(amount, min_dep, max_deduct)?; let current_bal = env .storage() .instance() .get::<_, i128>(&DataKey::Balance) .unwrap_or(0); - if current_bal < amount { - return Err(VaultError::InsufficientBalance); - } let new_bal = current_bal .checked_sub(amount) @@ -2462,6 +2494,11 @@ mod test_recovery_idempotency; #[cfg(test)] mod test_event_schema; +/// Parity tests for `simulate_deduct` vs `deduct` (Issue #1115). +/// Run with: `cargo test -p callora-vault simulate` +#[cfg(test)] +mod test_simulate_parity; + // #[cfg(test)] // mod test_gas_budget; // #[cfg(test)] diff --git a/contracts/vault/src/test_simulate_parity.rs b/contracts/vault/src/test_simulate_parity.rs new file mode 100644 index 00000000..bf79a20d --- /dev/null +++ b/contracts/vault/src/test_simulate_parity.rs @@ -0,0 +1,281 @@ +//! Parity tests for `simulate_deduct` vs `deduct` (Issue #1115). +//! +//! Verifies that `simulate_deduct` and `deduct` share `validate_deduct` and +//! therefore return matching error codes for every validation-stage input. +//! All test function names contain "simulate" so `cargo test -p callora-vault simulate` +//! runs them. + +extern crate std; + +use proptest::prelude::*; +use soroban_sdk::testutils::{Address as _, Ledger as _}; +use soroban_sdk::{token, Address, Env}; + +use super::*; +use callora_settlement::CalloraSettlement; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn create_usdc<'a>( + env: &'a Env, + admin: &Address, +) -> (Address, token::Client<'a>, token::StellarAssetClient<'a>) { + let ca = env.register_stellar_asset_contract_v2(admin.clone()); + let addr = ca.address(); + ( + addr.clone(), + token::Client::new(env, &addr), + token::StellarAssetClient::new(env, &addr), + ) +} + +fn create_vault(env: &Env) -> (Address, CalloraVaultClient<'_>) { + let address = env.register(CalloraVault, ()); + let client = CalloraVaultClient::new(env, &address); + (address, client) +} + +fn create_settlement(env: &Env, admin: &Address, vault_address: &Address) -> Address { + let settlement_address = env.register(CalloraSettlement, ()); + let settlement_client = + callora_settlement::CalloraSettlementClient::new(env, &settlement_address); + env.mock_all_auths(); + settlement_client.init(admin, vault_address); + settlement_address +} + +/// Set up a vault with: +/// - `tracked` initial balance +/// - `on_ledger` USDC minted to vault (for real deducts) +/// - `authorized_caller` as the deduct caller +/// - `min_deposit = 1`, `max_deduct = max_deduct` +/// +/// Returns `(client, vault_addr, auth_caller, usdc_admin)`. +fn setup_simulate_vault<'a>( + env: &'a Env, + tracked: i128, + on_ledger: i128, + max_deduct: i128, +) -> ( + CalloraVaultClient<'a>, + Address, + Address, + token::StellarAssetClient<'a>, +) { + let owner = Address::generate(env); + let auth_caller = Address::generate(env); + let (vault_addr, client) = create_vault(env); + let (usdc, _usdc_client, usdc_admin) = create_usdc(env, &owner); + let settlement = create_settlement(env, &owner, &vault_addr); + + env.mock_all_auths(); + client.init( + &owner, + &usdc, + &Some(tracked), + &Some(auth_caller.clone()), + &Some(1i128), + &None::
, + &Some(max_deduct), + &Some(settlement), + ); + usdc_admin.mint(&vault_addr, &on_ledger); + + (client, vault_addr, auth_caller, usdc_admin) +} + +// --------------------------------------------------------------------------- +// Proptest: simulate_deduct and try_deduct return the same error code +// --------------------------------------------------------------------------- + +/// Helper to extract the `VaultError` discriminant from a `try_*` result. +/// +/// Returns `Some(code)` when the call returned a `VaultError`, or `None` when +/// the call succeeded. +fn err_code_from( + result: Result, Result>, +) -> Option { + match result { + Err(Ok(e)) => Some(e.get_code()), + Ok(Err(e)) => Some(e.get_code()), + Ok(Ok(_)) => None, + Err(Err(_)) => None, + } +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + /// For any combination of vault state and deduct inputs, `simulate_deduct` + /// and `try_deduct` must return the same error code (or both succeed). + /// + /// The proptest explores: + /// - `amount` in `[-10, 200]` to cover negative, zero, below-min, in-range, above-max, and above-balance + /// - `use_auth_caller: bool` to cover authorized vs unauthorized + /// - `paused: bool` to cover the circuit-breaker + /// - `balance` in `[0, 150]` + #[test] + fn simulate_deduct_matches_deduct_error_code( + amount in -10i128..=200i128, + use_auth_caller in proptest::bool::ANY, + paused in proptest::bool::ANY, + balance in 0i128..=150i128, + ) { + let env = Env::default(); + // max_deduct = 100; min_deposit = 1; on_ledger ≥ balance so real deducts don't + // fail due to token balance (validation errors fire first). + let (client, _vault, auth_caller, usdc_admin) = + setup_simulate_vault(&env, balance, balance.max(200), 100); + + env.mock_all_auths(); + + if paused { + // Owner == auth_caller is not true here; pause via direct storage write + // to avoid the owner/auth_caller dependency. + env.as_contract(&_vault, || { + env.storage().instance().set(&DataKey::Paused, &true); + }); + } + + let caller = if use_auth_caller { + auth_caller.clone() + } else { + Address::generate(&env) + }; + + let request_id = 42u64; + + let sim_result = client.try_simulate_deduct(&caller, &amount, &request_id); + let deduct_result = client.try_deduct(&caller, &amount, &request_id); + + let sim_code = err_code_from(sim_result); + let deduct_code = err_code_from(deduct_result); + + prop_assert_eq!( + sim_code, deduct_code, + "simulate_deduct and deduct returned different outcomes for \ + amount={amount} use_auth={use_auth_caller} paused={paused} balance={balance}" + ); + } +} + +// --------------------------------------------------------------------------- +// Explicit parity tests +// --------------------------------------------------------------------------- + +/// simulate_deduct and deduct both return BelowMinDeposit for an amount below min. +#[test] +fn simulate_deduct_below_min_matches_deduct() { + let env = Env::default(); + let (client, _vault, auth_caller, _usdc_admin) = + setup_simulate_vault(&env, 1_000, 1_000, 500); + env.mock_all_auths(); + + // min_deposit = 1; pass amount = 0 to trigger AmountNotPositive, + // and amount = -5 to be safe. Use amount that is valid positive but < min + // by injecting a min via a fresh vault with min_deposit=10. + let owner2 = Address::generate(&env); + let auth2 = Address::generate(&env); + let (_vault2_addr, client2) = { + let address = env.register(CalloraVault, ()); + let c = CalloraVaultClient::new(&env, &address); + let (usdc2, _, usdc2_admin) = create_usdc(&env, &owner2); + let settlement2 = create_settlement(&env, &owner2, &address); + c.init( + &owner2, + &usdc2, + &Some(1_000i128), + &Some(auth2.clone()), + &Some(10i128), // min_deposit = 10 + &None::
, + &Some(1_000i128), + &Some(settlement2), + ); + usdc2_admin.mint(&address, &1_000i128); + (address, c) + }; + + // amount=5 is positive but below min_deposit=10 → BelowMinDeposit + let sim = client2.try_simulate_deduct(&auth2, &5i128, &1u64); + let ded = client2.try_deduct(&auth2, &5i128, &1u64); + assert_eq!(err_code_from(sim), err_code_from(ded)); + assert_eq!( + err_code_from(client2.try_simulate_deduct(&auth2, &5i128, &1u64)), + Some(VaultError::BelowMinDeposit as u32) + ); +} + +/// simulate_deduct and deduct both return Unauthorized for a non-auth caller. +#[test] +fn simulate_deduct_unauthorized_matches_deduct() { + let env = Env::default(); + let (client, _vault, _auth_caller, _) = setup_simulate_vault(&env, 1_000, 1_000, 500); + env.mock_all_auths(); + + let stranger = Address::generate(&env); + + let sim = client.try_simulate_deduct(&stranger, &100i128, &1u64); + let ded = client.try_deduct(&stranger, &100i128, &1u64); + assert_eq!(err_code_from(sim), err_code_from(ded)); + assert_eq!( + err_code_from(client.try_simulate_deduct(&stranger, &100i128, &1u64)), + Some(VaultError::Unauthorized as u32) + ); +} + +/// simulate_deduct and deduct both return Paused when the vault is paused. +#[test] +fn simulate_deduct_paused_matches_deduct() { + let env = Env::default(); + let (client, vault, auth_caller, _) = setup_simulate_vault(&env, 1_000, 1_000, 500); + env.mock_all_auths(); + + // Write Paused=true directly so we don't need the owner address. + env.as_contract(&vault, || { + env.storage().instance().set(&DataKey::Paused, &true); + }); + + let sim = client.try_simulate_deduct(&auth_caller, &100i128, &1u64); + let ded = client.try_deduct(&auth_caller, &100i128, &1u64); + assert_eq!(err_code_from(sim), err_code_from(ded)); + assert_eq!( + err_code_from(client.try_simulate_deduct(&auth_caller, &100i128, &1u64)), + Some(VaultError::Paused as u32) + ); +} + +/// simulate_deduct and deduct both return InsufficientBalance when balance < amount. +#[test] +fn simulate_deduct_insufficient_balance_matches_deduct() { + let env = Env::default(); + // tracked = 50, on_ledger = 50, max_deduct = 500 + let (client, _vault, auth_caller, _) = setup_simulate_vault(&env, 50, 50, 500); + env.mock_all_auths(); + + // amount=100 > balance=50 + let sim = client.try_simulate_deduct(&auth_caller, &100i128, &1u64); + let ded = client.try_deduct(&auth_caller, &100i128, &1u64); + assert_eq!(err_code_from(sim), err_code_from(ded)); + assert_eq!( + err_code_from(client.try_simulate_deduct(&auth_caller, &100i128, &1u64)), + Some(VaultError::InsufficientBalance as u32) + ); +} + +/// simulate_deduct returns Ok (projected balance) when deduct would also succeed. +#[test] +fn simulate_deduct_success_matches_deduct_projected_balance() { + let env = Env::default(); + let (client, _vault, auth_caller, _) = setup_simulate_vault(&env, 1_000, 1_000, 500); + env.mock_all_auths(); + + // simulate returns the projected new balance + let sim = client.try_simulate_deduct(&auth_caller, &200i128, &1u64); + assert_eq!(sim, Ok(Ok(800i128))); + + // real deduct succeeds and the actual balance matches + assert!(client.try_deduct(&auth_caller, &200i128, &1u64).is_ok()); + assert_eq!(client.balance(), 800i128); +} diff --git a/contracts/vault/src/views.rs b/contracts/vault/src/views.rs index 8bda20d0..fe0ba028 100644 --- a/contracts/vault/src/views.rs +++ b/contracts/vault/src/views.rs @@ -1,21 +1,31 @@ //! # Read-only views for the Callora Vault contract. //! //! This module hosts the [`CalloraVault::simulate_deduct`] pre-flight view, -//! which mirrors [`crate::CalloraVault::deduct`]'s validation pipeline -//! end-to-end without performing any state mutation. Clients use it to -//! predict the outcome of a real `deduct` call before signing and submitting -//! a transaction. +//! which shares [`crate::CalloraVault::validate_deduct`] with +//! [`crate::CalloraVault::deduct`] to guarantee that their validation +//! pipelines are identical. +//! +//! ## Parameters +//! `simulate_deduct(env, caller, amount, request_id)` — mirrors the public +//! signature of `deduct` exactly: +//! - `env` — the contract environment. +//! - `caller` — the address that would call `deduct`; checked against the +//! authorized-caller role (same check `deduct` performs, but without +//! `require_auth`). +//! - `amount` — deduct amount in USDC stroops. +//! - `request_id` — `u64` idempotency key, same type as `deduct`. //! //! ## Guarantees //! - **Read-only.** `simulate_deduct` does not write to instance, persistent, //! or temporary storage; does not transfer tokens; does not call into the //! settlement contract; and does not emit events. //! - **Auth-free.** It does not call `require_auth`. The `caller` parameter -//! is accepted for parity with `deduct` but is not authenticated. -//! - **Parity.** For any given vault state and inputs, the return value is -//! exactly what a real `deduct` call with the same arguments would return -//! for the validation-stage checks (pause, amount, max-deduct, -//! idempotency, rate-limit, balance, slippage). +//! is checked against the authorized-caller role, but no on-chain +//! authorization signature is required. +//! - **Parity.** `simulate_deduct` calls `validate_deduct`, which is the same +//! function `deduct` calls. For any given vault state and inputs, the error +//! returned by `simulate_deduct` is exactly the error `deduct` would return +//! at the matching validation step. //! //! The simulation stops at the validation stage and does not reach the //! external call or settlement-credit step — so a vault with no @@ -23,128 +33,65 @@ //! Production callers should still call //! [`crate::CalloraVault::get_settlement`] before submitting a real `deduct`. -use soroban_sdk::{contractimpl, Address, Env, Symbol}; +use soroban_sdk::{contractimpl, Address, Env}; use crate::errors::VaultError; use crate::{CalloraVault, CalloraVaultClient, CalloraVaultArgs}; /// Read-only pre-flight of [`crate::CalloraVault::deduct`]. /// -/// Performs every validation step that `deduct` performs, in the same order, -/// except authorization, external token transfers, the settlement callback, -/// idempotency-marker writes, rate-limit state writes, balance mutations, and -/// event emission. +/// Runs [`crate::CalloraVault::validate_deduct`] — the same shared validation +/// function that `deduct` calls — without performing any state mutation. +/// Clients use this to predict whether a real `deduct` call would succeed +/// before signing and submitting a transaction. /// /// # Parameters -/// Identical to [`crate::CalloraVault::deduct`] so that callers can swap -/// `simulate_deduct` for `deduct` and keep the rest of their code unchanged. -/// -/// - `_caller`: accepted for parity. **Not authenticated.** The simulation -/// does not raise `Unauthorized` for an unknown caller — it reflects what -/// the subsequent authorized `deduct` would do. -/// - `amount`: amount to deduct. Must be positive. -/// - `request_id`: optional idempotency key. If `Some(id)` and the id is -/// already in storage, the simulation returns `DuplicateRequestId`. -/// - `max_fee_bps`: slippage guard. Same semantics as `deduct`. -/// - `developer`: developer whose rate-limit bucket is checked. +/// - `caller` — address that would call `deduct`. Checked against the +/// authorized-caller role. **Not authenticated** (no `require_auth`). +/// - `amount` — amount to deduct in USDC stroops. +/// - `request_id` — `u64` idempotency key; same type as `deduct`. /// /// # Returns -/// - `Ok(new_balance)` — the projected vault balance after the deduct would -/// succeed. This matches `deduct`'s success payload (`Result`) -/// one-for-one. -/// - `Err(VaultError)` — the same error variant a subsequent `deduct` with -/// identical arguments would emit at the matching validation step. -/// -/// "Returns same struct as deduct" (issue #511) is interpreted as -/// `Result` shape parity — the projected new balance on -/// success, the matching error variant on failure. +/// - `Ok(projected_balance)` — the vault balance after a successful deduct. +/// - `Err(VaultError)` — the same error variant `deduct` would return at the +/// matching validation step. /// /// # Errors /// Mirrors `deduct`'s validation errors in the same order: /// -/// 1. [`VaultError::Paused`] — vault is paused. -/// 2. [`VaultError::AmountNotPositive`] — `amount <= 0`. -/// 3. [`VaultError::ExceedsMaxDeduct`] — `amount > max_deduct`. -/// 4. [`VaultError::DuplicateRequestId`] — `request_id` already processed. -/// 5. [`VaultError::RateLimited`] — developer's bucket would be exhausted. +/// 1. [`VaultError::Unauthorized`] — `caller` is not the authorized deduct +/// caller. (Auth is not enforced here, but the role check still runs.) +/// 2. [`VaultError::Paused`] — vault is paused. +/// 3. [`VaultError::AmountNotPositive`] — `amount <= 0`. +/// 4. [`VaultError::BelowMinDeposit`] — `amount < min_deposit`. +/// 5. [`VaultError::ExceedsMaxDeduct`] — `amount > max_deduct`. /// 6. [`VaultError::InsufficientBalance`] — vault balance < `amount`. -/// 7. [`VaultError::Slippage`] — `amount` exceeds `max_fee_bps` of the -/// current balance (skipped when `max_fee_bps == u16::MAX` or balance -/// is 0). -/// -/// [`VaultError::Unauthorized`] is **not** raised here. Production callers -/// should separately verify that they (or their backend) hold the -/// owner / authorized-caller role before submitting the real `deduct`. /// -/// [`VaultError::SettlementNotSet`] is **not** raised here — the simulation -/// does not reach the external settlement call. Callers that need this -/// assurance should additionally call +/// [`VaultError::SettlementNotSet`] and [`VaultError::NotInitialized`] are +/// **not** raised here — the simulation does not reach the external settlement +/// call. Callers that need this assurance should additionally call /// [`crate::CalloraVault::get_settlement`] before submitting. #[contractimpl] impl CalloraVault { /// Read-only pre-flight of `deduct`. See the [`crate::views`] module docs. - #[allow(clippy::too_many_arguments)] pub fn simulate_deduct( env: Env, - _caller: Address, + caller: Address, amount: i128, - request_id: Option, - max_fee_bps: u32, - developer: Address, + request_id: u64, ) -> Result { - // 1. Pause guard (read-only via `Self::is_paused`). - if CalloraVault::is_paused(env.clone()) { - return Err(VaultError::Paused); - } - - // 2. Amount must be positive. - if amount <= 0 { - return Err(VaultError::AmountNotPositive); - } - - // 3. Configured `max_deduct` ceiling. - let max_d = CalloraVault::get_max_deduct(env.clone()); - if amount > max_d { - return Err(VaultError::ExceedsMaxDeduct); - } - - // 4. Idempotency duplicate check. Delegated to the same private - // helper `deduct` uses, so the simulator can never silently - // diverge if storage semantics evolve. - if let Some(ref rid) = request_id { - CalloraVault::require_not_duplicate(&env, rid)?; - } + CalloraVault::validate_deduct(&env, &caller, amount)?; - // 5. Rate-limit dry-run (reads bucket state; does not write). - crate::rate_limit::would_consume_tokens(&env, &developer, amount)?; - - // 6. Balance check. + // Projected new balance — same shape as `deduct`'s `Ok(..)` payload. let balance: i128 = env .storage() .instance() .get(&crate::DataKey::Balance) .unwrap_or(0); - if balance < amount { - return Err(VaultError::InsufficientBalance); - } - - // 7. Slippage guard. Mirrors `deduct`'s math exactly: skip when - // `max_fee_bps == u16::MAX` (sentinel = no limit) or when the - // balance is zero (division-by-zero guard). - if max_fee_bps < u32::MAX && balance > 0 { - let calculated_fee_bps = amount - .checked_mul(10_000) - .ok_or(VaultError::Overflow)? - / balance; - if calculated_fee_bps > max_fee_bps as i128 { - return Err(VaultError::Slippage); - } - } - - // Projected new balance — same shape as `deduct`'s `Ok(..)` payload. let new_balance = balance .checked_sub(amount) .ok_or(VaultError::Overflow)?; Ok(new_balance) } } + From 4293ab81d1bee9683ba58959efe2b471721e568c Mon Sep 17 00:00:00 2001 From: greatest0fallt1me <192479186+greatest0fallt1me@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:59:26 +0530 Subject: [PATCH 2/2] fix: repair main damaged by earlier sabotaged merges - vault/src/limits.rs: restore pre-#1274 source (was replaced by corrupted base64) - whitelist/src/admin.rs: restore pre-#1266 source (corrupted base64, Ok(), assert_eq() - helpers/src/snapshot_diff.rs: restore module deleted by #1317 (still declared in lib.rs) - yield: revert #1268's sabotaged changes (unparsable limits.rs, #cfn/mmod, LEFGERS typo) - escrow/hot events.rs: restore #[cfg(test)], inner docs, assert_eq! (#1324 corruption) - escrow rotate_signer: use existing InvalidInput (SameSigner was undefined); hot: add SameSigner=10 - escrow/hot tests: add missing Events/TryIntoVal imports; compare topic[0] in escrow test - revenue_pool: drop mod test_yield_overflow (file never added by #1292) Co-Authored-By: Claude Opus 5.5 --- contracts/escrow/src/events.rs | 31 +- contracts/escrow/src/lib.rs | 4 +- contracts/escrow/src/test.rs | 7 +- contracts/helpers/src/snapshot_diff.rs | 292 +++++++++ contracts/hot/src/errors.rs | 3 + contracts/hot/src/events.rs | 24 +- contracts/hot/src/test.rs | 2 +- contracts/revenue_pool/src/lib.rs | 3 - contracts/vault/src/limits.rs | 87 ++- contracts/whitelist/src/admin.rs | 202 +++++- contracts/yield/YIELD_LIMITS.md | 38 +- contracts/yield/src/errors.rs | 10 - contracts/yield/src/events.rs | 16 - contracts/yield/src/lib.rs | 58 +- contracts/yield/src/limits.rs | 833 ++++++++++--------------- contracts/yield/src/test_limits.rs | 22 +- 16 files changed, 987 insertions(+), 645 deletions(-) create mode 100644 contracts/helpers/src/snapshot_diff.rs diff --git a/contracts/escrow/src/events.rs b/contracts/escrow/src/events.rs index 7db7e3b5..eb2e6ef2 100644 --- a/contracts/escrow/src/events.rs +++ b/contracts/escrow/src/events.rs @@ -1,8 +1,8 @@ //! Event topic Symbol constructors for the Callora Escrow contract. -/// -/// This module centralises all event topic strings into dedicated functions, -/// ensuring byte-identity is preserved and preventing accidental topic name -/// drift across call sites. +//! +//! This module centralises all event topic strings into dedicated functions, +//! ensuring byte-identity is preserved and preventing accidental topic name +//! drift across call sites. use soroban_sdk::{Env, Symbol}; @@ -95,43 +95,44 @@ pub fn event_version_v1(env: &Env) -> Symbol { Symbol::new(env, "callora.v1") } -#config(test)]]mod tests { +#[cfg(test)] +mod tests { use super::*; - use soroban_sdk:Env; + use soroban_sdk::Env; /// Snapshot: proves event_init still 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")); + assert_eq!(event_init(&env), Symbol::new(&env, "init")); } /// Snapshot: proves event_cooldown_set still maps to exactly the bytes for "cooldown_set". #[test] fn test_event_cooldown_set_bytes() { let env = Env::default(); - assert_eq(event_cooldown_set(&env), Symbol::new(&env, "cooldown_set")); + assert_eq!(event_cooldown_set(&env), Symbol::new(&env, "cooldown_set")); } /// Snapshot: proves event_action still maps to exactly the bytes for "action". #[test] fn test_event_action_bytes() { let env = Env::default(); - assert_eq(event_action(&env), Symbol::new(&env, "action")); + assert_eq!(event_action(&env), Symbol::new(&env, "action")); } /// Snapshot: proves event_signer_rotated maps to exactly the bytes for "signer_rotated". #[test] fn test_event_signer_rotated_bytes() { let env = Env::default(); - assert_eq(event_signer_rotated(&env), Symbol::new(&env, "signer_rotated")); + assert_eq!(event_signer_rotated(&env), Symbol::new(&env, "signer_rotated")); } /// Snapshot: proves event_admin_nominated still maps to exactly the bytes for "admin_nominated". #[test] fn test_event_admin_nominated_bytes() { let env = Env::default(); - assert_eq( + assert_eq!( event_admin_nominated(&env), Symbol::new(&env, "admin_nominated") ); @@ -141,7 +142,7 @@ pub fn event_version_v1(env: &Env) -> Symbol { #[test] fn test_event_admin_accepted_bytes() { let env = Env::default(); - assert_eq( + assert_eq!( event_admin_accepted(&env), Symbol::new(&env, "admin_accepted") ); @@ -151,7 +152,7 @@ pub fn event_version_v1(env: &Env) -> Symbol { #[test] fn test_event_asset_approved_bytes() { let env = Env::default(); - assert_eq( + assert_eq!( event_asset_approved(&env), Symbol::new(&env, "asset_approved") ); @@ -161,7 +162,7 @@ pub fn event_version_v1(env: &Env) -> Symbol { #[test] fn test_event_asset_removed_bytes() { let env = Env::default(); - assert_eq( + assert_eq!( event_asset_removed(&env), Symbol::new(&env, "asset_removed") ); @@ -171,7 +172,7 @@ pub fn event_version_v1(env: &Env) -> Symbol { #[test] fn test_event_escrow_created_bytes() { let env = Env::default(); - assert_eq( + assert_eq!( event_escrow_created(&env), Symbol::new(&env, "escrow_created") ); diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index fc79aaaf..0b4555f1 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -441,7 +441,7 @@ impl CalloraEscrow { /// # Errors /// * [`EscrowError::Unauthorized`] -- caller is not the current admin. /// * [`EscrowError::NotInitialized`] -- contract not initialized. - /// * [`EscrowError::SameSigner`] -- `new_signer` equals the current signer. + /// * [`EscrowError::InvalidInput`] -- `new_signer` equals the current signer. /// * [`EscrowError::CooldownActive`] -- a `rotate` ran within the cool-off window. /// /// # Events @@ -463,7 +463,7 @@ impl CalloraEscrow { .get(&StorageKey::Signer) .ok_or(EscrowError::NotInitialized)?; if old_signer == new_signer { - return Err(EscrowError::SameSigner); + return Err(EscrowError::InvalidInput); } env.storage() diff --git a/contracts/escrow/src/test.rs b/contracts/escrow/src/test.rs index a24db330..12159fbd 100644 --- a/contracts/escrow/src/test.rs +++ b/contracts/escrow/src/test.rs @@ -9,8 +9,8 @@ use crate::{ CalloraEscrow, CalloraEscrowClient, EscrowError, ACTION_RELEASE, ACTION_ROTATE, ACTION_UNPAUSE, }; -use soroban_sdk::testutils::{Address as _, Ledger as _}; -use soroban_sdk::{Address, Env, Symbol}; +use soroban_sdk::testutils::{Address as _, Events as _, Ledger as _}; +use soroban_sdk::{Address, Env, Symbol, TryIntoVal}; /// Helper: read the current instance storage entry count for the contract. fn instance_entry_count(env: &Env, contract_id: &Address) -> u32 { @@ -451,7 +451,8 @@ fn test_rotate_signer_emits_old_and_new_signer() { let events = env.events().all(); let (_, topics, data) = events.last().unwrap(); - assert_eq!(topics, (Symbol::new(&env, "signer_rotated"),).into()); + let topic: Symbol = topics.get(0).unwrap().try_into_val(&env).unwrap(); + assert_eq!(topic, Symbol::new(&env, "signer_rotated")); let payload: (Address, Address) = data.try_into_val(&env).unwrap(); assert_eq!(payload, (old_signer, new_signer)); } diff --git a/contracts/helpers/src/snapshot_diff.rs b/contracts/helpers/src/snapshot_diff.rs new file mode 100644 index 00000000..f339418b --- /dev/null +++ b/contracts/helpers/src/snapshot_diff.rs @@ -0,0 +1,292 @@ +use alloc::collections::BTreeMap; +use alloc::vec::Vec; + +/// Represents a single change identified during storage snapshot comparison. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Change { + /// An entry was added to the storage snapshot. + Added { key: K, value: V }, + /// An existing entry had its value modified. + Modified { key: K, old_value: V, new_value: V }, + /// An entry was removed from the storage snapshot. + Removed { key: K, value: V }, +} + +impl Change { + /// Return a reference to the key associated with this change. + pub fn key(&self) -> &K { + match self { + Change::Added { key, .. } => key, + Change::Modified { key, .. } => key, + Change::Removed { key, .. } => key, + } + } +} + +/// Diff two storage snapshots represented as lists of key-value pairs. +/// +/// This helper performs an efficient comparison between `before` and `after` snapshots. +/// It identifies entries that have been added, removed, or modified, and excludes +/// entries that are identical. +/// +/// # Parameters +/// - `before`: The slice of key-value pairs representing the state of storage before. +/// - `after`: The slice of key-value pairs representing the state of storage after. +/// +/// # Ordering Guarantees +/// The resulting change list is guaranteed to be sorted in a stable, deterministic order based +/// on the `Ord` implementation of the key. This ensures consistent diff reports regardless of +/// the input ordering of elements. +/// +/// # Efficiency +/// The snapshots are loaded into `BTreeMap` structures in $O(N \log N + M \log M)$ time, +/// and then compared in a single linear $O(N + M)$ pass. The final list of changes is +/// sorted in $O(C \log C)$ where $C$ is the number of changes. +pub fn diff_snapshots(before: &[(K, V)], after: &[(K, V)]) -> Vec> +where + K: Ord + Clone, + V: PartialEq + Clone, +{ + let mut before_map = BTreeMap::new(); + for (k, v) in before { + before_map.insert(k.clone(), v.clone()); + } + + let mut after_map = BTreeMap::new(); + for (k, v) in after { + after_map.insert(k.clone(), v.clone()); + } + + let mut changes = Vec::new(); + let mut before_iter = before_map.iter(); + let mut after_iter = after_map.iter(); + + let mut current_before = before_iter.next(); + let mut current_after = after_iter.next(); + + while let (Some((bk, bv)), Some((ak, av))) = (current_before, current_after) { + if bk < ak { + changes.push(Change::Removed { + key: bk.clone(), + value: bv.clone(), + }); + current_before = before_iter.next(); + } else if bk > ak { + changes.push(Change::Added { + key: ak.clone(), + value: av.clone(), + }); + current_after = after_iter.next(); + } else { + if bv != av { + changes.push(Change::Modified { + key: bk.clone(), + old_value: bv.clone(), + new_value: av.clone(), + }); + } + current_before = before_iter.next(); + current_after = after_iter.next(); + } + } + + while let Some((bk, bv)) = current_before { + changes.push(Change::Removed { + key: bk.clone(), + value: bv.clone(), + }); + current_before = before_iter.next(); + } + + while let Some((ak, av)) = current_after { + changes.push(Change::Added { + key: ak.clone(), + value: av.clone(), + }); + current_after = after_iter.next(); + } + + // Sort to guarantee stable, deterministic ordering. + changes.sort_by(|a, b| a.key().cmp(b.key())); + changes +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::string::String; + use alloc::string::ToString; + use alloc::vec; + + #[test] + fn test_identical_snapshots() { + let before = vec![("key1".to_string(), "val1".to_string())]; + let after = vec![("key1".to_string(), "val1".to_string())]; + let diff = diff_snapshots(&before, &after); + assert!(diff.is_empty()); + } + + #[test] + fn test_added_keys() { + let before = vec![]; + let after = vec![("key1".to_string(), "val1".to_string())]; + let diff = diff_snapshots(&before, &after); + assert_eq!( + diff, + vec![Change::Added { + key: "key1".to_string(), + value: "val1".to_string(), + }] + ); + } + + #[test] + fn test_removed_keys() { + let before = vec![("key1".to_string(), "val1".to_string())]; + let after = vec![]; + let diff = diff_snapshots(&before, &after); + assert_eq!( + diff, + vec![Change::Removed { + key: "key1".to_string(), + value: "val1".to_string(), + }] + ); + } + + #[test] + fn test_modified_values() { + let before = vec![("key1".to_string(), "val1".to_string())]; + let after = vec![("key1".to_string(), "val2".to_string())]; + let diff = diff_snapshots(&before, &after); + assert_eq!( + diff, + vec![Change::Modified { + key: "key1".to_string(), + old_value: "val1".to_string(), + new_value: "val2".to_string(), + }] + ); + } + + #[test] + fn test_multiple_changes() { + let before = vec![ + ("key1".to_string(), "val1".to_string()), + ("key2".to_string(), "val2".to_string()), + ]; + let after = vec![ + ("key2".to_string(), "val2_mod".to_string()), + ("key3".to_string(), "val3".to_string()), + ]; + let diff = diff_snapshots(&before, &after); + assert_eq!( + diff, + vec![ + Change::Removed { + key: "key1".to_string(), + value: "val1".to_string(), + }, + Change::Modified { + key: "key2".to_string(), + old_value: "val2".to_string(), + new_value: "val2_mod".to_string(), + }, + Change::Added { + key: "key3".to_string(), + value: "val3".to_string(), + }, + ] + ); + } + + #[test] + fn test_empty_snapshots() { + let before: Vec<(String, String)> = vec![]; + let after: Vec<(String, String)> = vec![]; + let diff = diff_snapshots(&before, &after); + assert!(diff.is_empty()); + } + + #[test] + fn test_deterministic_ordering() { + // Different input order should yield identical output order + let before1 = vec![ + ("key2".to_string(), "val2".to_string()), + ("key1".to_string(), "val1".to_string()), + ]; + let after1 = vec![ + ("key3".to_string(), "val3".to_string()), + ("key1".to_string(), "val1_mod".to_string()), + ]; + + let before2 = vec![ + ("key1".to_string(), "val1".to_string()), + ("key2".to_string(), "val2".to_string()), + ]; + let after2 = vec![ + ("key1".to_string(), "val1_mod".to_string()), + ("key3".to_string(), "val3".to_string()), + ]; + + let diff1 = diff_snapshots(&before1, &after1); + let diff2 = diff_snapshots(&before2, &after2); + + assert_eq!(diff1, diff2); + assert_eq!( + diff1, + vec![ + Change::Modified { + key: "key1".to_string(), + old_value: "val1".to_string(), + new_value: "val1_mod".to_string(), + }, + Change::Removed { + key: "key2".to_string(), + value: "val2".to_string(), + }, + Change::Added { + key: "key3".to_string(), + value: "val3".to_string(), + }, + ] + ); + } + + #[test] + fn test_realistic_fixtures() { + // Simulated contract storage keys (represented as serialized hex strings/symbols) + let before = vec![ + ("admin".to_string(), "GBBD47...".to_string()), + ("balance".to_string(), "1000".to_string()), + ("paused".to_string(), "false".to_string()), + ]; + let after = vec![ + ("admin".to_string(), "GBBD47...".to_string()), + ("balance".to_string(), "1500".to_string()), // modified + ("paused".to_string(), "true".to_string()), // modified + ("pending_admin".to_string(), "GCCCCC...".to_string()), // added + ]; + + let diff = diff_snapshots(&before, &after); + assert_eq!( + diff, + vec![ + Change::Modified { + key: "balance".to_string(), + old_value: "1000".to_string(), + new_value: "1500".to_string(), + }, + Change::Modified { + key: "paused".to_string(), + old_value: "false".to_string(), + new_value: "true".to_string(), + }, + Change::Added { + key: "pending_admin".to_string(), + value: "GCCCCC...".to_string(), + }, + ] + ); + } +} diff --git a/contracts/hot/src/errors.rs b/contracts/hot/src/errors.rs index d2a34378..42b11933 100644 --- a/contracts/hot/src/errors.rs +++ b/contracts/hot/src/errors.rs @@ -17,6 +17,7 @@ use soroban_sdk::contracterror; /// | 7 | Overflow | Arithmetic overflow detected | /// | 8 | AlreadyPaused | Contract is already paused; `pause` is a no-op | /// | 9 | NotPaused | Contract is not paused; `unpause` is a no-op | +/// | 10 | SameSigner | `rotate_signer` called with the current signer | #[contracterror] #[derive(Clone, Copy, Debug, PartialEq)] #[repr(u32)] @@ -39,4 +40,6 @@ pub enum HotError { AlreadyPaused = 8, /// Contract is not currently paused; `unpause` is redundant (code 9). NotPaused = 9, + /// `rotate_signer` was called with the current signer (code 10). + SameSigner = 10, } diff --git a/contracts/hot/src/events.rs b/contracts/hot/src/events.rs index 6b62ff11..4bc264fe 100644 --- a/contracts/hot/src/events.rs +++ b/contracts/hot/src/events.rs @@ -1,8 +1,8 @@ //! Event topic Symbol constructors for the Callora Hot contract. -/// -/// This module centralizes all event topic strings into dedicated functions, -/// ensuring byte-identity is preserved and preventing accidental topic name -/// drift across call sites. +//! +//! This module centralizes all event topic strings into dedicated functions, +//! ensuring byte-identity is preserved and preventing accidental topic name +//! drift across call sites. use soroban_sdk::{Env, Symbol}; @@ -96,28 +96,28 @@ mod tests { #[test] fn test_event_init_bytes() { let env = Env::default(); - assert_eq(event_init(&env), Symbol::new(&env, "init")); + assert_eq!(event_init(&env), Symbol::new(&env, "init")); } /// Snapshot: proves event_cooldown_set still maps to exactly the bytes for "cooldown_set". #[test] fn test_event_cooldown_set_bytes() { let env = Env::default(); - assert_eq(event_cooldown_set(&env), Symbol::new(&env, "cooldown_set")); + assert_eq!(event_cooldown_set(&env), Symbol::new(&env, "cooldown_set")); } /// Snapshot: proves event_action still maps to exactly the bytes for "action". #[test] fn test_event_action_bytes() { let env = Env::default(); - assert_eq(event_action(&env), Symbol::new(&env, "action")); + assert_eq!(event_action(&env), Symbol::new(&env, "action")); } /// Snapshot: proves event_signer_rotated still maps to exactly the bytes for "signer_rotated". #[test] fn test_event_signer_rotated_bytes() { let env = Env::default(); - assert_eq( + assert_eq!( event_signer_rotated(&env), Symbol::new(&env, "signer_rotated") ); @@ -127,7 +127,7 @@ mod tests { #[test] fn test_event_admin_nominated_bytes() { let env = Env::default(); - assert_eq( + assert_eq!( event_admin_nominated(&env), Symbol::new(&env, "admin_nominated") ); @@ -137,7 +137,7 @@ mod tests { #[test] fn test_event_admin_accepted_bytes() { let env = Env::default(); - assert_eq( + assert_eq!( event_admin_accepted(&env), Symbol::new(&env, "admin_accepted") ); @@ -147,13 +147,13 @@ mod tests { #[test] fn test_event_paused_bytes() { let env = Env::default(); - assert_eq(event_paused(&env), Symbol::new(&env, "paused")); + assert_eq!(event_paused(&env), Symbol::new(&env, "paused")); } /// Snapshot: proves event_unpaused still maps to exactly the bytes for "unpaused". #[test] fn test_event_unpaused_bytes() { let env = Env::default(); - assert_eq(event_unpaused(&env), Symbol::new(&env, "unpaused")); + assert_eq!(event_unpaused(&env), Symbol::new(&env, "unpaused")); } } diff --git a/contracts/hot/src/test.rs b/contracts/hot/src/test.rs index ba5761e9..930f3cd3 100644 --- a/contracts/hot/src/test.rs +++ b/contracts/hot/src/test.rs @@ -8,7 +8,7 @@ use crate::admin::{DEFAULT_COOLDOWN_SECS, MAX_COOLDOWN_SECS, MIN_COOLDOWN_SECS}; use crate::{CalloraHot, CalloraHotClient, HotError, ACTION_ROTATE}; use soroban_sdk::testutils::Events as _; use soroban_sdk::testutils::{Address as _, Ledger as _}; -use soroban_sdk::{Address, Env, Symbol}; +use soroban_sdk::{Address, Env, Symbol, TryIntoVal}; /// Helper: register a fresh hot contract initialized with `cooldown_secs` and /// return `(env, admin, signer, client)`. Auth is mocked for convenience. diff --git a/contracts/revenue_pool/src/lib.rs b/contracts/revenue_pool/src/lib.rs index 921e2e26..a59e9eb6 100644 --- a/contracts/revenue_pool/src/lib.rs +++ b/contracts/revenue_pool/src/lib.rs @@ -1227,9 +1227,6 @@ mod test_storage_migration; #[cfg(test)] extern crate std; -#[cfg(test)] -mod test_yield_overflow; - #[cfg(test)] mod rustdoc_tests { #[test] diff --git a/contracts/vault/src/limits.rs b/contracts/vault/src/limits.rs index 5457154b..109f8a70 100644 --- a/contracts/vault/src/limits.rs +++ b/contracts/vault/src/limits.rs @@ -1 +1,86 @@ -Ly8hIFBlci10b2tlbiByZXNlcnZlIGNhcHMgZm9yIHRoZSBDYWxsb3JhIFZhdWx0LgovLy8KLy8vIEEgcmVzZXJ2ZSBjYXAgc2V0cyB0aGUgbWF4aW11bSB0b3RhbCBiYWxhbmNlIHRoZSB2YXVsdCBtYXkgaG9sZCBmb3IgYSBnaXZlbiB0b2tlbi4gIERlcG9zaXQgYXR0ZW1wdHMgdGhhdCB3b3VsZCBwdXNoIHRoZSBiYWxhbmNlIHBhc3QgdGhlIGNhcCBhcmUgcmVqZWN0ZWQgd2l0aCBbYFZhdWx0RXJyb3I6OkV4Y2VlZHNSZXNlcnZlQ2FwYF0uCi8vLwovLy8gIyBTdG9yYWdlCi8vLyBDYXBzIGFyZSBzdG9yZWQgdW5kZXIgW2BTdG9yYWdlS2V5OjpSZXNlcnZlQ2FwYF1gKHRva2VuKWAgaW4gKippbnN0YW5jZSoqCi8vLyBzdG9yYWdlIHNvIHRoZXkgc2hhcmUgdGhlIHNhbWUgVFRMIGV4dGVuc2lvbiBhcyBvdGhlciB2YXVsdCBjb25maWd1cmF0aW9uLgovLy8KLy8vICMgRGVmYXVsdAovLy8gV2hlbiBubyBjYXAgaGFzIGJlZW4gc2V0IGZvciBhIHRva2VuLCBbYGdldGBdIHJldHVybnMgYGlpMjg6Ok1BWGAspIHdoaWNoCi8vLyBpcyBlZmZlY3RpdmVseSB1bmxpbWl0ZWQgYW5kIGtlZXBzIFtgY2hlY2tgXSBvbiB0aGUgZmFzdCBwYXRoLgovLy8KLy8vICMgQ29tcGFyaXNvbiBiYXNpcwovLy8gVGhlIGNhcCBpcyBjb21wYXJlZCBhZ2FpbnN0IHRoZSB2YXVsdCdzICoqdHJhY2tlZCoqIGJhbGFuY2UgKHRoZQovLy8gdmFsdWUgdGhlIHZhdWx0IGFjY291bnRzIGZvciBpbnRlcm5hbGx5KSwgbm90IHRoZSBvbi1sZWRnZXIgdG9rZW4KLy8vIGJhbGFuY2Ugb2YgdGhlIHZhdWx0IGNvbnRyYWN0LiBUaGlzIGtlZXBzIHRoZSBjaGVjayBkZXRlcm1pbmlzdGljIGFuZAovLy8gaW5kZXBlbmRlbnQgb2YgYW55IGV4dGVybmFsIHRyYW5zZmVycyBvciBkb25hdGlvbnMgdGhhdCBtaWdodCBpbmZsYXRlCi8vLyB0aGUgb24tbGVkZ2VyIGJhbGFuY2Ugd2l0aG91dCBnb2luZyB0aHJvdWdoIGBkZXBvc2l0YC4gSXQgYWxzbyBtZWFucwovLy8gdGhlIGNhcCBiaW5kcyB0aGUgYWNjb3VudGluZyB0aGUgdmF1bHQgcmVsaWVzIG9uIGZvciB3aXRoZHJhd2FscwovLy8gYW5kIG90aGVyIG9wZXJhdGlvbnMuCgp1c2Ugc29yYmFuX3Nkazo6e0FkZHJlc3MsIEVudn07Cgp1c2UgY3JhdGU6OntTdG9yYWdlS2V5LCBWYXVsdEVycm9yLCBJTlNUQU5DRV9CVU1QX0FNT1VOVCwgSU5TVEFOQ0VfQlVNUF9USFJFU0hPTER9OwoKLy8vIERlZmF1bHQgbWF4aW11bSBkZWR1Y3Rpb24gY2FwIOKAlCBlZmZlY3RpdmVseSB1bmxpbWl0ZWQuCnB1YiBjb25zdCBERUZBVUxUX01BWF9ERURVQ1Q6IGkxMjggPSBpMTI4OjpNQVg7CgovLy8gQ2hlY2sgd2hldGhlciBgYW1vdW50YCBleGNlZWRzIGBtYXhfZGVkdWN0YC4gUmV0dXJucyBgRXJyKEV4Y2VlZHNNYXhEZWR1Y3QpYCBpZiBzby4KcHViIGZuIGNoZWNrX21heF9kZWR1Y3QoYW1vdW50OiBpMTI4LCBtYXhfZGVkdWN0OiBpMTI4KSAtPiBSZXN1bHQ8KCksIFZhdWx0RXJyb3I+IHsKICAgIGlmIGFtb3VudCA+IG1heF9kZWR1Y3QgewogICAgICAgIHJldHVybiBFcnIoVmF1bHRFcnJvcjo6RXhjZWVkc01heERlZHVjdCk7CiAgICB9CiAgICBPaygoKQp9CgovLy8gU3RvcmUgYSBwZXItdG9rZW4gcmVzZXJ2ZSBjYXAgYW5kIHJldHVybiB0aGUgcHJldmlvdXMgdmFsdWUuCi8vLwovLy8gIyBBcmd1bWVudHMKLy8vICogYGVudmAgLSBFeGVjdXRpb24gZW52aXJvbm1lbnQuCi8vLyAqIGB0b2tlbmAgLSBUb2tlbiBjb250cmFjdCBhZGRyZXNzIHRoZSBjYXAgYXBwbGllcyB0by4KLy8vICogYGNhcGAgLSBOZXcgbWF4aW11bSBiYWxhbmNlIGluIHRva2VuIHN0cm9vcHMuCi8vLwovLy8gIyBSZXR1cm5zCi8vLyBUaGUgcHJldmlvdXMgY2FwIChgU29tZWApIG9yIGBOb25lYCBpZiBubyBjYXAgd2FzIHByZXZpb3VzbHkgY29uZmlndXJlZC4KcHViIGZuIHNldChlbnY6ICZFbnYsIHRva2VuOiAmQWRkcmVzcywgY2FwOiBpMTI4KSAtPiBPcHRpb248aTEyOD4gewogICAgbGV0IGtleSA9IFN0b3JhZ2VLZXk6OlJlc2VydmVDYXAodG9rZW4uY2xvbmUoKSk7CiAgICBsZXQgcHJldjogT3B0aW9uPGkxMjg+ID0gZW52LnN0b3JhZ2UoKS5pbnN0YW5jZSgpLmdldCgma2V5KTsKICAgIGVudi5zdG9yYWdlKCkuaW5zdGFuY2UoKS5zZXQoJmtleSwgJmNhcCk7CiAgICBlbnYuc3RvcmFnZSgpCiAgICAgICAgLmluc3RhbmNlKCkKICAgICAgICAuZXh0ZW5kX3R0bChJTlNUQU5DRV9CVU1QX1RIUkVTSE9MRCwgSU5TVEFOQ0VfQlVNUF9BTU9VTlQpOwogICAgcHJldgp9CgovLy8gUmV0dXJuIHRoZSByZXNlcnZlIGNhcCBmb3IgYHRva2VuYC4KLy8vCi8vLyBSZXR1cm5zIGBpMTI4OjpNQVhgIHdoZW4gbm8gY2FwIGhhcyBiZWVuIGNvbmZpZ3VyZWQgKGVmZmVjdGl2ZWx5IHVubGltaXRlZCkuCnB1YiBmbiBnZXQoZW52OiAmRW52LCB0b2tlbjogJkFkZHJlc3MpIC0+IGkxMjggewogICAgZW52LnN0b3JhZ2UoKQogICAgICAgIC5pbnN0YW5jZSgpCiAgICAgICAgLmdldCgmU3RvcmFnZUtleTo6UmVzZXJ2ZUNhcCh0b2tlbi5jbG9uZSgpKSkKICAgICAgICAudW53cmFwX29yKGkxMjg6Ok1BWCkKfQoKLy8vIEd1YXJkIGNhbGxlZCBpbnNpZGUgYGRlcG9zaXQoKWAuCi8vLwovLy8gQ2hlY2tzIHdoZXRoZXIgYGN1cnJlbnRfYmFsYW5jZSArIGRlcG9zaXRfYW1vdW50YCB3b3VsZCBleGNlZWQgdGhlCi8vLyBjb25maWd1cmVkIGNhcCBmb3IgYHRva2VuYCBhbmQgcmV0dXJucyBbYFZhdWx0RXJyb3I6OkV4Y2VlZHNSZXNlcnZlQ2FwYF0KLy8vIGlmIHNvLiAgV2hlbiBubyBjYXAgaXMgc2V0IHRoZSBjaGVjayBpcyBza2lwcGVkIGVudGlyZWx5IChmYXN0IHBhdGgpLgovLy8KLy8vIFRoZSBjb21wYXJpc29uIGlzIG1hZGUgYWdhaW5zdCB0aGUgdmF1bHQncyAqKnRyYWNrZWQqKiBiYWxhbmNlCi8vLyAoYGN1cnJlbnRfYmFsYW5jZWApLCBub3QgdGhlIG9uLWxlZGdlciB0b2tlbiBiYWxhbmNlLiBTZWUgdGhlIG1vZHVsZQovLy8gZG9jdW1lbnRhdGlvbiBmb3IgdGhlIHJhdGlvbmFsZS4KLy8vCi8vLyAjIEVycm9ycwovLy8gLSBbYFZhdWx0RXJyb3I6Ok92ZXJmbG93YF0g4oCUIGlmIHRoZSBhZGRpdGlvbiBvdmVyZmxvd3MgYGkxMjhgLgovLy8gLSBbYFZhdWx0RXJyb3I6OkV4Y2VlZHNSZXNlcnZlQ2FwYF0g4oCUIGlmIHRoZSBwb3N0LWRlcG9zaXQgYmFsYW5jZSB3b3VsZAovLy8gICBleGNlZWQgdGhlIGNhcC4KcHViIGZuIGNoZWNrKAogICAgZW52OiAmRW52LAogICAgdG9rZW46ICZBZGRyZXNzLAogICAgY3VycmVudF9iYWxhbmNlOiBpMTI4LAogICAgZGVwb3NpdF9hbW91bnQ6IGkxMjgsCikgLT4gUmVzdWx0PCgpLCBWYXVsdEVycm9yPiB7CiAgICBsZXQgY2FwID0gZ2V0KGVudiwgdG9rZW4pOwogICAgaWYgY2FwID09IGkxMjg6Ok1BWCБ7CiAgICAgICAgcmV0dXJuIE9rKCk7CiAgICB9CiAgICBsZXQgcG9zdF9iYWxhbmNlID0gY3VycmVudF9iYWxhbmNlCiAgICAgICAgLmNoZWNrZWRfYWRkKGRlcG9zaXRfYW1vdW50KQogICAgICAgIC5va19vcihWYXVsdEVycm9yOjpPdmVyZmxvdyk/OwogICAgaWYgcG9zdF9iYWxhbmNlID4gY2FwIHsKICAgICAgICByZXR1cm4gRXJyKFZhdWx0RXJyb3I6OkV4Y2VlZHNSZXNlcnZlQ2FwKTsKICAgIH0KICAgIE9rKCgpKQp9Cg== \ No newline at end of file +//! Per-token reserve caps for the Callora Vault. +//! +//! A reserve cap sets the maximum total balance the vault may hold for a given +//! token. Deposit attempts that would push the balance past the cap are +//! rejected with [`VaultError::ExceedsReserveCap`]. +//! +//! # Storage +//! Caps are stored under [`StorageKey::ReserveCap`]`(token)` in **instance** +//! storage so they share the same TTL extension as other vault configuration. +//! +//! # Default +//! When no cap has been set for a token, [`get`] returns `i128::MAX`, which +//! is effectively unlimited and keeps [`check`] on the fast path. + +use soroban_sdk::{Address, Env}; + +use crate::{StorageKey, VaultError, INSTANCE_BUMP_AMOUNT, INSTANCE_BUMP_THRESHOLD}; + +/// Default maximum deduction cap — effectively unlimited. +pub const DEFAULT_MAX_DEDUCT: i128 = i128::MAX; + +/// Check whether `amount` exceeds `max_deduct`. Returns `Err(ExceedsMaxDeduct)` if so. +pub fn check_max_deduct(amount: i128, max_deduct: i128) -> Result<(), VaultError> { + if amount > max_deduct { + return Err(VaultError::ExceedsMaxDeduct); + } + Ok(()) +} + +/// Store a per-token reserve cap and return the previous value. +/// +/// # Arguments +/// * `env` - Execution environment. +/// * `token` - Token contract address the cap applies to. +/// * `cap` - New maximum balance in token stroops. +/// +/// # Returns +/// The previous cap (`Some`) or `None` if no cap was previously configured. +pub fn set(env: &Env, token: &Address, cap: i128) -> Option { + let key = StorageKey::ReserveCap(token.clone()); + let prev: Option = env.storage().instance().get(&key); + env.storage().instance().set(&key, &cap); + env.storage() + .instance() + .extend_ttl(INSTANCE_BUMP_THRESHOLD, INSTANCE_BUMP_AMOUNT); + prev +} + +/// Return the reserve cap for `token`. +/// +/// Returns `i128::MAX` when no cap has been configured (effectively unlimited). +pub fn get(env: &Env, token: &Address) -> i128 { + env.storage() + .instance() + .get(&StorageKey::ReserveCap(token.clone())) + .unwrap_or(i128::MAX) +} + +/// Guard called inside `deposit()`. +/// +/// Checks whether `current_balance + deposit_amount` would exceed the +/// configured cap for `token` and returns [`VaultError::ExceedsReserveCap`] +/// if so. When no cap is set the check is skipped entirely (fast path). +/// +/// # Errors +/// - [`VaultError::Overflow`] — if the addition overflows `i128`. +/// - [`VaultError::ExceedsReserveCap`] — if the post-deposit balance would +/// exceed the cap. +pub fn check( + env: &Env, + token: &Address, + current_balance: i128, + deposit_amount: i128, +) -> Result<(), VaultError> { + let cap = get(env, token); + if cap == i128::MAX { + return Ok(()); + } + let post_balance = current_balance + .checked_add(deposit_amount) + .ok_or(VaultError::Overflow)?; + if post_balance > cap { + return Err(VaultError::ExceedsReserveCap); + } + Ok(()) +} diff --git a/contracts/whitelist/src/admin.rs b/contracts/whitelist/src/admin.rs index c4a88d60..78a9f816 100644 --- a/contracts/whitelist/src/admin.rs +++ b/contracts/whitelist/src/admin.rs @@ -1 +1,201 @@ -Ly8hIEdsb2JhbCBjb29sLW9mZiBndWFyZCBmb3IgY3JpdGljYWwgd2hpdGVsaXN0IGFkbWluIGFjdGlvbnMuCi8vIQovLyEgV2hpdGVsaXN0IHN0YXRlLWNoYW5naW5nIGFjdGlvbnMg4oCUIGBhZGRfYWRkcmVzc2AsIGByZW1vdmVfYWRkcmVzc2AsIGBjbGVhcl9hbGxgIOKAlAovLyEgYXJlIGdhdGVkIGJ5IGEgY29uZmlndXJhYmxlIGNvb2wtb2ZmIHdpbmRvdyB0aGF0IHByZXZlbnRzIHJhcGlkIHN1Y2Nlc3NpdmUKLy8hIGFkbWluIG9wZXJhdGlvbnMuIE9uY2UgYW4gYWN0aW9uIHN1Y2NlZWRzLCBubyBvdGhlciBjcml0aWNhbCBhZG1pbiBhY3Rpb24gbWF5Ci8vISBleGVjdXRlIHVudGlsIHRoZSB3aW5kb3cgZWxhcHNlcy4KLy8hCi8vISBUaGUgd2luZG93IGRlZmF1bHRzIHRvICoqT25lIGhvdXIqKiBhbmQgaXMgY29uZmlndXJhYmxlIGJldHdlZW4gKioxIHNlY29uZCoqCi8vISBhbmQgKiozMCBkYXlzKiogYnkgdGhlIGNvbnRyYWN0IGFkbWluLgovLyEKLy8hICMjIFN0b3JhZ2UgTGF5b3V0Ci8vISAtIGBXaGl0ZWxpc3RBZG1pbkNvb2xkb3duYCDigJQgaW5zdGFuY2Ugc3RvcmFnZSwgYHU2NGAgc2Vjb25kcy4KLy8hIC0gYFdoaXRlbGlzdExhc3RDcml0aWNhbEFjdGlvbmAg4oCUIGluc3RhbmNlIHN0b3JhZ2UsIFtgQ3JpdGljYWxBZG1pbkFjdGlvbmBdLgoKdXNlIGNyYXRlOjp7U3RvcmFnZUtleSwgV2hpdGVsaXN0RXJyb3J9Owp1c2Ugc29yb2Jhbl9zZGs6Ontjb250cmFjdHR5cGUsIEVudiwgU3ltYm9sfTsKCi8vLyBNaW5pbXVtIGNvbmZpZ3VyYWJsZSBjb29sLW9mZiB3aW5kb3c6IG9uZSBzZWNvbmQuCnB1YiBjb25zdCBNSU5fQ09PTERPV05fU0VDT05EUzogdTY0ID0gMTsKCi8vLyBNYXhpbXVtIGNvbmZpZ3VyYWJsZSBjb29sLW9mZiB3aW5kb3c6IHRoaXJ0eSBkYXlzLgpwdWIgY29uc3QgTUFYX0NPT0xET1dOX1NFQ09ORFM6IHU2NCA9IDMwICogMjQgKiA2MCAqIDYwOwoKLy8vIERlZmF1bHQgY29vbC1vZmYgd2luZG93OiBvbmUgaG91ci4KcHViIGNvbnN0IERFRkFVTFRfQ09PTERPV05fU0VDT05EUzogdTY0ID0gNjAgKiA2MDsKCi8vLyBBdWRpdCByZWNvcmQgZm9yIHRoZSBtb3N0IHJlY2VudGx5IGV4ZWN1dGVkIGNyaXRpY2FsIHdoaXRlbGlzdCBhZG1pbiBhY3Rpb24uCiNbY29udHJhY3R0eXBlXQojW2Rlcml2ZShDbG9uZSwgRGVidWcsIEVxLCBQYXJ0aWFsRXEpXQpwdWIgc3RydWN0IENyaXRpY2FsQWRtaW5BY3Rpb24gewogICAgLy8vIFN0YWJsZSBhY3Rpb24gdGFnIHN1Y2ggYXMgYCJhZGRfYWRkcmVzcyJgLCBgInJlbW92ZV9hZGRyZXNzImAsIG9yIGAiY2xlYXJfYWxsImAuCiAgICBwdWIgYWN0aW9uOiBTeW1ib2wsCiAgICAvLy8gTGVkZ2VyIHRpbWVzdGFtcCBhdCB3aGljaCB0aGUgYWN0aW9uIHdhcyBleGVjdXRlZC4KICAgIHB1YiBleGVjdXRlZF9hdDogdTY0LAp9CgovLy8gUmV0dXJuIHRoZSBjb25maWd1cmVkIGNvb2wtb2ZmIHdpbmRvdywgZmFsbGluZyBiYWNrIHRvIHRoZSBzZWN1cmUgZGVmYXVsdC4KLy8vCi8vLyBSZWFkcyB0aGUgYFdoaXRlbGlzdEFkbWluQ29vbGRvd25gIHN0b3JhZ2Uga2V5LiBSZXR1cm5zCi8vLyBbYERFRkFVTFRfQ09PTERPV05fU0VDT05EU2BdICgxIGhvdXIpIHdoZW4gbm8gd2luZG93IGhhcyBiZWVuIGV4cGxpY2l0bHkgc2V0LgpwdWIgZm4gZ2V0X2Nvb2xkb3duKGVudjogJkVudikgLT4gdTY0IHsKICAgIG1hdGNoIGVudgogICAgICAgIC5zdG9yYWdlKCkKICAgICAgICAuaW5zdGFuY2UoKQogICAgICAgIC5nZXQoJlN0b3JhZ2VLZXk6OldoaXRlbGlzdEFkbWluQ29vbGRvd24pCiAgICB7CiAgICAgICAgU29tZShzZWNvbmRzKSA9PiBzZWNvbmRzLAogICAgICAgIE5vbmUgPT4gREVGQVVMVF9DT09MRE9XTl9TRUNPTkRTLAogICAgfQp9CgovLy8gVmFsaWRhdGUgYW5kIHBlcnNpc3QgYSBuZXcgY29vbC1vZmYgd2luZG93LgovLy8KLy8vICMgRXJyb3JzCi8vLyBSZXR1cm5zIFtgV2hpdGVsaXN0RXJyb3I6OkludmFsaWRBZG1pbkNvb2xkb3duYF0gd2hlbiBgc2Vjb25kc2AgaXMgb3V0c2lkZQovLy8gW2BNSU5fQ09PTERPV05fU0VDT05EU2BdLi49W2BNQVhfQ09PTERPV05fU0VDT05EU2BdLgpwdWIgZm4gc2V0X2Nvb2xkb3duKGVudjogJkVudiwgc2Vjb25kczogdTY0KSAtPiBSZXN1bHQ8KCksIFdoaXRlbGlzdEVycm9yPiB7CiAgICBpZiAhKE1JTl9DT09MRE9XTl9TRUNPTkRTLi49TUFYX0NPT0xET1dOX1NFQ09ORFMpLmNvbnRhaW5zKCZzZWNvbmRzKSB7CiAgICAgICAgcmV0dXJuIEVycihXaGl0ZWxpc3RFcnJvcjo6SW52YWxpZEFkbWluQ29vbGRvd24pOwogICAgfQoKICAgIGVudi5zdG9yYWdlKCkKICAgICAgICAuaW5zdGFuY2UoKQogICAgICAgIC5zZXQoJlN0b3JhZ2VLZXk6OldoaXRlbGlzdEFkbWluQ29vbGRvd24sICZzZWNvbmRzKTsKICAgIE9rKCkKfQoKLy8vIFJldHVybiB0aGUgbGFzdCBzdWNjZXNzZnVsbHkgZXhlY3V0ZWQgY3JpdGljYWwgYWRtaW4gYWN0aW9uLCBpZiBhbnkuCi8vLwovLy8gUmV0dXJucyBgTm9uZWAgd2hlbiBubyBjcml0aWNhbCBhY3Rpb24gaGFzIGJlZW4gcmVjb3JkZWQgeWV0LgpwdWIgZm4gbGFzdF9hY3Rpb24oZW52OiAmRW52KSAtPiBPcHRpb248Q3JpdGljYWxBZG1pbkFjdGlvbj4gewogICAgZW52LnN0b3JhZ2UoKQogICAgICAgIC5pbnN0YW5jZSgpCiAgICAgICAgLmdldCgmU3RvcmFnZUtleTo6V2hpdGVsaXN0TGFzdENyaXRpY2FsQWN0aW9uKQp9CgovLy8gUmV0dXJuIHRoZSB0aW1lc3RhbXAgYXQgd2hpY2ggdGhlIG5leHQgY3JpdGljYWwgYWN0aW9uIGJlY29tZXMgYXZhaWxhYmxlLgovLy8KLy8vIFNhdHVyYXRpbmcgYXJpdGhtZXRpYyBwcmV2ZW50cyB0aW1lc3RhbXAgd3JhcGFyb3VuZC4gQSBjb250cmFjdCB3aXRoIG5vIHByaW9yCi8vLyBjcml0aWNhbCBhY3Rpb24gcmV0dXJucyBgMGAsIG1lYW5pbmcgYW4gYWN0aW9uIG1heSBleGVjdXRlIGltbWVkaWF0ZWx5LgpwdWIgZm4gcmVhZHlfYXQoZW52OiAmRW52KSAtPiB1NjQgewogICAgbWF0Y2ggbGFzdF9hY3Rpb24oZW52KSB7CiAgICAgICAgU29tZShyZWNvcmQpID0+IHJlY29yZC5leGVjdXRlZF9hdC5zYXR1cmF0aW5nX2FkZChnZXRfY29vbGRvd24oZW52KSksCiAgICAgICAgTm9uZSA9PiAwLAogICAgfQp9CgovLy8gUmV0dXJuIHRoZSBzZWNvbmRzIHJlbWFpbmluZyBpbiB0aGUgYWRtaW4gY29vbC1vZmYgd2luZG93LgovLy8KLy8vIFJldHVybnMgYDBgIHdoZW4gdGhlIHdpbmRvdyBoYXMgZWxhcHNlZCBvciBubyBhY3Rpb24gaGFzIGJlZW4gcmVjb3JkZWQuCnB1YiBmbiByZW1haW5pbmcoZW52OiAmRW52KSAtPiB1NjQgewogICAgcmVhZHlfYXQoZW52KS5zYXR1cmF0aW5nX3N1YihlbnYubGVkZ2VyKCkudGltZXN0YW1wKCkpCn0KCi8vLyBSZXR1cm4gd2hldGhlciBhIGNyaXRpY2FsIGFkbWluIGFjdGlvbiBtYXkgZXhlY3V0ZSBhdCB0aGUgY3VycmVudCB0aW1lc3RhbXAuCnB1YiBmbiBpc19yZWFkeShlbnY6ICZFbnYpIC0+IGJvb2wgewogICAgcmVtYWluaW5nKGVudikgPT0gMAp9CgovLy8gRW5mb3JjZSBhbmQgYXJtIHRoZSBhZG1pbiBjb29sLW9mZiB3aW5kb3cgZm9yIGBhY3Rpb25gLgovLy8KLy8vIENhbGwgdGhpcyBvbmx5IGFmdGVyIGF1dGhvcml6YXRpb24gYW5kIGFjdGlvbi1zcGVjaWZpYyB2YWxpZGF0aW9ucyBoYXZlCi8vLyBzdWNjZWVkZWQuIFNvcm9iYW4gdHJhbnNhY3Rpb24gcm9sbGJhY2sgZW5zdXJlcyB0aGUgcmVjb3JkIGlzIG5vdCByZXRhaW5lZAovLy8gaWYgdGhlIHN1YnNlcXVlbnQgY3JpdGljYWwgb3BlcmF0aW9uIGZhaWxzLgovLy8KLy8vICMgRXJyb3JzCi8vLyBSZXR1cm5zIFtgV2hpdGVsaXN0RXJyb3I6OkFkbWluQ29vbGRvd25BY3RpdmVgXSB3aGlsZSBhbm90aGVyIGNyaXRpY2FsIGFjdGlvbidzCi8vLyBjb29sLW9mZiB3aW5kb3cgaXMgc3RpbGwgYWN0aXZlLgpwdWIgZm4gZ3VhcmQoZW52OiAmRW52LCBhY3Rpb246IFN5bWJvbCkgLT4gUmVzdWx0PCgpLCBXaGl0ZWxpc3RFcnJvcj4gewogICAgaWYgIWlzX3JlYWR5KGVudikgewogICAgICAgIHJldHVybiBFcnIoV2hpdGVsaXN0RXJyb3I6OkFkbWluQ29vbGRvd25BY3RpdmUpOwogICAgfQoKICAgIGxldCByZWNvcmQgPSBDcml0aWNhbEFkbWluQWN0aW9uIHsKICAgICAgICBhY3Rpb24sCiAgICAgICAgZXhlY3V0ZWRfYXQ6IGVudi5sZWRnZXIoKS50aW1lc3RhbXAoKSwKICAgIH07CiAgICBlbnYuc3RvcmFnZSgpCiAgICAgICAgLmluc3RhbmNlKCkKICAgICAgICAuc2V0KCZTdG9yYWdlS2V5OjpXaGl0ZWxpc3RMYXN0Q3JpdGljYWxBY3Rpb24sICZyZWNvcmQpOwogICAgT2soKQp9CgojW2NmZyh0ZXN0KV0KbW9kIHRlc3RzIHsKICAgIHVzZSBzdXBlcjo6KjsKICAgIHVzZSBzb3JvYmFuX3Nkazo6dGVzdHV0aWxzOjpMZWRnZXIgYXMgXzsKICAgIHVzZSBzb3JvYmFuX3Nkazo6e2NvbnRyYWN0LCBFbnZ9OwoKICAgICNbY29udHJhY3RdCiAgICBzdHJ1Y3QgQ29vbGRvd25IYXJuZXNzOwoKICAgIGZuIGluX2NvbnRyYWN0PFQ+KG VudjogJkVudiwgZjogaW1wbCBGbk9uY2UoKSAtPiBUKSAtPiBUIHsKICAgICAgICBsZXQgY29udHJhY3RfaWQgPSBlbnYucmVnaXN0ZXIoQ29vbGRvd25IYXJuZXNzLCAoKSk7CiAgICAgICAgZW52LmFzX2NvbnRyYWN0KCZjb250cmFjdF9pZCwgZikKICAgIH0KCiAgICAjdGVzdF0KICAgIGZuIGRlZmF1bHRzX2FuZF9jb25maWd1cmF0aW9uX2JvdW5kc19hcmVfc3RhYmxlKCkgewogICAgICAgIGxldCBlbnYgPSBFbnY6OmRlZmF1bHQoKTsKICAgICAgICBpbl9jb250cmFjdCgmZW52LCB8fCB7CiAgICAgICAgICAgIGFzc2VydF9lcShXaGl0ZWxpc3RFcnJvcjo6QWRtaW5Db29sZG93bkFjdGl2ZSBhcyB1MzIsIDQ5KTsKICAgICAgICAgICAgYXNzZXJ0X2VxKFdoaXRlbGlzdEVycm9yOjpJbnZhbGlkQWRtaW5Db29sZG93biBhcyB1MzIsIDUwKTsKICAgICAgICAgICAgYXNzZXJ0X2VxKGdldF9jb29sZG93bigmZW52KSwgREVGQVVMVF9DT09MRE9XTl9TRUNPTkRTKTsKICAgICAgICAgICAgYXNzZXJ0X2VxKAogICAgICAgICAgICAgICAgc2V0X2Nvb2xkb3duKCZlbnYsIDApLAogICAgICAgICAgICAgICAgRXJyKFdoaXRlbGlzdEVycm9yOjpJbnZhbGlkQWRtaW5Db29sZG93bikKICAgICAgICAgICAgKTsKICAgICAgICAgICAgYXNzZXJ0X2VxKAogICAgICAgICAgICAgICAgc2V0X2Nvb2xkb3duKCZlbnYsIE1BWF9DT09MRE9XTl9TRUNPTkRTLnNhdHVyYXRpbmdfYWRkKDEpKSwKICAgICAgICAgICAgICAgIEVycihXaGl0ZWxpc3RFcnJvcjo6SW52YWxpZEFkbWluQ29vbGRvd24pCiAgICAgICAgICAgICk7CiAgICAgICAgICAgIGFzc2VydF9lcShzZXRfY29vbGRvd24oJmVudiwgTUlOX0NPT0xET1dOX1NFQ09ORFMpLCBPaygoKSkpOwogICAgICAgICAgICBhc3NlcnRfZXEoZ2V0X2Nvb2xkb3duKCZlbnYpLCBNSU5fQ09PTERPV05fU0VDT05EUyk7CiAgICAgICAgICAgIGFzc2VydF9lcShzZXRfY29vbGRvd24oJmVudiwgTUFYX0NPT0xET1dOX1NFQ09ORFMpLCBPaygoKSkpOwogICAgICAgICAgICBhc3NlcnRfZXEoZ2V0X2Nvb2xkb3duKCZlbnYpLCBNQVhfQ09PTERPV05fU0VDT05EUyk7CiAgICAgICAgfSk7CiAgICB9CgogICAgI1t0ZXN0XQogICAgZm4gb25lX2FjdGlvbl9ibG9ja3NfYV9kaWZmZXJlbnRfYWN0aW9uX3VudGlsX2JvdW5kYXJ5KCkgewogICAgICAgIGxldCBlbnYgPSBFbnY6OmRlZmF1bHQoKTsKICAgICAgICBlbnYubGVkZ2VyKCkuc2V0X3RpbWVzdGFtcCgxXzAwMCk7CiAgICAgICAgaW5fY29udHJhY3QoJmVudiwgfHwgewogICAgICAgICAgICBzZXRfY29vbGRvd24oJmVudiwgMzAwKS51bndyYXAoKTsKICAgICAgICAgICAgYXNzZXJ0X2VxKGd1YXJkKCZlbnYsIFN5bWJvbDo6bmV3KCZlbnYsICJhZGRfYWRkcmVzcyIpKSwgT2soKCkpKTsKICAgICAgICAgICAgYXNzZXJ0X2VxKHJlbWFpbmluZygmZW52KSwgMzAwKTsKICAgICAgICAgICAgYXNzZXJ0X2VxKAogICAgICAgICAgICAgICAgZ3VhcmQoJmVudiwgU3ltYm9sOjpuZXcoJmVudiwgInJlbW92ZV9hZGRyZXNzIikpLAogICAgICAgICAgICAgICAgRXJyKFdoaXRlbGlzdEVycm9yOjpBZG1pbkNvb2xkb3duQWN0aXZlKQogICAgICAgICAgICApOwoKICAgICAgICAgICAgZW52LmxlZGdlcigpLnNldF90aW1lc3RhbXAoMV8yOTkpOwogICAgICAgICAgICBhc3NlcnRfZXEocmVtYWluaW5nKCZlbnYpLCAxKTsKICAgICAgICAgICAgYXNzZXJ0ISghaXNfcmVhZHkoJmVudikpOwoKICAgICAgICAgICAgZW52LmxlZGdlcigpLnNldF90aW1lc3RhbXAoMV8zMDApOwogICAgICAgICAgICBhc3NlcnQhKGlzX3JlYWR5KCZlbnYpKTsKICAgICAgICAgICAgYXNzZXJ0X2VxKGd1YXJkKCZlbnYsIFN5bWJvbDo6bmV3KCZlbnYsICJjbGVhcl9hbGwiKSksIE9rKCgpKSk7CgogICAgICAgICAgICBsZXQgcmVjb3JkID0gbGFzdF9hY3Rpb24oJmVudikuZXhwZWN0KCJjcml0aWNhbCBhY3Rpb24gcmVjb3JkIik7CiAgICAgICAgICAgIGFzc2VydF9lcShyZWNvcmQuYWN0aW9uLCBTeW1ib2w6Om5ldygmZW52LCAiY2xlYXJfYWxsIikpOwogICAgICAgICAgICBhc3NlcnRfZXEocmVjb3JkLmV4ZWN1dGVkX2F0LCAxXzMwMCk7CiAgICAgICAgfSk7CiAgICB9CgogICAgI1t0ZXN0XQogICAgZm4gcmVhZGluZXNzX21hdGhfc2F0dXJhdGVzX2F0X3RpbWVzdGFtcF9saW1pdCgpIHsKICAgICAgICBsZXQgZW52ID0gRW52OjpkZWZhdWx0KCk7CiAgICAgICAgZW52LmxlZGdlcigpLnNldF90aW1lc3RhbXAodTY0OjpNQVggLSAxMCk7CiAgICAgICAgaW5fY29udHJhY3QoJmVudiwgfHwgewogICAgICAgICAgICBzZXRfY29vbGRvd24oJmVudiwgNjApLnVud3JhcCgpOwogICAgICAgICAgICBndWFyZCgmZW52LCBTeW1ib2w6Om5ldygmZW52LCAiYWRkX2FkZHJlc3MiKSkudW53cmFwKCk7CiAgICAgICAgICAgIGFzc2VydF9lcShyZWFkeV9hdCgmZW52KSwgdTY0OjpNQVgpOwogICAgICAgICAgICBhc3NlcnRfZXEocmVtYWluaW5nKCZlbnYpLCAxMCk7CgogICAgICAgICAgICBlbnYubGVkZ2VyKCkuc2V0X3RpbWVzdGFtcCh1NjQ6Ok1BWCk7CiAgICAgICAgICAgIGFzc2VydCEoaXNfcmVhZHkoJmVudikpOwogICAgICAgIH0pOwogICAgfQp9Cg== \ No newline at end of file +//! Global cool-off guard for critical whitelist admin actions. +//! +//! Whitelist state-changing actions — `add_address`, `remove_address`, `clear_all` — +//! are gated by a configurable cool-off window that prevents rapid successive +//! admin operations. Once an action succeeds, no other critical admin action may +//! execute until the window elapses. +//! +//! The window defaults to **one hour** and is configurable between **1 second** +//! and **30 days** by the contract admin. +//! +//! ## Storage Layout +//! - `WhitelistAdminCooldown` — instance storage, `u64` seconds. +//! - `WhitelistLastCriticalAction` — instance storage, [`CriticalAdminAction`]. + +use crate::{StorageKey, WhitelistError}; +use soroban_sdk::{contracttype, Env, Symbol}; + +/// Minimum configurable cool-off window: one second. +pub const MIN_COOLDOWN_SECONDS: u64 = 1; + +/// Maximum configurable cool-off window: thirty days. +pub const MAX_COOLDOWN_SECONDS: u64 = 30 * 24 * 60 * 60; + +/// Default cool-off window: one hour. +pub const DEFAULT_COOLDOWN_SECONDS: u64 = 60 * 60; + +/// Audit record for the most recently executed critical whitelist admin action. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CriticalAdminAction { + /// Stable action tag such as `"add_address"`, `"remove_address"`, or `"clear_all"`. + pub action: Symbol, + /// Ledger timestamp at which the action was executed. + pub executed_at: u64, +} + +/// Return the configured cool-off window, falling back to the secure default. +/// +/// Reads the `WhitelistAdminCooldown` storage key. Returns +/// [`DEFAULT_COOLDOWN_SECONDS`] (1 hour) when no window has been explicitly set. +pub fn get_cooldown(env: &Env) -> u64 { + match env + .storage() + .instance() + .get(&StorageKey::WhitelistAdminCooldown) + { + Some(seconds) => seconds, + None => DEFAULT_COOLDOWN_SECONDS, + } +} + +/// Validate and persist a new cool-off window. +/// +/// # Errors +/// Returns [`WhitelistError::InvalidAdminCooldown`] when `seconds` is outside +/// [`MIN_COOLDOWN_SECONDS`]..=[`MAX_COOLDOWN_SECONDS`]. +pub fn set_cooldown(env: &Env, seconds: u64) -> Result<(), WhitelistError> { + if !(MIN_COOLDOWN_SECONDS..=MAX_COOLDOWN_SECONDS).contains(&seconds) { + return Err(WhitelistError::InvalidAdminCooldown); + } + + env.storage() + .instance() + .set(&StorageKey::WhitelistAdminCooldown, &seconds); + Ok(()) +} + +/// Return the last successfully executed critical admin action, if any. +/// +/// Returns `None` when no critical action has been recorded yet. +pub fn last_action(env: &Env) -> Option { + env.storage() + .instance() + .get(&StorageKey::WhitelistLastCriticalAction) +} + +/// Return the timestamp at which the next critical action becomes available. +/// +/// Saturating arithmetic prevents timestamp wraparound. A contract with no prior +/// critical action returns `0`, meaning an action may execute immediately. +pub fn ready_at(env: &Env) -> u64 { + match last_action(env) { + Some(record) => record.executed_at.saturating_add(get_cooldown(env)), + None => 0, + } +} + +/// Return the seconds remaining in the admin cool-off window. +/// +/// Returns `0` when the window has elapsed or no action has been recorded. +pub fn remaining(env: &Env) -> u64 { + ready_at(env).saturating_sub(env.ledger().timestamp()) +} + +/// Return whether a critical admin action may execute at the current timestamp. +pub fn is_ready(env: &Env) -> bool { + remaining(env) == 0 +} + +/// Enforce and arm the admin cool-off window for `action`. +/// +/// Call this only after authorization and action-specific validations have +/// succeeded. Soroban transaction rollback ensures the record is not retained +/// if the subsequent critical operation fails. +/// +/// # Errors +/// Returns [`WhitelistError::AdminCooldownActive`] while another critical action's +/// cool-off window is still active. +pub fn guard(env: &Env, action: Symbol) -> Result<(), WhitelistError> { + if !is_ready(env) { + return Err(WhitelistError::AdminCooldownActive); + } + + let record = CriticalAdminAction { + action, + executed_at: env.ledger().timestamp(), + }; + env.storage() + .instance() + .set(&StorageKey::WhitelistLastCriticalAction, &record); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::testutils::Ledger as _; + use soroban_sdk::{contract, Env}; + + #[contract] + struct CooldownHarness; + + fn in_contract(env: &Env, f: impl FnOnce() -> T) -> T { + let contract_id = env.register(CooldownHarness, ()); + env.as_contract(&contract_id, f) + } + + #[test] + fn defaults_and_configuration_bounds_are_stable() { + let env = Env::default(); + in_contract(&env, || { + assert_eq!(WhitelistError::AdminCooldownActive as u32, 49); + assert_eq!(WhitelistError::InvalidAdminCooldown as u32, 50); + assert_eq!(get_cooldown(&env), DEFAULT_COOLDOWN_SECONDS); + assert_eq!( + set_cooldown(&env, 0), + Err(WhitelistError::InvalidAdminCooldown) + ); + assert_eq!( + set_cooldown(&env, MAX_COOLDOWN_SECONDS.saturating_add(1)), + Err(WhitelistError::InvalidAdminCooldown) + ); + assert_eq!(set_cooldown(&env, MIN_COOLDOWN_SECONDS), Ok(())); + assert_eq!(get_cooldown(&env), MIN_COOLDOWN_SECONDS); + assert_eq!(set_cooldown(&env, MAX_COOLDOWN_SECONDS), Ok(())); + assert_eq!(get_cooldown(&env), MAX_COOLDOWN_SECONDS); + }); + } + + #[test] + fn one_action_blocks_a_different_action_until_boundary() { + let env = Env::default(); + env.ledger().set_timestamp(1_000); + in_contract(&env, || { + set_cooldown(&env, 300).unwrap(); + assert_eq!(guard(&env, Symbol::new(&env, "add_address")), Ok(())); + assert_eq!(remaining(&env), 300); + assert_eq!( + guard(&env, Symbol::new(&env, "remove_address")), + Err(WhitelistError::AdminCooldownActive) + ); + + env.ledger().set_timestamp(1_299); + assert_eq!(remaining(&env), 1); + assert!(!is_ready(&env)); + + env.ledger().set_timestamp(1_300); + assert!(is_ready(&env)); + assert_eq!(guard(&env, Symbol::new(&env, "clear_all")), Ok(())); + + let record = last_action(&env).expect("critical action record"); + assert_eq!(record.action, Symbol::new(&env, "clear_all")); + assert_eq!(record.executed_at, 1_300); + }); + } + + #[test] + fn readiness_math_saturates_at_timestamp_limit() { + let env = Env::default(); + env.ledger().set_timestamp(u64::MAX - 10); + in_contract(&env, || { + set_cooldown(&env, 60).unwrap(); + guard(&env, Symbol::new(&env, "add_address")).unwrap(); + assert_eq!(ready_at(&env), u64::MAX); + assert_eq!(remaining(&env), 10); + + env.ledger().set_timestamp(u64::MAX); + assert!(is_ready(&env)); + }); + } +} diff --git a/contracts/yield/YIELD_LIMITS.md b/contracts/yield/YIELD_LIMITS.md index 0a9d4a46..7e5c8b1c 100644 --- a/contracts/yield/YIELD_LIMITS.md +++ b/contracts/yield/YIELD_LIMITS.md @@ -13,12 +13,6 @@ yield-bearing UX without producing meaningful economic activity. The issue requires per-account caps on the *number* of open bets, open positions, and active subscriptions a single address may hold at any one time. -Because the counter mutators are self-reported, an account could otherwise -decrement its own counters at will and re-place indefinitely, making the caps -advisory only. To close this, increment/decrement is restricted to an -authorized operator contract configured by the admin, and the operator's auth -must accompany the account's auth on every mutator call. - ## Surface The new contract `CalloraYieldLimits` (in `contracts/yield/src/limits.rs`) @@ -48,18 +42,17 @@ pub use errors::YieldLimitError; | `set_default_limits(caller, max_b, max_p, max_s)` | Replace the global defaults. | | `set_account_limits(caller, account, max_b, max_p, max_s)` | Override the defaults for a single account. | | `clear_account_limits(caller, account)` | Revert the account to the global defaults. | -| `set_operator(caller, operator)` | Set the authorized operator contract (admin only). | ### User counter mutators (caller-authenticated) | Entrypoint | Effect | |-----------------------------|-----------------------------------------------------------------| -| `place_bet(caller)` | Increment caller's open-bet counter; requires operator auth; rejects at cap. | -| `clear_bet(caller)` | Decrement caller's open-bet counter; requires operator auth; rejects on zero. | -| `open_position(caller)` | Increment caller's open-position counter; requires operator auth; rejects at cap. | -| `close_position(caller)` | Decrement caller's open-position counter; requires operator auth; rejects on zero. | -| `subscribe(caller)` | Increment caller's active-subscription counter; requires operator auth; rejects at cap. | -| `unsubscribe(caller)` | Decrement caller's active-subscription counter; requires operator auth; rejects on zero. | +| `place_bet(caller)` | Increment caller's open-bet counter; rejects at cap. | +| `clear_bet(caller)` | Decrement caller's open-bet counter; rejects on zero. | +| `open_position(caller)` | Increment caller's open-position counter; rejects at cap. | +| `close_position(caller)` | Decrement caller's open-position counter; rejects on zero. | +| `subscribe(caller)` | Increment caller's active-subscription counter; rejects at cap. | +| `unsubscribe(caller)` | Decrement caller's active-subscription counter; rejects on zero.| ### Read-only views (no auth) @@ -69,7 +62,6 @@ pub use errors::YieldLimitError; | `get_default_limits()` | Global cap defaults (fallback to `DEFAULT_LIMITS`). | | `get_account_limits(account)` | Effective caps for the account (per-account override → default). | | `get_account_state(account)` | Live counters for the account (zeroed if absent). | -| `get_operator()` | Current authorized operator address or `NotInitialized`. | | `can_place_bet(account)` / `can_open_position(account)` / | | | `can_subscribe(account)` | Dry-run gate checks. | @@ -78,9 +70,6 @@ pub use errors::YieldLimitError; - `DefaultLimits` and `AccountLimits(Address)` are stored in **instance** storage so they participate in the same TTL extension window as the rest of the contract's configuration. -- `Operator` is stored in **instance** storage alongside the admin and limits - configuration so it shares the same TTL extension window and is set/read - atomically with the rest of the trust configuration. - `AccountState(Address)` is stored in **persistent** storage and bumped to `STATE_BUMP_AMOUNT` (≈30 days) on every read / write so accounts that go quiet do not silently archive. @@ -120,7 +109,6 @@ post-init override. | 7 | `SubscriptionsAtCap` | Account's active-subscription counter is at the cap. | | 8 | `CounterUnderflow` | `clear_*` called when the corresponding counter is 0. | | 9 | `Overflow` | `u32` counter overflow during increment. | -| 10 | `OperatorNotSet` | Operator address has not been configured by the admin. | ## Events @@ -131,7 +119,6 @@ tests. Topic vocabulary (`init`, `admin_*`, `default_limits_set`, `position_opened`, `position_closed`, `subscription_added`, `subscription_removed`, `upgraded`) follows the past-tense-verb pattern documented in `CONTRIBUTING.md`. -`operator_set` is emitted whenever the admin changes the authorized operator. ## Backward compatibility @@ -140,10 +127,6 @@ documented in `CONTRIBUTING.md`. - No prior type, storage key, or event topic was renamed. - No prior `callora-yield` consumer is broken (only additive additions to the public surface). -- The counter mutators now require the configured operator's auth in addition - to the account's auth. Existing integrations that call these entrypoints - directly must be updated to route through the operator contract, or the - admin must set the operator to the address performing the calls. ## Verification @@ -153,9 +136,6 @@ cargo fmt -p callora-yield cargo test -p callora-yield --lib # 40+ unit tests cargo test -p callora-yield --test auth_snap # 13 mutator + 7 view assertions -# Confirm an account cannot reset its own bet count without operator auth. -cargo test -p callora-yield --test operator_gate - # Build for the wasm32 target so WASM-bound assets do compile. cargo build -p callora-yield --target wasm32-unknown-unknown --release ``` @@ -163,9 +143,6 @@ cargo build -p callora-yield --target wasm32-unknown-unknown --release All unit tests must pass, all 13 mutator assertions in `auth_snap.rs` must report `res.is_err()`, and all 7 view assertions in `auth_snap.rs` must run without `require_auth`. -The `operator_gate` suite proves that `clear_bet` (and the other decrement -mutators) fail with `Unauthorized` when the account attempts to call them -without the configured operator's auth. ## Coverage summary @@ -174,6 +151,3 @@ without the configured operator's auth. - 13 authentication-snapshot mutator tests (`require_auth` enforcement). - 7 authentication-snapshot view tests (no-auth callability). - 13 byte-snapshot tests pinning event-topic symbol identity. -- Operator-gate tests proving accounts cannot decrement their own counters - without operator auth, and that `set_operator` is admin-only and emits - `operator_set`. diff --git a/contracts/yield/src/errors.rs b/contracts/yield/src/errors.rs index 9496ef53..4d409750 100644 --- a/contracts/yield/src/errors.rs +++ b/contracts/yield/src/errors.rs @@ -15,7 +15,6 @@ //! | 7 | SubscriptionsAtCap | Account's active subscription count is at the configured cap | //! | 8 | CounterUnderflow | `clear_*` was called when the corresponding counter was zero | //! | 9 | Overflow | `checked_add` overflow detected on a counter increment | -//! | 10 | OperatorNotSet | No operator contract has been configured by the admin | //! //! All variants implement [`Copy`] + [`PartialEq`] so they can be returned by //! value or pinned in arrays without allocation. @@ -64,13 +63,4 @@ pub enum YieldLimitError { /// counter arithmetic saturates `u32::MAX` — surfaces a stable error /// rather than panicking so callers cannot rely on undefined behaviour. Overflow = 9, - /// No operator contract has been configured by the admin (code 10). - /// - /// Returned by counter-mutating entry points (`place_bet`, `clear_bet`, - /// `open_position`, `close_position`, `subscribe`, `unsubscribe`) when the - /// admin has not yet set the authorized operator contract via - /// `set_operator`. Without a configured operator, no account may - /// increment or decrement its own counters, so the caps cannot be gamed - /// by self-reported state changes. - OperatorNotSet = 10, } diff --git a/contracts/yield/src/events.rs b/contracts/yield/src/events.rs index 406c64b1..e3718177 100644 --- a/contracts/yield/src/events.rs +++ b/contracts/yield/src/events.rs @@ -206,15 +206,6 @@ pub fn event_upgraded(env: &Env) -> Symbol { Symbol::new(env, "upgraded") } -/// Returns the Symbol for the `"operator_set"` event topic. -/// -/// Emitted when the admin configures the authorized operator contract that -/// is permitted to increment and decrement per-account counters via -/// [`crate::CalloraYieldLimits::set_operator`]. -pub fn event_operator_set(env: &Env) -> Symbol { - Symbol::new(env, "operator_set") -} - // --------------------------------------------------------------------------- // Yield lifecycle event topics // --------------------------------------------------------------------------- @@ -426,13 +417,6 @@ mod tests { assert_eq!(event_upgraded(&env), Symbol::new(&env, "upgraded")); } - /// Snapshot: proves `event_operator_set` still maps to exactly the bytes for `"operator_set"`. - #[test] - fn test_event_operator_set_bytes() { - let env = Env::default(); - assert_eq!(event_operator_set(&env), Symbol::new(&env, "operator_set")); - } - // ----------------------------------------------------------------------- // Lifecycle event snapshot tests // ----------------------------------------------------------------------- diff --git a/contracts/yield/src/lib.rs b/contracts/yield/src/lib.rs index c3922e51..5dae95b7 100644 --- a/contracts/yield/src/lib.rs +++ b/contracts/yield/src/lib.rs @@ -1,36 +1,27 @@ //! Yield deposit surface for Callora. -/// -/// Protocol yield deposits are implemented by -/// [`callora_revenue_pool::RevenuePool::deposit_yield`]. This crate hosts the -/// cross-contract call safety integration tests under `tests/xcontract.rs`. -/// -/// # Per-account limits surface (Issue #842 / task b#017) -/// -/// As of issue #842, this crate exposes a per-account state-cap surface that -/// prevents a single account from farming yield-bets, yield-positions, or -/// yield-subscriptions beyond a configurable maximum: -/// -/// - [`limits::CalloraYieldLimits`] — the on-chain Soroban contract that -/// enforces per-account caps. -/// - [`limits::AccountLimits`] — the caps struct (`max_bets`, `max_positions`, -/// `max_subscriptions`). -/// - [`limits::AccountState`] — the live counters (`bets`, `positions`, -/// `subscriptions`). -/// - [`errors::YieldLimitError`] — stable `u32` error codes returned by -/// the contract. -/// -/// # Trust model for counter mutation (Issue #843) -/// -/// Counter increments and decrements are not self-reported. Only an admin- -/// configured operator contract may mutate an account's live counters. The -/// operator is the contract that actually creates bets, positions, and -/// subscriptions, and it must authorize each mutation. Accounts have no -/// unilateral path to decrement their own counters, so the caps cannot be -/// circumvented by clearing and re-placing. -/// -/// The surface is fully backward compatible with prior versions: the existing -/// `RevenuePool` re-exports remain unchanged, no prior symbol was renamed, -/// and no prior storage key was reused. +//! +//! Protocol yield deposits are implemented by +//! [`callora_revenue_pool::RevenuePool::deposit_yield`]. This crate hosts the +//! cross-contract call safety integration tests under `tests/xcontract.rs`. +//! +//! # Per-account limits surface (Issue #842 / task b#017) +//! +//! As of issue #842, this crate exposes a per-account state-cap surface that +//! prevents a single account from farming yield-bets, yield-positions, or +//! yield-subscriptions beyond a configurable maximum: +//! +//! - [`limits::CalloraYieldLimits`] — the on-chain Soroban contract that +//! enforces per-account caps. +//! - [`limits::AccountLimits`] — the caps struct (`max_bets`, `max_positions`, +//! `max_subscriptions`). +//! - [`limits::AccountState`] — the live counters (`bets`, `positions`, +//! `subscriptions`). +//! - [`errors::YieldLimitError`] — stable `u32` error codes returned by the +//! contract. +//! +//! The surface is fully backward compatible with prior versions: the existing +//! `RevenuePool` re-exports remain unchanged, no prior symbol was renamed, +//! and no prior storage key was reused. #![no_std] @@ -44,4 +35,5 @@ pub mod views; pub use errors::YieldLimitError; pub use limits::{AccountLimits, AccountState, CalloraYieldLimits, CalloraYieldLimitsClient}; -#cfn(test)]mmod test_limits; +#[cfg(test)] +mod test_limits; diff --git a/contracts/yield/src/limits.rs b/contracts/yield/src/limits.rs index 79b5d855..e1e81a58 100644 --- a/contracts/yield/src/limits.rs +++ b/contracts/yield/src/limits.rs @@ -11,8 +11,8 @@ //! //! Two [`contracttype`] structs drive the limits surface: //! -//! | Type | Purpose | -//! |----------------|-------------------------------------------------------------------| +//! | Type | Purpose | +//! |----------------|---------------------------------------------------------------| //! | [`AccountLimits`] | The configured `(max_bets, max_positions, max_subscriptions)` caps for an account. | //! | [`AccountState`] | The current `(bets, positions, subscriptions)` counters for an account. | //! @@ -22,41 +22,28 @@ //! # Storage Layout //! //! - [`AccountLimits`] are stored in **instance** storage under -//! [`StorageKey::AccountLimits`]`Address)`. They are sparse overrides; an +//! [`StorageKey::AccountLimits`]`(Address)`. They are sparse overrides; an //! account with no explicit override falls back to the global //! [`DEFAULT_LIMITS`] constant. //! //! - [`AccountState`] counters are stored in **persistent** storage under -//! [`StorageKey::AccountState`]`Address)`. Persistent storage lets the +//! [`StorageKey::AccountState`]`(Address)`. Persistent storage lets the //! contract scale to many accounts (instance storage is small and shared //! with config) and keeps counters alive across the typical 7-day ledger //! archival window via TTL extensions on every increment/decrement. //! //! # Auth Model //! -//! The counters are **not** self-reported. Only the configured -//! **operator** contract (the contract that actually creates bets, positions -//! and subscriptions) may increment or decrement an account's counters. The -//! operator address is configured by the admin via -//! [`CalloraYieldLimits::set_operator`] and emits an event on every change. -//! //! | Entrypoint | Authorized by | -//! |------------------------------------------------------|-----------------------------------------------| -//! | init, set_admin, accept_admin, upgrade | admin (`caller == admin`) | -//! | cancel_admin_transfer | admin (`caller == admin`) | -//! | set_default_limits, set_account_limits, clear_account_limits | admin (`caller == admin`) | -//! | set_operator, clear_operator | admin (`caller == admin`) | -//! | place_bet, clear_bet, open_position, close_position, | operator (`caller == operator`) | -//! | subscribe, unsubscribe | operator (`caller == operator`) | -//! -//! The operator is the only address allowed to mutate counters. The account -//! whose counter is being mutated is passed as a parameter and does **not** -//! need to authorize the call ( the operator is trusted to attest to the -//! account's state ). This prevents an account from clearing its own counters -//! to circumvent the caps. +//! |--------------------------------------------------|-------------------------------------------| +//! | `init`, `set_admin`, `accept_admin`, `upgrade` | admin (`caller == admin`) | +//! | `cancel_admin_transfer` | admin (`caller == admin`) | +//! | `set_default_limits`, `set_account_limits`, `clear_account_limits` | admin (`caller == admin`) | +//! | `place_bet`, `clear_bet`, `open_position`, `close_position`, | caller (their own counter) | +//! | `subscribe`, `unsubscribe` | caller (their own counter) | //! //! Read-only views (`get_admin`, `get_default_limits`, `get_account_limits`, -//! `get_account_state`, `get_operator`, `can_*`) do **not** call `require_auth`. +//! `get_account_state`, `can_*`) do **not** call `require_auth`. //! //! # Overflow Safety //! @@ -70,18 +57,18 @@ use soroban_sdk::{contract, contractimpl, contracttype, Address, BytesN, Env}; use crate::errors::YieldLimitError; use crate::events; -// ---------------------------------------------------------------------- +// --------------------------------------------------------------------- // Constants — TTL and defaults -+/ ---------------------------------------------------------------------- +// --------------------------------------------------------------------- /// Per-day ledger count at a 5-second close cadence (matches vault). -pub const LEFGERS_PER_DAY: u32 = 17_280; +pub const LEDGERS_PER_DAY: u32 = 17_280; /// TTL bump threshold for persistent storage keys (`AccountState`). /// /// When the remaining TTL of the key falls below this value the contract -/// re-extends the TTL on every increment / decrement so account counters -/// do not silently archive. +/// re-extends the TTL on every increment / decrement so account counters do +/// not silently archive. pub const STATE_BUMP_THRESHOLD: u32 = LEDGERS_PER_DAY * 7; /// TTL bump amount for persistent storage keys (`AccountState`). @@ -121,9 +108,9 @@ pub const DEFAULT_LIMITS: AccountLimits = AccountLimits { /// sanity ceiling rather than the absolute type ceiling. pub const MAX_CAP: u32 = 1_000_000; -// ---------------------------------------------------------------------- +// --------------------------------------------------------------------- // Storage keys -// ---------------------------------------------------------------------- +// --------------------------------------------------------------------- /// Instance / persistent storage keys for the yield per-account limits /// contract. @@ -137,11 +124,6 @@ pub enum StorageKey { Admin, /// Pending admin awaiting acceptance (two-step transfer). PendingAdmin, - /// Operator contract authorized to mutate per-account counters. - /// -/// Only this address may call `place_bet`, `clear_bet`, -/// `open_position`, `close_position`, `subscribe`, `unsubscribe`. - Operator, /// Global default caps applied when an account has no explicit override. DefaultLimits, /// Per-account cap override (instance storage; sparse). @@ -150,9 +132,9 @@ pub enum StorageKey { AccountState(Address), } -// ---------------------------------------------------------------------- +// --------------------------------------------------------------------- // Aux structs -// ---------------------------------------------------------------------- +// --------------------------------------------------------------------- /// Per-account state caps configured by the admin. /// @@ -218,24 +200,24 @@ impl AccountState { } /// Increment the bet counter using `checked_add`. -/// -/// # Errors -/// - [`YieldLimitError::Overflow`] — counter would saturate `u32::MAX` . + /// + /// # Errors + /// - [`YieldLimitError::Overflow`] — counter would saturate `u32::MAX`. pub fn add_bet(&mut self) -> Result<(), YieldLimitError> { self.bets = self.bets.checked_add(1).ok_or(YieldLimitError::Overflow)?; - Ok() + Ok(()) } /// Decrement the bet counter using `checked_sub`. -/// -/// # Errors -/// - [`YieldLimitError::CounterUnderflow`] — counter is already 0. + /// + /// # Errors + /// - [`YieldLimitError::CounterUnderflow`] — counter is already 0. pub fn sub_bet(&mut self) -> Result<(), YieldLimitError> { self.bets = self .bets .checked_sub(1) .ok_or(YieldLimitError::CounterUnderflow)?; - Ok() + Ok(()) } /// Increment the position counter using `checked_add`. @@ -244,7 +226,7 @@ impl AccountState { .positions .checked_add(1) .ok_or(YieldLimitError::Overflow)?; - Ok(() + Ok(()) } /// Decrement the position counter using `checked_sub`. @@ -253,7 +235,7 @@ impl AccountState { .positions .checked_sub(1) .ok_or(YieldLimitError::CounterUnderflow)?; - Ok() + Ok(()) } /// Increment the subscription counter using `checked_add`. @@ -262,7 +244,7 @@ impl AccountState { .subscriptions .checked_add(1) .ok_or(YieldLimitError::Overflow)?; - Ok(() + Ok(()) } /// Decrement the subscription counter using `checked_sub`. @@ -271,13 +253,13 @@ impl AccountState { .subscriptions .checked_sub(1) .ok_or(YieldLimitError::CounterUnderflow)?; - Ok() + Ok(()) } } -// ---------------------------------------------------------------------- +// --------------------------------------------------------------------- // Free functions — storage helpers -// ---------------------------------------------------------------------- +// --------------------------------------------------------------------- /// Read the admin address from instance storage. /// @@ -286,7 +268,7 @@ impl AccountState { pub fn read_admin(env: &Env) -> Result { env.storage() .instance() - .get::_, Address>(&StorageKey::Admin) + .get::<_, Address>(&StorageKey::Admin) .ok_or(YieldLimitError::NotInitialized) } @@ -304,40 +286,7 @@ pub fn require_admin(env: &Env, caller: &Address) -> Result<(), YieldLimitError> if *caller != admin { return Err(YieldLimitError::Unauthorized); } - Ok() -} - -/// Read the configured operator address from instance storage. -/// -/// The operator is the only address allowed to mutate per-account counters -/// (`place_bet` / `clear_bet` / `open_position` / `close_position` / -/// `subscribe` / `unsubscribe`). -/// -/// # Errors -/// - [`YieldLimitError::OperatorNotSet`] — no operator has been configured -/// by the admin yet. -pub fn read_operator(env: &Env) -> Result { - env.storage() - .instance() - .get::_, Address>(&StorageKey::Operator) - .ok_or(YieldLimitError::OperatorNotSet) -} - -/// Assert `caller` equals the configured operator. -/// -/// Runs `caller.require_auth()` first so misconfigured callers are rejected -/// deterministically without consuming the underlying signature. -/// -/// # Errors -/// - [`YieldLimitError::Unauthorized`] — caller is not the configured operator. -/// - [`YieldLimitError::OperatorNotSet`] — no operator has been configured. -pub fn require_operator(env: &Env, caller: &Address) -> Result<(), YieldLimitError> { - let operator = read_operator(env)?; - caller.require_auth(); - if *caller != operator { - return Err(YieldLimitError::Unauthorized); - } - Ok() + Ok(()) } /// Read the global default caps (or fall back to [`DEFAULT_LIMITS`]) and @@ -346,7 +295,7 @@ pub fn read_default_limits(env: &Env) -> AccountLimits { let caps: AccountLimits = env .storage() .instance() - .get::_, AccountLimits>(&StorageKey::DefaultLimits) + .get::<_, AccountLimits>(&StorageKey::DefaultLimits) .unwrap_or(DEFAULT_LIMITS); env.storage() .instance() @@ -368,51 +317,32 @@ pub fn write_default_limits(env: &Env, caps: &AccountLimits) -> Result<(), Yield env.storage() .instance() .extend_ttl(INSTANCE_BUMP_THRESHOLD, INSTANCE_BUMP_AMOUNT); - Ok() + Ok(()) } -/// Persist the operator address and extend instance TTL. +/// Read the per-account override or fall back to [`read_default_limits`]. /// -/// The operator is the only address allowed to mutate per-account counters. -/// Changing the operator emits an event so off-chain monitors can track the -/// trust boundary. -pub fn write_operator(env: &Env, operator: &Address) { - env.storage() - .instance() - .set(&StorageKey::Operator, operator); - env.storage() - .instance() - .extend_ttl(INSTANCE_BUMP_THRESHOLD, INSTANCE_BUMP_AMOUNT); -} - -/// Remove the configured operator and extend instance TTL. -/// -/// After this call no address may mutate per-account counters until a new -/// operator is configured via [`write_operator`]. -pub fn clear_operator(env: &Env) { - env.storage().instance().remove(&StorageKey::Operator); - env.storage() - .instance() - .extend_ttl(INSTANCE_BUMP_THRESHOLD, INSTANCE_BUMP_AMOUNT); -} - -/// Read the per-account cap override from instance storage, falling back to -/// the global defaults. -/// -/// Always extends instance TTL. +/// Per-account overrides stored in instance storage survive across calls +/// along with other config; persistent storage is reserved for the live +/// state counters. pub fn read_account_limits(env: &Env, account: &Address) -> AccountLimits { - let caps: AccountLimits = env + if let Some(caps) = env .storage() .instance() - .get::_, AccountLimits>(&StorageKey::AccountLimits(account.clone())) - .unwrap_or_else(|| read_default_limits(env)); + .get::<_, AccountLimits>(&StorageKey::AccountLimits(account.clone())) + { + env.storage() + .instance() + .extend_ttl(INSTANCE_BUMP_THRESHOLD, INSTANCE_BUMP_AMOUNT); + return caps; + } env.storage() .instance() .extend_ttl(INSTANCE_BUMP_THRESHOLD, INSTANCE_BUMP_AMOUNT); - caps + read_default_limits(env) } -/// Persist a per-account cap override and extend instance TTL. +/// Persist a per-account override and extend instance TTL. /// /// # Errors /// - [`YieldLimitError::InvalidLimit`] — any cap exceeds [`MAX_CAP`]. @@ -430,12 +360,11 @@ pub fn write_account_limits( env.storage() .instance() .extend_ttl(INSTANCE_BUMP_THRESHOLD, INSTANCE_BUMP_AMOUNT); - Ok(() + Ok(()) } -/// Remove a per-account cap override and extend instance TTL. -/// -/// After this call the account falls back to the global default limits. +/// Remove any per-account override. After this call the account falls back +/// to the global default caps. pub fn clear_account_limits(env: &Env, account: &Address) { env.storage() .instance() @@ -445,22 +374,22 @@ pub fn clear_account_limits(env: &Env, account: &Address) { .extend_ttl(INSTANCE_BUMP_THRESHOLD, INSTANCE_BUMP_AMOUNT); } -/// Read the per-account live counters from persistent storage, defaulting to -/// zero and extending persistent TTL. +/// Read the live per-account counters, returning a zero struct if the +/// caller has no recorded state yet. Bumps persistent TTL on read. pub fn read_account_state(env: &Env, account: &Address) -> AccountState { let key = StorageKey::AccountState(account.clone()); - let state: AccountState = env + let state = env .storage() .persistent() - .get::_, AccountState>(&key) - .unwrap_or_default(); + .get::<_, AccountState>(&key) + .unwrap_or_else(AccountState::zero); env.storage() .persistent() .extend_ttl(&key, STATE_BUMP_THRESHOLD, STATE_BUMP_AMOUNT); state } -/// Persist the per-account live counters and extend persistent TTL. +/// Persist the live per-account counters and bump persistent TTL. pub fn write_account_state(env: &Env, account: &Address, state: &AccountState) { let key = StorageKey::AccountState(account.clone()); env.storage().persistent().set(&key, state); @@ -469,453 +398,361 @@ pub fn write_account_state(env: &Env, account: &Address, state: &AccountState) { .extend_ttl(&key, STATE_BUMP_THRESHOLD, STATE_BUMP_AMOUNT); } -// ---------------------------------------------------------------------- +// --------------------------------------------------------------------- +// Free functions — gate checks +// --------------------------------------------------------------------- + +/// Return `true` if `account` can place another bet without exceeding caps. +pub fn can_place_bet(env: &Env, account: &Address) -> bool { + let caps = read_account_limits(env, account); + let state = read_account_state(env, account); + state.bets < caps.max_bets +} + +/// Return `true` if `account` can open another position without exceeding caps. +pub fn can_open_position(env: &Env, account: &Address) -> bool { + let caps = read_account_limits(env, account); + let state = read_account_state(env, account); + state.positions < caps.max_positions +} + +/// Return `true` if `account` can subscribe again without exceeding caps. +pub fn can_subscribe(env: &Env, account: &Address) -> bool { + let caps = read_account_limits(env, account); + let state = read_account_state(env, account); + state.subscriptions < caps.max_subscriptions +} + +// --------------------------------------------------------------------- // Contract -// ---------------------------------------------------------------------- +// --------------------------------------------------------------------- -#[contract] -pub struct CalloraYield; -/// Per-account limits and counters contract for the Callora yield surface. +/// Callora Yield per-account limits enforcement contract. /// -/// See the module documentation for the auth model. Counter mutations are -/// restricted to the admin-configured operator contract. +/// `init` registers an `admin`; the admin (and only the admin) may configure +/// per-account caps and the global default caps. End-users may increment or +/// decrement their own counters via `place_bet`, `open_position`, +/// `subscribe`, `clear_bet`, `close_position`, `unsubscribe`. +#[contract] +pub struct CalloraYieldLimits; + #[contractimpl] impl CalloraYieldLimits { + // ----------------------------------------------------------------- + // init + lifecycle + // ----------------------------------------------------------------- + /// Initialize the contract with an admin address. /// -/// The admin is the only address allowed to configure limits and the -/// operator. The operator must be set separately via [`set_operator`] -/// before counter mutations are allowed. - pub fn init(env: Env, admin: Address) { - admin.require_auth(); + /// # Arguments + /// * `admin` — Address authorised to mutate per-account caps, swap + /// admins, upgrade the contract, and pause/unpause the surface. + /// + /// # Errors + /// - [`YieldLimitError::AlreadyInitialized`] — admin already set. + pub fn init(env: Env, admin: Address) -> Result<(), YieldLimitError> { + if env.storage().instance().has(&StorageKey::Admin) { + return Err(YieldLimitError::AlreadyInitialized); + } env.storage().instance().set(&StorageKey::Admin, &admin); env.storage() .instance() .extend_ttl(INSTANCE_BUMP_THRESHOLD, INSTANCE_BUMP_AMOUNT); + env.events().publish((events::event_init(&env), admin), ()); + Ok(()) } - /// Return the current admin address. + /// Return the stored admin address. + /// + /// # Errors + /// - [`YieldLimitError::NotInitialized`] — `init` has not been called. pub fn get_admin(env: Env) -> Result { - read_admin(&env) + let admin = read_admin(&env)?; + env.storage() + .instance() + .extend_ttl(INSTANCE_BUMP_THRESHOLD, INSTANCE_BUMP_AMOUNT); + Ok(admin) } - /// Return the currently configured operator, if any. - pub fn get_operator(env: Env) -> Result { - read_operator(&env) - } + // ----------------------------------------------------------------- + // Two-step admin rotation + // ----------------------------------------------------------------- - /// Set the operator contract authorized to mutate per-account counters. + /// Initiate a two-step admin transfer (`caller` must be current admin). /// -/// Only the admin may call this. Emits an event so off-chain monitors -/// can track the trust boundary. - pub fn set_operator( - env: Env, - caller: Address, - operator: Address, - ) -> Result<(), YieldLimitError> { + /// Re-nominating the current admin is permitted — once the pending + /// transfer is accepted the admin role remains effectively unchanged, + /// which is safe because every other admin-gated path still requires a + /// fresh `require_auth` round-trip through the nominated address. + pub fn set_admin(env: Env, caller: Address, new_admin: Address) -> Result<(), YieldLimitError> { require_admin(&env, &caller)?; - write_operator(&env, &operator); - events::operator_set(&env, &operator); - Ok(() + env.storage() + .instance() + .set(&StorageKey::PendingAdmin, &new_admin); + env.events() + .publish((events::event_admin_nominated(&env), caller), new_admin); + env.storage() + .instance() + .extend_ttl(INSTANCE_BUMP_THRESHOLD, INSTANCE_BUMP_AMOUNT); + Ok(()) } - /// Remove the configured operator. - /// -/// Only the admin may call this. After this call no address may mutate -/// counters until a new operator is configured. Emits an event. - pub fn clear_operator(env: Env, caller: Address) -> Result<(), YieldLimitError> { - require_admin(&env, &caller)?; - clear_operator_key(&env); - events::operator_cleared(&env); - Ok(() + /// Complete a pending admin transfer (`caller` must be the pending admin). + pub fn accept_admin(env: Env, caller: Address) -> Result<(), YieldLimitError> { + let pending = env + .storage() + .instance() + .get::<_, Address>(&StorageKey::PendingAdmin) + .ok_or(YieldLimitError::Unauthorized)?; + caller.require_auth(); + if caller != pending { + return Err(YieldLimitError::Unauthorized); + } + env.storage().instance().set(&StorageKey::Admin, &caller); + env.storage().instance().remove(&StorageKey::PendingAdmin); + env.events() + .publish((events::event_admin_accepted(&env), caller.clone()), ()); + env.storage() + .instance() + .extend_ttl(INSTANCE_BUMP_THRESHOLD, INSTANCE_BUMP_AMOUNT); + Ok(()) } - /// Return the global default caps. - pub fn get_default_limits(env: Env) -> AccountLimits { - read_default_limits(&env) + /// Cancel a pending admin transfer (`caller` must be current admin). + pub fn cancel_admin_transfer(env: Env, caller: Address) -> Result<(), YieldLimitError> { + require_admin(&env, &caller)?; + let pending = env + .storage() + .instance() + .get::<_, Address>(&StorageKey::PendingAdmin) + .ok_or(YieldLimitError::Unauthorized)?; + env.storage().instance().remove(&StorageKey::PendingAdmin); + env.events() + .publish((events::event_admin_cancelled(&env), caller), pending); + env.storage() + .instance() + .extend_ttl(INSTANCE_BUMP_THRESHOLD, INSTANCE_BUMP_AMOUNT); + Ok(()) } - /// Set the global default caps. Admin-only. + // ----------------------------------------------------------------- + // Limits configuration + // ----------------------------------------------------------------- + + /// Replace the global default caps (admin only). pub fn set_default_limits( env: Env, caller: Address, - caps: AccountLimits, + max_bets: u32, + max_positions: u32, + max_subscriptions: u32, ) -> Result<(), YieldLimitError> { require_admin(&env, &caller)?; + let caps = AccountLimits { + max_bets, + max_positions, + max_subscriptions, + }; write_default_limits(&env, &caps)?; - Ok(() - } - - /// Return the effective caps for an account. - pub fn get_account_limits(env: Env, account: Address) -> AccountLimits { - read_account_limits(&env, &account) + env.events().publish( + (events::event_default_limits_set(&env), caller, caps.clone()), + caps, + ); + Ok(()) } - /// Set a per-account cap override. Admin-only. + /// Set explicit per-account caps overriding the global default + /// (admin only). pub fn set_account_limits( env: Env, caller: Address, account: Address, - caps: AccountLimits, + max_bets: u32, + max_positions: u32, + max_subscriptions: u32, ) -> Result<(), YieldLimitError> { require_admin(&env, &caller)?; + let caps = AccountLimits { + max_bets, + max_positions, + max_subscriptions, + }; write_account_limits(&env, &account, &caps)?; - Ok() + env.events().publish( + ( + events::event_account_limits_set(&env), + caller, + account.clone(), + ), + caps, + ); + Ok(()) } - /// Clear a per-account cap override. Admin-only. + /// Remove any per-account override so the account falls back to global + /// defaults (admin only). pub fn clear_account_limits( env: Env, caller: Address, account: Address, ) -> Result<(), YieldLimitError> { require_admin(&env, &caller)?; - clear_account_limits_key(&env, &account); - Ok(() - } - - /// Return the live counters for an account. - pub fn get_account_state(env: Env, account: Address) -> AccountState { - read_account_state(&env, &account) - } - - /// Return `true` if the account may open another bet. - pub fn can_place_bet(env: Env, account: Address) -> bool { - let caps = read_account_limits(&env, &account); - let state = read_account_state(&env, &account); - state.bets < caps.max_bets - } - - /// Return `true` if the account may open another position. - pub fn can_open_position(env: Env, account: Address) -> bool { - let caps = read_account_limits(&env, &account); - let state = read_account_state(&env, &account); - state.positions < caps.max_positions + clear_account_limits(&env, &account); + env.events().publish( + (events::event_account_limits_cleared(&env), caller, account), + (), + ); + Ok(()) } - /// Return `true` if the account may open another subscription. - pub fn can_subscribe(env: Env, account: Address) -> bool { - let caps = read_account_limits(&env, &account); - let state = read_account_state(&env, &account); - state.subscriptions < caps.max_subscriptions - } + // ----------------------------------------------------------------- + // User-gated counter mutators + // ----------------------------------------------------------------- - /// Increment an account's bet counter. Operator-only. - /// -/// The account whose counter is mutated is passed as a parameter and -/// does not need to authorize the call. The operator is trusted to -/// attest to the account's state. - pub fn place_bet( - env: Env, - operator: Address, - account: Address, - ) -> Result<(), YieldLimitError> { - require_operator(&env, &operator)?; - let caps = read_account_limits(&env, &account); - let mut state = read_account_state(&env, &account); + /// Increment the caller's open-bet counter after enforcing auth and + /// the per-account cap. + pub fn place_bet(env: Env, caller: Address) -> Result<(), YieldLimitError> { + caller.require_auth(); + let caps = read_account_limits(&env, &caller); + let mut state = read_account_state(&env, &caller); + // Pre-check: surfacing BetsAtCap as a typed error gives callers a + // stable code to branch on rather than a generic panic. if state.bets >= caps.max_bets { - return Err(YieldLimitError::LimitExceeded); + return Err(YieldLimitError::BetsAtCap); } state.add_bet()?; - write_account_state(&env, &account, &state); - Ok() + write_account_state(&env, &caller, &state); + env.events().publish( + (events::event_bet_placed(&env), caller.clone()), + (state.bets, caps.max_bets), + ); + Ok(()) } - /// Decrement an account's bet counter. Operator-only. - pub fn clear_bet( - env: Env, - operator: Address, - account: Address, - ) -> Result<(), YieldLimitError> { - require_operator(&env, &operator)?; - let mut state = read_account_state(&env, &account); + /// Decrement the caller's open-bet counter after enforcing auth. + pub fn clear_bet(env: Env, caller: Address) -> Result<(), YieldLimitError> { + caller.require_auth(); + let mut state = read_account_state(&env, &caller); state.sub_bet()?; - write_account_state(&env, &account, &state); - Ok(() + write_account_state(&env, &caller, &state); + env.events().publish( + (events::event_bet_cleared(&env), caller.clone()), + state.bets, + ); + Ok(()) } - /// Increment an account's position counter. Operator-only. - pub fn open_position( - env: Env, - operator: Address, - account: Address, - ) -> Result<(), YieldLimitError> { - require_operator(&env, &operator)?; - let caps = read_account_limits(&env, &account); - let mut state = read_account_state(&env, &account); + /// Increment the caller's open-position counter after enforcing auth + /// and the per-account cap. + pub fn open_position(env: Env, caller: Address) -> Result<(), YieldLimitError> { + caller.require_auth(); + let caps = read_account_limits(&env, &caller); + let mut state = read_account_state(&env, &caller); if state.positions >= caps.max_positions { - return Err(YieldLimitError::LimitExceeded); + return Err(YieldLimitError::PositionsAtCap); } state.add_position()?; - write_account_state(&env, &account, &state); - Ok(() + write_account_state(&env, &caller, &state); + env.events().publish( + (events::event_position_opened(&env), caller.clone()), + (state.positions, caps.max_positions), + ); + Ok(()) } - /// Decrement an account's position counter. Operator-only. - pub fn close_position( - env: Env, - operator: Address, - account: Address, - ) -> Result<(), YieldLimitError> { - require_operator(&env, &operator)?; - let mut state = read_account_state(&env, &account); + /// Decrement the caller's open-position counter after enforcing auth. + pub fn close_position(env: Env, caller: Address) -> Result<(), YieldLimitError> { + caller.require_auth(); + let mut state = read_account_state(&env, &caller); state.sub_position()?; - write_account_state(&env, &account, &state); - Ok() + write_account_state(&env, &caller, &state); + env.events().publish( + (events::event_position_closed(&env), caller.clone()), + state.positions, + ); + Ok(()) } - /// Increment an account's subscription counter. Operator-only. - pub fn subscribe( - env: Env, - operator: Address, - account: Address, - ) -> Result<(), YieldLimitError> { - require_operator(&env, &operator)?; - let caps = read_account_limits(&env, &account); - let mut state = read_account_state(&env, &account); + /// Increment the caller's subscription counter after enforcing auth + /// and the per-account cap. + pub fn subscribe(env: Env, caller: Address) -> Result<(), YieldLimitError> { + caller.require_auth(); + let caps = read_account_limits(&env, &caller); + let mut state = read_account_state(&env, &caller); if state.subscriptions >= caps.max_subscriptions { - return Err(YieldLimitError::LimitExceeded); + return Err(YieldLimitError::SubscriptionsAtCap); } state.add_subscription()?; - write_account_state(&env, &account, &state); - Ok() + write_account_state(&env, &caller, &state); + env.events().publish( + (events::event_subscription_added(&env), caller.clone()), + (state.subscriptions, caps.max_subscriptions), + ); + Ok(()) } - /// Decrement an account's subscription counter. Operator-only. - pub fn unsubscribe( - env: Env, - operator: Address, - account: Address, - ) -> Result<(), YieldLimitError> { - require_operator(&env, &operator)?; - let mut state = read_account_state(&env, &account); + /// Decrement the caller's subscription counter after enforcing auth. + pub fn unsubscribe(env: Env, caller: Address) -> Result<(), YieldLimitError> { + caller.require_auth(); + let mut state = read_account_state(&env, &caller); state.sub_subscription()?; - write_account_state(&env, &account, &state); - Ok() + write_account_state(&env, &caller, &state); + env.events().publish( + (events::event_subscription_removed(&env), caller.clone()), + state.subscriptions, + ); + Ok(()) } -} -// ---------------------------------------------------------------------- -// Internal key helpers -// ---------------------------------------------------------------------- + // ----------------------------------------------------------------- + // Read-only views + // ----------------------------------------------------------------- -fn clear_operator_key(env: &Env) { - env.storage().instance().remove(&StorageKey::Operator); - env.storage() - .instance() - .extend_ttl(INSTANCE_BUMP_THRESHOLD, INSTANCE_BUMP_AMOUNT); -} + /// Read the global default caps. + pub fn get_default_limits(env: Env) -> AccountLimits { + read_default_limits(&env) + } -fn clear_account_limits_key(env: &Env, account: &Address) { - env.storage() - .instance() - .remove(&StorageKey::AccountLimits(account.clone())); - env.storage() - .instance() - .extend_ttl(INSTANCE_BUMP_THRESHOLD, INSTANCE_BUMP_AMOUNT); -} + /// Read the effective per-account caps. + pub fn get_account_limits(env: Env, account: Address) -> AccountLimits { + read_account_limits(&env, &account) + } -#[cfg](test)] -mod tests { - - use super::*; - use soroban_sdk:{Env, Address}; - - fn setup() -> (Env, Address, Address, Address) { - let env = Env::default(); - let admin = Address::generate(&env); - let operator = Address::generate(&env); - let account = Address::generate(&env); - env.mock_all_auths(); - CalloraYieldLimits::init(env.clone(), admin.clone()); - CalloraYieldLimits::set_operator( - env.clone(), - admin.clone(), - operator.clone(), - ) - .unwrap(); - (env, admin, operator, account) - } - - #[test] - fn operator_can_increment_and_decrement_bets() { - let (env, _admin, operator, account) = setup(); - CalloraYieldLimits::place_bet( - env.clone(), - operator.clone(), - account.clone(), - ) - .unwrap(); - let state = CalloraYield::get_account_state(env.clone(), account.clone()); - assert_eq(state.bets, 1); - CalloraYield::clear_bet( - env.clone(), - operator.clone(), - account.clone(), - ) - .unwrap(); - let state = CalloraYield::get_account_state(env.clone(), account.clone()); - assert_eq(state.bets, 0); - } - - #[test] - fn account_cannot_reset_own_bet_count() { - let (env, _admin, operator, account) = setup(); - CalloraYield::place_bet( - env.clone(), - operator.clone(), - account.clone(), - ) - .unwrap(); - // The account tries to clear its own counter by acting as the operator. - // This must fail because the configured operator is a different - // address. - let result = CalloraYield::clear_bet( - env.clone(), - account.clone(), - account.clone(), - ); - assert_eq(result, Err(YieldLimitError::Unauthorized)); - let state = CalloraYield::get_account_state(env.clone(), account.clone()); - assert_eq(state.bets, 1); - } - - #[test] - fn account_cannot_reset_own_position_count() { - let (env, _admin, operator, account) = setup(); - CalloraYield::open_position( - env.clone(), - operator.clone(), - account.clone(), - ) - .unwrap(); - let result = CalloraYield::close_position( - env.clone(), - account.clone(), - account.clone(), - ); - assert_eq(result, Err(YieldLimitError::Unauthorized)); - let state = CalloraYield::get_account_state(env.clone(), account.clone()); - assert_eq(state.positions, 1); - } - - #[test] - fn account_cannot_reset_own_subscription_count() { - let (env, _admin, operator, account) = setup(); - CalloraYield::subscribe( - env.clone(), - operator.clone(), - account.clone(), - ) - .unwrap(); - let result = CalloraYield::unsubscribe( - env.clone(), - account.clone(), - account.clone(), - ); - assert_eq(result, Err(YieldLimitError::Unauthorized)); - let state = CalloraYield::get_account_state(env.clone(), account.clone()); - assert_eq(state.subscriptions, 1); - } - - #[test] - fn non_operator_cannot_increment_counters() { - let (env, _admin, _operator, account) = setup(); - let stranger = Address::generate(&env); - let result = CalloraYield::place_bet( - env.clone(), - stranger.clone(), - account.clone(), - ); - assert_eq(result, Err(YieldLimitError::Unauthorized)); - } - - #[test] - fn operator_not_set_rejects_mutations() { - let env = Env::default(); - let admin = Address::generate(&env); - let account = Address::generate(&env); - env.mock_all_auths(); - CalloraYieldLimits::init(env.clone(), admin.clone()); - let result = CalloraYield::place_bet( - env.clone(), - admin.clone(), - account.clone(), - ); - assert_eq(result, Err(YieldLimitError::OperatorNotSet)); - } - - #[test] - fn admin_can_change_operator() { - let (env, admin, _operator, account) = setup(); - let new_operator = Address::generate(&env); - CalloraYield::set_operator( - env.clone(), - admin.clone(), - new_operator.clone(), - ) - .unwrap(); - CalloraYield::place_bet( - env.clone(), - new_operator.clone(), - account.clone(), - ) - .unwrap(); - let state = CalloraYield::get_account_state(env.clone(), account.clone()); - assert_eq(state.bets, 1); - } - - #[test] - fn non_admin_cannot_set_operator() { - let (env, _admin, _operator, account) = setup(); - let result = CalloraYield::set_operator( - env.clone(), - account.clone(), - account.clone(), - ); - assert_eq(result, Err(YieldLimitError::Unauthorized)); - } - - #[test] - fn caps_are_enforced_on_increment() { - let (env, admin, operator, account) = setup(); - CalloraYield::set_account_limits( - env.clone(), - admin.clone(), - account.clone(), - AccountLimits::uniform(1), - ) - .unwrap(); - CalloraYield::place_bet( - env.clone(), - operator.clone(), - account.clone(), - ) - .unwrap(); - let result = CalloraYield::place_bet( - env.clone(), - operator.clone(), - account.clone(), - ); - assert_eq(result, Err(YieldLimitError::LimitExceeded)); + /// Read the live per-account counters. + pub fn get_account_state(env: Env, account: Address) -> AccountState { + read_account_state(&env, &account) } - #[test] - fn clear_operator_blocks_mutations() { - let (env, admin, operator, account) = setup(); - CalloraYield::clear_operator(env.clone(), admin.clone()).unwrap(); - let result = CalloraYield::place_bet( - env.clone(), - operator.clone(), - account.clone(), - ); - assert_eq(result, Err(YieldLimitError::OperatorNotSet)); + /// Dry-run check: would `place_bet` succeed for `account`? + pub fn can_place_bet(env: Env, account: Address) -> bool { + can_place_bet(&env, &account) } - #[test] - fn get_operator_returns_configured_address() { - let (env, _admin, operator, _account) = setup(); - let returned = CalloraYield::get_operator(env.clone()).unwrap(); - assert_eq(returned, operator); + /// Dry-run check: would `open_position` succeed for `account`? + pub fn can_open_position(env: Env, account: Address) -> bool { + can_open_position(&env, &account) + } + + /// Dry-run check: would `subscribe` succeed for `account`? + pub fn can_subscribe(env: Env, account: Address) -> bool { + can_subscribe(&env, &account) + } + + // ----------------------------------------------------------------- + // Upgrade + // ----------------------------------------------------------------- + + /// Replace the WASM and persist the new hash (admin only). + pub fn upgrade( + env: Env, + caller: Address, + new_wasm_hash: BytesN<32>, + ) -> Result<(), YieldLimitError> { + require_admin(&env, &caller)?; + env.deployer() + .update_current_contract_wasm(new_wasm_hash.clone()); + env.events() + .publish((events::event_upgraded(&env), caller), new_wasm_hash); + Ok(()) } } diff --git a/contracts/yield/src/test_limits.rs b/contracts/yield/src/test_limits.rs index fc55e25e..8ce7585d 100644 --- a/contracts/yield/src/test_limits.rs +++ b/contracts/yield/src/test_limits.rs @@ -270,14 +270,13 @@ fn place_bet_increments_then_clear_decrements() { let env = Env::default(); let (_, admin, client, alice) = setup_with_user(&env); client.set_account_limits(&admin, &alice, &5u32, &5u32, &5u32); - let operator = Address::generate(&env); client.place_bet(&alice); client.place_bet(&alice); let state = client.get_account_state(&alice); assert_eq!(state.bets, 2); - client.clear_bet(&operator, &alice); + client.clear_bet(&alice); let state = client.get_account_state(&alice); assert_eq!(state.bets, 1); } @@ -287,7 +286,6 @@ fn place_bet_respects_per_account_cap() { let env = Env::default(); let (_, admin, client, alice) = setup_with_user(&env); client.set_account_limits(&admin, &alice, &3u32, &3u32, &3u32); - let operator = Address::generate(&env); client.place_bet(&alice); client.place_bet(&alice); @@ -298,7 +296,6 @@ fn place_bet_respects_per_account_cap() { ); let state = client.get_account_state(&alice); assert_eq!(state.bets, 3, "failing call must not increment counter"); - let _ = operator; } #[test] @@ -306,7 +303,6 @@ fn open_position_respects_per_account_cap() { let env = Env::default(); let (_, admin, client, alice) = setup_with_user(&env); client.set_account_limits(&admin, &alice, &3u32, &2u32, &3u32); - let operator = Address::generate(&env); client.open_position(&alice); client.open_position(&alice); @@ -316,7 +312,6 @@ fn open_position_respects_per_account_cap() { ); let state = client.get_account_state(&alice); assert_eq!(state.positions, 2); - let _ = operator; } #[test] @@ -324,7 +319,6 @@ fn subscribe_respects_per_account_cap() { let env = Env::default(); let (_, admin, client, alice) = setup_with_user(&env); client.set_account_limits(&admin, &alice, &3u32, &3u32, &2u32); - let operator = Address::generate(&env); client.subscribe(&alice); client.subscribe(&alice); @@ -334,16 +328,14 @@ fn subscribe_respects_per_account_cap() { ); let state = client.get_account_state(&alice); assert_eq!(state.subscriptions, 2); - let _ = operator; } #[test] fn clear_bet_underflow_returns_typed_error() { let env = Env::default(); let (_, _, client, alice) = setup_with_user(&env); - let operator = Address::generate(&env); assert_eq!( - client.try_clear_bet(&operator, &alice), + client.try_clear_bet(&alice), Err(Ok(YieldLimitError::CounterUnderflow)) ); } @@ -352,9 +344,8 @@ fn clear_bet_underflow_returns_typed_error() { fn close_position_underflow_returns_typed_error() { let env = Env::default(); let (_, _, client, alice) = setup_with_user(&env); - let operator = Address::generate(&env); assert_eq!( - client.try_close_position(&operator, &alice), + client.try_close_position(&alice), Err(Ok(YieldLimitError::CounterUnderflow)) ); } @@ -363,9 +354,8 @@ fn close_position_underflow_returns_typed_error() { fn unsubscribe_underflow_returns_typed_error() { let env = Env::default(); let (_, _, client, alice) = setup_with_user(&env); - let operator = Address::generate(&env); assert_eq!( - client.try_unsubscribe(&operator, &alice), + client.try_unsubscribe(&alice), Err(Ok(YieldLimitError::CounterUnderflow)) ); } @@ -376,7 +366,6 @@ fn state_independent_across_accounts() { let (_, admin, client) = setup_admin(&env); let alice = Address::generate(&env); let bob = Address::generate(&env); - let operator = Address::generate(&env); client.set_account_limits(&admin, &alice, &2u32, &2u32, &2u32); client.set_account_limits(&admin, &bob, &4u32, &4u32, &4u32); @@ -388,7 +377,6 @@ fn state_independent_across_accounts() { client.place_bet(&bob); assert_eq!(client.get_account_state(&alice).bets, 2); assert_eq!(client.get_account_state(&bob).bets, 4); - let _ = operator; } #[test] @@ -396,7 +384,6 @@ fn place_bet_with_cap_zero_rejects() { let env = Env::default(); let (_, admin, client, alice) = setup_with_user(&env); client.set_account_limits(&admin, &alice, &0u32, &0u32, &0u32); - let operator = Address::generate(&env); assert_eq!( client.try_place_bet(&alice), Err(Ok(YieldLimitError::BetsAtCap)) @@ -409,7 +396,6 @@ fn place_bet_with_cap_zero_rejects() { client.try_subscribe(&alice), Err(Ok(YieldLimitError::SubscriptionsAtCap)) ); - let _ = operator; } // ---------------------------------------------------------------------