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
61 changes: 60 additions & 1 deletion contracts/donation-vault/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -45,6 +45,7 @@ pub enum DataKey {
Paused,
Treasury,
FeeBps,
TokenAllowlist,
}

#[contracterror]
Expand All @@ -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
Expand Down Expand Up @@ -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<Vec<Address>> = 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.
Expand Down Expand Up @@ -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<Address> = 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<Vec<Address>> = env.storage().instance().get(&DataKey::TokenAllowlist);
let Some(list) = allowlist else {
return Ok(());
};

let filtered: Vec<Address> = 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.
///
Expand Down Expand Up @@ -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);
Expand Down
49 changes: 49 additions & 0 deletions contracts/donation-vault/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading