Skip to content
Merged
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
26 changes: 26 additions & 0 deletions EVENT_SCHEMA.md
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,30 @@ Emitted when the admin updates the per-leg distribution cap.

---

### `set_min_pool_balance`

Emitted when the admin updates the minimum pool balance floor (Issue #416).

| Index | Location | Type | Description |
|---------|----------|--------------|--------------------------------------|
| topic 0 | topics | Symbol | `"set_min_pool_balance"` |
| topic 1 | topics | Address | admin address |
| data | data | (i128, i128) | `(old_min, new_min)` in USDC stroops |

```json
{
"topics": ["set_min_pool_balance", "GADMIN..."],
"data": [0, 50000000]
}
```

> **Security note:** After this event, any `distribute` or `batch_distribute`
> call that would leave the pool below `new_min` will abort with
> `MinPoolBalanceBreached` before any transfer occurs. Set `new_min` to `0`
> to restore the default (no floor).

---

### `batch_distribute`

Emitted **once per payment** during a `batch_distribute()` call. If a batch has
Expand Down Expand Up @@ -897,6 +921,7 @@ operational edge cases (off-chain payment reconciliation, dispute resolution).
| `admin_transfer_completed`| revenue-pool | `claim_admin()` |
| `receive_payment` | revenue-pool | `receive_payment()` |
| `distribute` | revenue-pool | `distribute()` |
| `set_min_pool_balance` | revenue-pool | `set_min_pool_balance()` |
| `batch_distribute` | revenue-pool | each payment in `batch_distribute()` |
| `payment_received` | settlement | `receive_payment()` |
| `balance_credited` | settlement | `receive_payment()` with `to_pool=false` |
Expand All @@ -914,5 +939,6 @@ operational edge cases (off-chain payment reconciliation, dispute resolution).
| 0.0.1 | vault | Added `metadata_removed` event on `remove_metadata()` for stale-entry cleanup |
| 0.0.1 | revenue-pool | Full revenue pool event suite with JSON examples |
| 0.0.1 | revenue-pool | Added `admin_changed` event on `set_admin` for explicit old/new admin intent |
| 0.0.1 | revenue-pool | Added `set_min_pool_balance` event; floor enforcement on `distribute` + `batch_distribute` (Issue #416) |
| 0.1.0 | settlement | `payment_received`, `balance_credited` |
| 0.1.0 | settlement | `developer_force_credited` (admin escape hatch) |
79 changes: 77 additions & 2 deletions contracts/revenue_pool/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,15 @@ const ADMIN_KEY: &str = "admin";
const PENDING_ADMIN_KEY: &str = "pending_admin";
const USDC_KEY: &str = "usdc";
const MAX_DISTRIBUTE_KEY: &str = "max_distribute";
/// Storage key for the minimum pool balance floor (Issue #416).
const MIN_POOL_BALANCE_KEY: &str = "min_pool_bal";
const ERR_AMOUNT_NOT_POSITIVE: &str = "amount must be positive";
const ERR_AMOUNT_EXCEEDS_MAX_DISTRIBUTE: &str = "amount exceeds max_distribute";
const ERR_UNAUTHORIZED: &str = "unauthorized: caller is not admin";
const ERR_INSUFFICIENT_BALANCE: &str = "insufficient USDC balance";
/// Emitted when a distribute/batch_distribute call would reduce the pool
/// balance below the configured `min_pool_balance` floor.
const ERR_MIN_POOL_BALANCE_BREACHED: &str = "MinPoolBalanceBreached";
const ERR_NOT_INITIALIZED: &str = "revenue pool not initialized";
const ERR_DUPLICATE_RECIPIENT: &str = "duplicate recipient in batch";
const PAUSED_KEY: &str = "paused";
Expand Down Expand Up @@ -332,6 +337,61 @@ impl RevenuePool {
);
}

// -----------------------------------------------------------------------
// Minimum pool balance floor (Issue #416)
// -----------------------------------------------------------------------

/// Return the current minimum pool balance floor.
///
/// The pool must retain at least this many USDC base units after every
/// `distribute` or `batch_distribute` call. Defaults to `0` when not set,
/// which preserves the original behaviour of allowing a full drain.
pub fn get_min_pool_balance(env: Env) -> i128 {
env.storage()
.instance()
.get(&Symbol::new(&env, MIN_POOL_BALANCE_KEY))
.unwrap_or(0_i128)
}

/// Set the minimum pool balance floor.
///
/// After this call, every `distribute` and `batch_distribute` call will
/// abort with `MinPoolBalanceBreached` if the resulting pool balance would
/// fall below `amount`.
///
/// # Arguments
/// * `env` - The environment running the contract.
/// * `caller` - Must be the current admin.
/// * `amount` - New floor in USDC base units. Must be `>= 0`.
/// Pass `0` to restore the default (no floor).
///
/// # Panics
/// * If the caller is not the current admin (`"unauthorized: caller is not admin"`).
/// * If `amount` is negative (`"min_pool_balance must not be negative"`).
///
/// # Events
/// Emits `set_min_pool_balance` with `caller` as a topic and
/// `(old_min, new_min)` as data.
pub fn set_min_pool_balance(env: Env, caller: Address, amount: i128) {
caller.require_auth();
let admin = Self::get_admin(env.clone());
if caller != admin {
panic!("{}", ERR_UNAUTHORIZED);
}
assert!(amount >= 0, "min_pool_balance must not be negative");
let old_min = Self::get_min_pool_balance(env.clone());
env.storage()
.instance()
.set(&Symbol::new(&env, MIN_POOL_BALANCE_KEY), &amount);
env.storage()
.instance()
.extend_ttl(LIFETIME_THRESHOLD, BUMP_AMOUNT);
env.events().publish(
(Symbol::new(&env, "set_min_pool_balance"), admin),
(old_min, amount),
);
}

fn validate_recipient(recipient: &Address, contract_self: &Address) {
// Rule 1 — no self-distributions (the contract sending to itself is almost
// certainly a logic bug; if you want to "reclaim" funds use a dedicated fn).
Expand Down Expand Up @@ -390,10 +450,17 @@ impl RevenuePool {
)
});

