Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/multi-period-revenue-deposit.md
Original file line number Diff line number Diff line change
Expand Up @@ -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₁
Expand Down
90 changes: 88 additions & 2 deletions src/revenue_deposit_contract.rs
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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<u32>).
Expand Down Expand Up @@ -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]
Expand All @@ -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::<Address>::new(&env));
env.storage().persistent().set(&DataKey::PeriodCounter, &0u32);
env.storage().persistent().set(&DataKey::PeriodIds, &Vec::<u32>::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<Address> = 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<Address> = 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<Address> = 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,
Expand Down Expand Up @@ -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<Address> {
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<u32, i128> {
let ids: Vec<u32> =
Expand Down
44 changes: 44 additions & 0 deletions src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading