diff --git a/contracts/donation-vault/src/lib.rs b/contracts/donation-vault/src/lib.rs index f8e7774..02987c1 100644 --- a/contracts/donation-vault/src/lib.rs +++ b/contracts/donation-vault/src/lib.rs @@ -8,7 +8,7 @@ #![allow(deprecated)] use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, symbol_short, token, Address, Env, + contract, contracterror, contractimpl, contracttype, symbol_short, token, Address, Env, Vec, }; mod math; @@ -45,6 +45,7 @@ pub enum DataKey { Paused, Treasury, FeeBps, + TokenAllowlist, } #[contracterror] @@ -63,6 +64,7 @@ pub enum Error { /// leave its type's range. Returned instead of letting the release /// profile's overflow checks panic and abort the transaction. ArithmeticOverflow = 9, + TokenNotAllowed = 10, } /// Fee cap of 10%, enforced by `set_fee_bps` so the admin can never take @@ -127,6 +129,14 @@ fn require_not_paused(env: &Env) -> Result<(), Error> { Ok(()) } +fn token_is_allowed(env: &Env, token: &Address) -> bool { + let allowlist: Option> = env.storage().instance().get(&DataKey::TokenAllowlist); + match allowlist { + Some(list) if !list.is_empty() => list.iter().any(|allowed| allowed == token.clone()), + _ => true, + } +} + /// Moves `amount` from a stream's `balance` into its `withdrawn` total, /// failing with `Error::ArithmeticOverflow` rather than panicking if /// either would leave i128's range. @@ -668,6 +678,52 @@ impl DonationVault { env.storage().instance().get(&DataKey::FeeBps).unwrap_or(0) } + /// Adds a token to the optional admin-managed allowlist. Once any token + /// is allowlisted, only those tokens can be used to open streams. + pub fn allow_token(env: Env, token: Address) -> Result<(), Error> { + require_admin(&env)?; + + let mut allowlist: Vec
= env + .storage() + .instance() + .get(&DataKey::TokenAllowlist) + .unwrap_or_else(|| Vec::new(&env)); + if !allowlist.iter().any(|allowed| allowed == token.clone()) { + allowlist.push_back(token); + } + + env.storage().instance().set(&DataKey::TokenAllowlist, &allowlist); + extend_instance_ttl(&env); + Ok(()) + } + + /// Removes a token from the optional allowlist. When the final entry is + /// removed, the allowlist is empty and stream creation reverts to the + /// original unrestricted behavior. + pub fn disallow_token(env: Env, token: Address) -> Result<(), Error> { + require_admin(&env)?; + + let allowlist: Option> = env.storage().instance().get(&DataKey::TokenAllowlist); + let Some(list) = allowlist else { + return Ok(()); + }; + + let filtered: Vec
= list + .iter() + .filter(|allowed| allowed != &&token) + .collect(); + + env.storage().instance().set(&DataKey::TokenAllowlist, &filtered); + extend_instance_ttl(&env); + Ok(()) + } + + /// Whether a token is accepted for new streams. Empty allowlists behave as + /// unrestricted, because there is no enforced gate at all. + pub fn is_token_allowed(env: Env, token: Address) -> bool { + token_is_allowed(&env, &token) + } + /// Opens a new stream: pulls `deposit` of `token` from the donor into the /// vault, to be released to the NGO at `rate` per second on withdrawal. /// @@ -706,6 +762,9 @@ impl DonationVault { if deposit <= 0 || rate <= 0 { return Err(Error::InvalidAmount); } + if !token_is_allowed(&env, &token) { + return Err(Error::TokenNotAllowed); + } let token_client = token::Client::new(&env, &token); token_client.transfer(&donor, env.current_contract_address(), &deposit); diff --git a/contracts/donation-vault/src/test.rs b/contracts/donation-vault/src/test.rs index ea537c7..387b18c 100644 --- a/contracts/donation-vault/src/test.rs +++ b/contracts/donation-vault/src/test.rs @@ -208,6 +208,55 @@ fn create_stream_rejects_non_positive_amounts() { assert_eq!(result, Err(Ok(Error::InvalidAmount))); } +#[test] +fn empty_allowlist_keeps_stream_creation_unrestricted() { + let s = setup(); + s.token_admin.mint(&s.donor, &1_000); + + assert!(s.client.is_token_allowed(&s.token.address)); + + let stream_id = s + .client + .create_stream(&s.donor, &s.ngo, &s.token.address, &1_000, &10); + assert_eq!(stream_id, 0); +} + +#[test] +fn populated_allowlist_enforces_token_gate_and_can_be_cleared() { + let s = setup(); + let token_issuer_2 = Address::generate(&s.env); + let (other_token, other_token_admin) = create_token(&s.env, &token_issuer_2); + s.token_admin.mint(&s.donor, &1_000); + other_token_admin.mint(&s.donor, &1_000); + + s.client.allow_token(&s.token.address); + + assert!(s.client.is_token_allowed(&s.token.address)); + assert!(!s.client.is_token_allowed(&other_token.address)); + + let result = s.client.try_create_stream( + &s.donor, + &s.ngo, + &other_token.address, + &1_000, + &10, + ); + assert_eq!(result, Err(Ok(Error::TokenNotAllowed))); + + let stream_id = s + .client + .create_stream(&s.donor, &s.ngo, &s.token.address, &1_000, &10); + assert_eq!(stream_id, 0); + + s.client.disallow_token(&s.token.address); + assert!(s.client.is_token_allowed(&s.token.address)); + + let stream_id = s + .client + .create_stream(&s.donor, &s.ngo, &s.token.address, &1_000, &10); + assert_eq!(stream_id, 1); +} + #[test] fn propose_then_accept_admin_transfers_control() { let s = setup();