diff --git a/docs/multi-period-revenue-deposit.md b/docs/multi-period-revenue-deposit.md index 960b4b92..48dcff74 100644 --- a/docs/multi-period-revenue-deposit.md +++ b/docs/multi-period-revenue-deposit.md @@ -13,7 +13,9 @@ The Multi-Period Revenue Deposit feature allows a privileged **admin** to deposi token revenue into the smart contract segmented across non-overlapping **periods**. Each period is defined by a ledger-based time window. After a period closes, registered **beneficiaries** may each claim their pro-rata share of that period's -deposited revenue. +deposited revenue. Deposits are now gated by an admin-managed allowlist of +authorized offering contract addresses so that only approved callers can fund a +period. ``` Admin ──deposit──► Contract ──claim──► Beneficiary₁ diff --git a/src/revenue_deposit_contract.rs b/src/revenue_deposit_contract.rs index 2798600b..c7a06b11 100644 --- a/src/revenue_deposit_contract.rs +++ b/src/revenue_deposit_contract.rs @@ -1,6 +1,6 @@ use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, token::Client as TokenClient, Address, - Env, Map, Vec, + contract, contracterror, contractimpl, contracttype, symbol_short, token::Client as TokenClient, + Address, Env, Map, Vec, }; /// Top-level storage keys stored in persistent contract storage. @@ -11,6 +11,8 @@ pub enum DataKey { Admin, /// The token contract ID used for all deposits and claims. Token, + /// Authorized offering contract addresses allowed to deposit revenue. + AuthorizedOfferings, /// Counter tracking the next period ID to be assigned. PeriodCounter, /// All registered period IDs (Vec). @@ -66,6 +68,8 @@ pub enum ContractError { Overflow = 10, /// No beneficiaries are registered; nothing to distribute. NoBeneficiaries = 11, + /// The caller is not an authorized offering contract. + UnauthorizedDepositor = 12, } #[contract] @@ -82,12 +86,86 @@ impl RevenueDepositContract { env.storage().persistent().set(&DataKey::Admin, &admin); env.storage().persistent().set(&DataKey::Token, &token); + env.storage().persistent().set(&DataKey::AuthorizedOfferings, &Vec::
::new(&env)); env.storage().persistent().set(&DataKey::PeriodCounter, &0u32); env.storage().persistent().set(&DataKey::PeriodIds, &Vec::::new(&env)); Ok(()) } + /// Authorize `offering` to deposit revenue into this contract. + pub fn add_authorized_offering(env: Env, offering: Address) -> Result<(), ContractError> { + let admin: Address = env.storage().persistent().get(&DataKey::Admin).unwrap(); + admin.require_auth(); + + let mut offerings: Vec
= env + .storage() + .persistent() + .get(&DataKey::AuthorizedOfferings) + .unwrap_or_else(|| Vec::new(&env)); + + if !offerings.contains(&offering) { + offerings.push_back(offering.clone()); + env.storage().persistent().set(&DataKey::AuthorizedOfferings, &offerings); + } + + env.events().publish(&symbol_short!("dep_allow_add"), &offering); + Ok(()) + } + + /// Remove `offering` from the authorized depositors allowlist. + pub fn remove_authorized_offering(env: Env, offering: Address) -> Result<(), ContractError> { + let admin: Address = env.storage().persistent().get(&DataKey::Admin).unwrap(); + admin.require_auth(); + + let mut offerings: Vec
= env + .storage() + .persistent() + .get(&DataKey::AuthorizedOfferings) + .unwrap_or_else(|| Vec::new(&env)); + + let pos = offerings.iter().position(|entry| entry == offering).ok_or(ContractError::UnauthorizedDepositor)?; + offerings.remove(pos as u32); + env.storage().persistent().set(&DataKey::AuthorizedOfferings, &offerings); + + env.events().publish(&symbol_short!("dep_allow_rm"), &offering); + Ok(()) + } + + /// Deposit `amount` of the configured token into `period_id` from an authorized offering. + pub fn deposit(env: Env, caller: Address, period_id: u32, amount: i128) -> Result<(), ContractError> { + let offerings: Vec
= env + .storage() + .persistent() + .get(&DataKey::AuthorizedOfferings) + .unwrap_or_else(|| Vec::new(&env)); + + if !offerings.contains(&caller) { + return Err(ContractError::UnauthorizedDepositor); + } + + caller.require_auth(); + + if amount <= 0 { + return Err(ContractError::InvalidInput); + } + + let mut period: Period = env + .storage() + .persistent() + .get(&DataKey::Period(period_id)) + .ok_or(ContractError::PeriodNotFound)?; + + period.revenue_amount = period.revenue_amount.checked_add(amount).ok_or(ContractError::Overflow)?; + env.storage().persistent().set(&DataKey::Period(period_id), &period); + + let token: Address = env.storage().persistent().get(&DataKey::Token).unwrap(); + let token_client = TokenClient::new(&env, &token); + token_client.transfer(&caller, &env.current_contract_address(), &amount); + + Ok(()) + } + /// Create a new revenue period and transfer `revenue_amount` tokens from the admin. pub fn create_period( env: Env, @@ -272,6 +350,14 @@ impl RevenueDepositContract { env.storage().persistent().get(&DataKey::Token).unwrap() } + /// Return the list of authorized offering contract addresses. + pub fn get_authorized_offerings(env: Env) -> Vec
{ + env.storage() + .persistent() + .get(&DataKey::AuthorizedOfferings) + .unwrap_or_else(|| Vec::new(&env)) + } + /// Build a summary map of unclaimed amounts per period. pub fn unclaimed_summary(env: Env) -> Map { let ids: Vec = diff --git a/src/test.rs b/src/test.rs index 380a8ada..7dfcc78d 100644 --- a/src/test.rs +++ b/src/test.rs @@ -77,6 +77,50 @@ fn test_initialize_rejects_double_init() { assert_eq!(result, Err(Ok(ContractError::AlreadyInitialized))); } +#[test] +fn test_deposit_rejects_unauthorized_offering() { + let (env, contract_id, token_id, _admin) = setup(); + let client = RevenueDepositContractClient::new(&env, &contract_id); + let unauthorized = Address::generate(&env); + let period_id = client.create_period(&100u32, &200u32, &10_000i128); + + crate::test_utils::mint_tokens(&env, &token_id, &unauthorized, 1_000_000); + + let result = client.try_deposit(&unauthorized, &period_id, &5_000i128); + assert_eq!(result, Err(Ok(ContractError::UnauthorizedDepositor))); +} + +#[test] +fn test_deposit_accepts_authorized_offering() { + let (env, contract_id, token_id, _admin) = setup(); + let client = RevenueDepositContractClient::new(&env, &contract_id); + let offering = Address::generate(&env); + let period_id = client.create_period(&100u32, &200u32, &10_000i128); + + crate::test_utils::mint_tokens(&env, &token_id, &offering, 1_000_000); + client.add_authorized_offering(&offering); + + let result = client.try_deposit(&offering, &period_id, &5_000i128); + assert_eq!(result, Ok(Ok(()))); + + let period = client.get_period(&period_id); + assert_eq!(period.revenue_amount, 15_000); + assert_eq!(crate::test_utils::get_balance(&env, &token_id, &contract_id), 15_000); +} + +#[test] +fn test_empty_authorized_offering_set_rejects_all_deposits() { + let (env, contract_id, token_id, _admin) = setup(); + let client = RevenueDepositContractClient::new(&env, &contract_id); + let offering = Address::generate(&env); + let period_id = client.create_period(&100u32, &200u32, &10_000i128); + + crate::test_utils::mint_tokens(&env, &token_id, &offering, 1_000_000); + + let result = client.try_deposit(&offering, &period_id, &5_000i128); + assert_eq!(result, Err(Ok(ContractError::UnauthorizedDepositor))); +} + // ─── 2. Period creation ─────────────────────────────────────────────────────── #[test]