if usdc.balance(&contract_address) < amount {
let current_balance = usdc.balance(&contract_address);
if current_balance < amount {
panic!("{}", ERR_INSUFFICIENT_BALANCE);
}

// Floor check: pool must not drop below min_pool_balance after transfer.
let floor = Self::get_min_pool_balance(env.clone());
if current_balance - amount < floor {
panic!("{}", ERR_MIN_POOL_BALANCE_BREACHED);
}

env.storage()
.instance()
.extend_ttl(LIFETIME_THRESHOLD, BUMP_AMOUNT);
Expand Down Expand Up @@ -543,10 +610,18 @@ impl RevenuePool {
let usdc = token::Client::new(&env, &usdc_address);
let contract_address = env.current_contract_address();

if usdc.balance(&contract_address) < total_amount {
let current_balance = usdc.balance(&contract_address);
if current_balance < total_amount {
panic!("{}", ERR_INSUFFICIENT_BALANCE);
}

// Floor check: the entire batch is atomic, so one pre-flight check suffices.
// The pool must not drop below min_pool_balance after all transfers complete.
let floor = Self::get_min_pool_balance(env.clone());
if current_balance - total_amount < floor {
panic!("{}", ERR_MIN_POOL_BALANCE_BREACHED);
}

// Extend TTL before executing transfers.
env.storage()
.instance()
Expand Down
204 changes: 203 additions & 1 deletion contracts/revenue_pool/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,9 @@ fn create_usdc<'a>(
client.pause(&admin);
assert!(client.is_paused());

let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| client.distribute(&admin, &developer, &100)));
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
client.distribute(&admin, &developer, &100)
}));
assert!(result.is_err());
}

