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
40 changes: 39 additions & 1 deletion EVENT_SCHEMA.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ All inline `Symbol::new(&env, "...")` event topic literals have been extracted f

- [`contracts/vault/src/events.rs`](contracts/vault/src/events.rs) — 23 topics
- [`contracts/settlement/src/events.rs`](contracts/settlement/src/events.rs) — 8 topics
- [`contracts/revenue_pool/src/events.rs`](contracts/revenue_pool/src/events.rs) — 10 topics
- [`contracts/revenue_pool/src/events.rs`](contracts/revenue_pool/src/events.rs) — 12 topics

Each module exports one `pub fn event_*(&env) -> Symbol` function per topic and includes
a `#[cfg(test)]` snapshot block asserting byte-level identity to the original literal.
Expand Down Expand Up @@ -565,6 +565,44 @@ Emitted when the nominee accepts the admin role (step 2 of 2).

---

### `pause_guardian_set`

Emitted when the admin sets or replaces the emergency pause guardian.

| Index | Location | Type | Description |
|---------|----------|---------|------------------------------------------|
| topic 0 | topics | Symbol | `"pause_guardian_set"` |
| topic 1 | topics | Address | `caller` — current admin |
| data | data | Address | `guardian` — address allowed to pause |

```json
{
"topics": ["pause_guardian_set", "GADMIN..."],
"data": "GGUARDIAN..."
}
```

---

### `pause_guardian_cleared`

Emitted when the admin clears the emergency pause guardian role.

| Index | Location | Type | Description |
|---------|----------|---------|------------------------------------------|
| topic 0 | topics | Symbol | `"pause_guardian_cleared"` |
| topic 1 | topics | Address | `caller` — current admin |
| data | data | Address | previous guardian address |

```json
{
"topics": ["pause_guardian_cleared", "GADMIN..."],
"data": "GOLD_GUARDIAN..."
}
```

---

### `receive_payment`

Emitted when the admin logs an inbound payment from the vault.
Expand Down
4 changes: 3 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,9 @@ The Revenue Pool contract (`contracts/revenue_pool`) operates under the followin
- **Excessive Single-Leg Distribution:** A compromised admin could still try to distribute a huge amount in a single `distribute()` or individual `batch_distribute` leg, increasing the blast radius for a compromised admin key.
- *Mitigation:* `callora-revenue-pool` now exposes a configurable `max_distribute` cap. Every `distribute` and every individual `batch_distribute` payment leg is validated against this cap. The cap is admin-gated, must be positive, and defaults to `i128::MAX` until configured.

- **Emergency Pause Delegation:** The admin can configure a `pause_guardian` for operational emergencies where the pool needs to be stopped quickly without sharing full admin power.
- *Mitigation:* `pause_guardian` is scoped to `pause` only. It cannot unpause, distribute funds, rotate admin, update caps, clear or replace itself, or upgrade the contract. `set_pause_guardian` and `clear_pause_guardian` are admin-only and emit dedicated events for monitoring.

### Input Validation

- [ ] All amounts validated to be > 0
Expand Down Expand Up @@ -266,4 +269,3 @@ As part of the authorization matrix hardening for the `callora-settlement` contr
- Comprehensive negative tests have been added to `contracts/settlement/src/test.rs` covering `receive_payment`, `set_admin`, `set_vault`, and `get_all_developer_balances`.
- Overflow regression tests now assert `receive_payment` panics with `"pool balance overflow"` and `"developer balance overflow"` when credits would exceed `i128::MAX`.
- Admin rotation (two-step) has been verified to correctly gate access during the transition period.

54 changes: 50 additions & 4 deletions contracts/revenue_pool/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,20 @@ pub fn event_admin_cancelled(env: &Env) -> Symbol {
Symbol::new(env, "admin_cancelled")
}

/// Returns the Symbol for the `"pause_guardian_set"` event topic.
///
/// Emitted when the admin sets or replaces the emergency pause guardian.
pub fn event_pause_guardian_set(env: &Env) -> Symbol {
Symbol::new(env, "pause_guardian_set")
}

/// Returns the Symbol for the `"pause_guardian_cleared"` event topic.
///
/// Emitted when the admin clears the emergency pause guardian.
pub fn event_pause_guardian_cleared(env: &Env) -> Symbol {
Symbol::new(env, "pause_guardian_cleared")
}

/// Returns the Symbol for the `"pause_set"` event topic.
///
/// Emitted by both `pause` (with data `true`) and `unpause` (with data `false`)
Expand Down Expand Up @@ -105,7 +119,10 @@ mod tests {
#[test]
fn test_event_admin_changed_bytes() {
let env = Env::default();
assert_eq!(event_admin_changed(&env), Symbol::new(&env, "admin_changed"));
assert_eq!(
event_admin_changed(&env),
Symbol::new(&env, "admin_changed")
);
}

/// Snapshot: proves event_admin_transfer_started still maps to exactly the bytes for "admin_transfer_started".
Expand Down Expand Up @@ -138,6 +155,26 @@ mod tests {
);
}

/// Snapshot: proves event_pause_guardian_set still maps to exactly the bytes for "pause_guardian_set".
#[test]
fn test_event_pause_guardian_set_bytes() {
let env = Env::default();
assert_eq!(
event_pause_guardian_set(&env),
Symbol::new(&env, "pause_guardian_set")
);
}

/// Snapshot: proves event_pause_guardian_cleared still maps to exactly the bytes for "pause_guardian_cleared".
#[test]
fn test_event_pause_guardian_cleared_bytes() {
let env = Env::default();
assert_eq!(
event_pause_guardian_cleared(&env),
Symbol::new(&env, "pause_guardian_cleared")
);
}

/// Snapshot: proves event_pause_set still maps to exactly the bytes for "pause_set".
#[test]
fn test_event_pause_set_bytes() {
Expand All @@ -149,14 +186,20 @@ mod tests {
#[test]
fn test_event_receive_payment_bytes() {
let env = Env::default();
assert_eq!(event_receive_payment(&env), Symbol::new(&env, "receive_payment"));
assert_eq!(
event_receive_payment(&env),
Symbol::new(&env, "receive_payment")
);
}

/// Snapshot: proves event_set_max_distribute still maps to exactly the bytes for "set_max_distribute".
#[test]
fn test_event_set_max_distribute_bytes() {
let env = Env::default();
assert_eq!(event_set_max_distribute(&env), Symbol::new(&env, "set_max_distribute"));
assert_eq!(
event_set_max_distribute(&env),
Symbol::new(&env, "set_max_distribute")
);
}

/// Snapshot: proves event_distribute still maps to exactly the bytes for "distribute".
Expand All @@ -170,7 +213,10 @@ mod tests {
#[test]
fn test_event_batch_distribute_bytes() {
let env = Env::default();
assert_eq!(event_batch_distribute(&env), Symbol::new(&env, "batch_distribute"));
assert_eq!(
event_batch_distribute(&env),
Symbol::new(&env, "batch_distribute")
);
}

/// Snapshot: proves event_upgraded still maps to exactly the bytes for "upgraded".
Expand Down
96 changes: 88 additions & 8 deletions contracts/revenue_pool/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@ use soroban_sdk::{
/// For detailed threat models and mitigations, see [`SECURITY.md`](../../SECURITY.md).
const ADMIN_KEY: &str = "admin";
const PENDING_ADMIN_KEY: &str = "pending_admin";
const PAUSE_GUARDIAN_KEY: &str = "pause_guardian";
const USDC_KEY: &str = "usdc";
const MAX_DISTRIBUTE_KEY: &str = "max_distribute";
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_UNAUTHORIZED_PAUSE: &str = "unauthorized: caller is not admin or pause guardian";
const ERR_INSUFFICIENT_BALANCE: &str = "insufficient USDC balance";
const ERR_NOT_INITIALIZED: &str = "revenue pool not initialized";
const ERR_DUPLICATE_RECIPIENT: &str = "duplicate recipient in batch";
Expand Down Expand Up @@ -229,10 +231,8 @@ impl RevenuePool {
inst.remove(&Symbol::new(&env, PENDING_ADMIN_KEY));
inst.extend_ttl(LIFETIME_THRESHOLD, BUMP_AMOUNT);

env.events().publish(
(events::event_admin_cancelled(&env), current, pending),
(),
);
env.events()
.publish((events::event_admin_cancelled(&env), current, pending), ());
}

/// Return the pending admin address, or `None` if no transfer is in progress.
Expand All @@ -242,6 +242,78 @@ impl RevenuePool {
.get(&Symbol::new(&env, PENDING_ADMIN_KEY))
}

/// Set or replace the emergency pause guardian.
///
/// The guardian may call `pause` but has no authority to unpause, distribute,
/// rotate admin, change caps, or upgrade the contract.
///
/// # Arguments
/// * `caller` - Must be the current admin; must authorize.
/// * `guardian` - Address that may pause the revenue pool.
///
/// # Panics
/// * If the caller is not the current admin.
///
/// # Events
/// Emits `pause_guardian_set` with `caller` as topic and `guardian` as data.
pub fn set_pause_guardian(env: Env, caller: Address, guardian: Address) {
caller.require_auth();
let admin = Self::get_admin(env.clone());
if caller != admin {
panic!("{}", ERR_UNAUTHORIZED);
}

env.storage()
.instance()
.set(&Symbol::new(&env, PAUSE_GUARDIAN_KEY), &guardian);
env.storage()
.instance()
.extend_ttl(LIFETIME_THRESHOLD, BUMP_AMOUNT);
env.events()
.publish((events::event_pause_guardian_set(&env), caller), guardian);
}

/// Clear the emergency pause guardian role.
///
/// Only the current admin may call this. After clearing, only the admin can
/// pause the revenue pool.
///
/// # Arguments
/// * `caller` - Must be the current admin; must authorize.
///
/// # Panics
/// * If the caller is not the current admin.
/// * If no pause guardian is configured.
///
/// # Events
/// Emits `pause_guardian_cleared` with `caller` as topic and the previous
/// guardian as data.
pub fn clear_pause_guardian(env: Env, caller: Address) {
caller.require_auth();
let admin = Self::get_admin(env.clone());
if caller != admin {
panic!("{}", ERR_UNAUTHORIZED);
}

let inst = env.storage().instance();
let guardian: Address = inst
.get(&Symbol::new(&env, PAUSE_GUARDIAN_KEY))
.expect("no pause guardian set");
inst.remove(&Symbol::new(&env, PAUSE_GUARDIAN_KEY));
inst.extend_ttl(LIFETIME_THRESHOLD, BUMP_AMOUNT);
env.events().publish(
(events::event_pause_guardian_cleared(&env), caller),
guardian,
);
}

/// Return the configured pause guardian, or `None` if no guardian is set.
pub fn get_pause_guardian(env: Env) -> Option<Address> {
env.storage()
.instance()
.get(&Symbol::new(&env, PAUSE_GUARDIAN_KEY))
}

fn require_not_paused(env: &Env) {
if env
.storage()
Expand All @@ -255,24 +327,29 @@ impl RevenuePool {

/// Pause the revenue pool, blocking `distribute` and `batch_distribute`.
///
/// Only the admin may call. Admin rotation remains available while paused.
/// The admin or configured pause guardian may call. Admin rotation remains
/// available while paused.
///
/// # Panics
/// * If the caller is not the current admin.
/// * If the caller is not the current admin or configured pause guardian.
/// * If the pool is already paused.
///
/// # Events
/// Emits a `pause_set` event with `caller` as a topic and `true` as data.
pub fn pause(env: Env, caller: Address) {
caller.require_auth();
let admin = Self::get_admin(env.clone());
if caller != admin {
panic!("{}", ERR_UNAUTHORIZED);
let guardian = Self::get_pause_guardian(env.clone());
if caller != admin && guardian.as_ref() != Some(&caller) {
panic!("{}", ERR_UNAUTHORIZED_PAUSE);
}
assert!(!Self::is_paused(env.clone()), "revenue pool already paused");
env.storage()
.instance()
.set(&Symbol::new(&env, PAUSED_KEY), &true);
env.storage()
.instance()
.extend_ttl(LIFETIME_THRESHOLD, BUMP_AMOUNT);
env.events()
.publish((events::event_pause_set(&env), caller), true);
}
Expand All @@ -297,6 +374,9 @@ impl RevenuePool {
env.storage()
.instance()
.set(&Symbol::new(&env, PAUSED_KEY), &false);
env.storage()
.instance()
.extend_ttl(LIFETIME_THRESHOLD, BUMP_AMOUNT);
env.events()
.publish((events::event_pause_set(&env), caller), false);
}
Expand Down
Loading