Expand Down Expand Up @@ -2422,3 +2424,203 @@ fn batch_distribute_duplicate_detected_before_balance_check() {

client.batch_distribute(&admin, &payments);
}

// ---------------------------------------------------------------------------
// Issue #416 — min_pool_balance floor tests
// ---------------------------------------------------------------------------

#[test]
#[should_panic(expected = "unauthorized: caller is not admin")]
fn set_min_pool_balance_admin_only() {
// A non-admin caller must be rejected before any state change.
let env = Env::default();
env.mock_all_auths();
let admin = Address::generate(&env);
let attacker = Address::generate(&env);
let (_, client) = create_pool(&env);
let (usdc, _, _) = create_usdc(&env, &admin);

client.init(&admin, &usdc);
client.set_min_pool_balance(&attacker, &500);
}

#[test]
#[should_panic(expected = "min_pool_balance must not be negative")]
fn set_min_pool_balance_negative_panics() {
// Negative floor values are nonsensical and must be rejected.
let env = Env::default();
env.mock_all_auths();
let admin = Address::generate(&env);
let (_, client) = create_pool(&env);
let (usdc, _, _) = create_usdc(&env, &admin);

client.init(&admin, &usdc);
client.set_min_pool_balance(&admin, &-1);
}

#[test]
fn set_min_pool_balance_stores_value_and_getter_returns_it() {
// Setter persists the value; getter reads it back correctly.
// Also verifies the default is 0 (no floor) before any setter call.
let env = Env::default();
env.mock_all_auths();
let admin = Address::generate(&env);
let (_, client) = create_pool(&env);
let (usdc, _, _) = create_usdc(&env, &admin);

client.init(&admin, &usdc);

// Default floor must be 0.
assert_eq!(client.get_min_pool_balance(), 0);

client.set_min_pool_balance(&admin, &1_000);
assert_eq!(client.get_min_pool_balance(), 1_000);

// Admin can lower it back to 0.
client.set_min_pool_balance(&admin, &0);
assert_eq!(client.get_min_pool_balance(), 0);
}

#[test]
fn set_min_pool_balance_emits_correct_event() {
// Event must carry topics (symbol, admin) and data (old_min, new_min).
let env = Env::default();
env.mock_all_auths();
let admin = Address::generate(&env);
let (_, client) = create_pool(&env);
let (usdc, _, _) = create_usdc(&env, &admin);

client.init(&admin, &usdc);
client.set_min_pool_balance(&admin, &2_500);

let events = env.events().all();
let ev = events.last().unwrap();

// topic 0 = "set_min_pool_balance"
let t0 = Symbol::try_from_val(&env, &ev.1.get(0).unwrap()).unwrap();
assert_eq!(t0, Symbol::new(&env, "set_min_pool_balance"));

// topic 1 = admin address
let t1 = Address::try_from_val(&env, &ev.1.get(1).unwrap()).unwrap();
assert_eq!(t1, admin);

// data = (old_min, new_min)
let data: (i128, i128) = ev.2.into_val(&env);
assert_eq!(data, (0_i128, 2_500_i128));
}

#[test]
#[should_panic(expected = "MinPoolBalanceBreached")]
fn distribute_breaches_floor_panics() {
// distribute must abort when the resulting balance would be below the floor.
// Pool: 1_000, floor: 600, request: 500 → balance after = 500 < 600 → breach.
let env = Env::default();
env.mock_all_auths();
let admin = Address::generate(&env);
let developer = Address::generate(&env);
let (pool_addr, client) = create_pool(&env);
let (usdc_address, _, usdc_admin) = create_usdc(&env, &admin);

client.init(&admin, &usdc_address);
fund_pool(&usdc_admin, &pool_addr, 1_000);
client.set_min_pool_balance(&admin, &600);

// 1000 - 500 = 500 < 600 → MinPoolBalanceBreached
client.distribute(&admin, &developer, &500);
}

#[test]
fn distribute_exact_floor_succeeds() {
// Distributing exactly (balance - floor) must succeed (equal is allowed).
// Pool: 1_000, floor: 600, request: 400 → balance after = 600 = floor → OK.
let env = Env::default();
env.mock_all_auths();
let admin = Address::generate(&env);
let developer = Address::generate(&env);
let (pool_addr, client) = create_pool(&env);
let (usdc_address, usdc_client, usdc_admin) = create_usdc(&env, &admin);

client.init(&admin, &usdc_address);
fund_pool(&usdc_admin, &pool_addr, 1_000);
client.set_min_pool_balance(&admin, &600);

// 1000 - 400 = 600 = floor → should succeed
client.distribute(&admin, &developer, &400);

assert_eq!(usdc_client.balance(&pool_addr), 600);
assert_eq!(usdc_client.balance(&developer), 400);
}

#[test]
#[should_panic(expected = "MinPoolBalanceBreached")]
fn batch_distribute_breaches_floor_panics() {
// batch_distribute must abort when the total payout would breach the floor.
// Pool: 1_000, floor: 400, batch total: 700 → balance after = 300 < 400 → breach.
let env = Env::default();
env.mock_all_auths();
let admin = Address::generate(&env);
let dev1 = Address::generate(&env);
let dev2 = Address::generate(&env);
let (pool_addr, client) = create_pool(&env);
let (usdc_address, _, usdc_admin) = create_usdc(&env, &admin);

client.init(&admin, &usdc_address);
fund_pool(&usdc_admin, &pool_addr, 1_000);
client.set_min_pool_balance(&admin, &400);

let mut payments: Vec<(Address, i128)> = Vec::new(&env);
payments.push_back((dev1.clone(), 400_i128));
payments.push_back((dev2.clone(), 300_i128)); // total 700; 1000 - 700 = 300 < 400

client.batch_distribute(&admin, &payments);
}

#[test]
fn batch_distribute_exact_floor_succeeds() {
// A batch that leaves the pool balance exactly at the floor must succeed.
// Pool: 1_000, floor: 400, batch total: 600 → balance after = 400 = floor → OK.
let env = Env::default();
env.mock_all_auths();
let admin = Address::generate(&env);
let dev1 = Address::generate(&env);
let dev2 = Address::generate(&env);
let (pool_addr, client) = create_pool(&env);
let (usdc_address, usdc_client, usdc_admin) = create_usdc(&env, &admin);

client.init(&admin, &usdc_address);
fund_pool(&usdc_admin, &pool_addr, 1_000);
client.set_min_pool_balance(&admin, &400);

let mut payments: Vec<(Address, i128)> = Vec::new(&env);
payments.push_back((dev1.clone(), 350_i128));
payments.push_back((dev2.clone(), 250_i128)); // total 600; 1000 - 600 = 400 = floor

client.batch_distribute(&admin, &payments);

assert_eq!(usdc_client.balance(&pool_addr), 400);
assert_eq!(usdc_client.balance(&dev1), 350);
assert_eq!(usdc_client.balance(&dev2), 250);
}

#[test]
fn floor_zero_default_allows_full_drain() {
// With the default floor of 0, distributing the entire balance must succeed,
// preserving full backwards-compatibility for callers that never set a floor.
let env = Env::default();
env.mock_all_auths();
let admin = Address::generate(&env);
let developer = Address::generate(&env);
let (pool_addr, client) = create_pool(&env);
let (usdc_address, usdc_client, usdc_admin) = create_usdc(&env, &admin);

client.init(&admin, &usdc_address);
fund_pool(&usdc_admin, &pool_addr, 500);

// No set_min_pool_balance call — default floor is 0.
assert_eq!(client.get_min_pool_balance(), 0);

client.distribute(&admin, &developer, &500); // full drain

assert_eq!(usdc_client.balance(&pool_addr), 0);
assert_eq!(usdc_client.balance(&developer), 500);
}
Loading