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
45 changes: 44 additions & 1 deletion EVENT_SCHEMA.md
Original file line number Diff line number Diff line change
Expand Up @@ -784,6 +784,46 @@ is persisted to instance storage and is queryable via `get_version()`.
> version is stored; calling `upgrade()` again overwrites the previous value.
---


### `treasury_transfer_started`

Emitted when the admin nominates a new treasury via `set_treasury()`. The nominee
must call `accept_treasury()` before it becomes authorized to call `deposit_yield()`.

| Index | Location | Type | Description |
|---------|----------|---------|----------------------------------------------|
| topic 0 | topics | Symbol | `"treasury_transfer_started"` |
| topic 1 | topics | Address | `caller` -- current admin that nominated the treasury |
| data[0] | data | Address | `old_treasury` -- currently active treasury |
| data[1] | data | Address | `new_treasury` -- nominated treasury |

---

### `treasury_transfer_completed`

Emitted when the pending treasury accepts the nomination via `accept_treasury()`.

| Index | Location | Type | Description |
|---------|----------|---------|----------------------------------------------|
| topic 0 | topics | Symbol | `"treasury_transfer_completed"` |
| topic 1 | topics | Address | `new_treasury` -- accepting treasury |
| data | data | Address | `old_treasury` -- previously active treasury |

---

### `treasury_cancelled`

Emitted when the admin cancels a pending treasury nomination via
`cancel_treasury_transfer()`.

| Index | Location | Type | Description |
|---------|----------|---------|----------------------------------------------|
| topic 0 | topics | Symbol | `"treasury_cancelled"` |
| topic 1 | topics | Address | `caller` -- current admin that cancelled |
| data | data | Address | `pending_treasury` -- cancelled nominee |

---

### `yield_deposited`

Emitted when the treasury deposits accumulated protocol yield into the revenue pool
Expand All @@ -792,7 +832,7 @@ via `deposit_yield()`. The cumulative tracker is updated atomically with the tra
| Index | Location | Type | Description |
|---------|----------|---------|--------------------------------------------------------|
| topic 0 | topics | Symbol | `"yield_deposited"` |
| topic 1 | topics | Address | `treasury` -- current admin who called `deposit_yield` |
| topic 1 | topics | Address | `treasury` -- configured treasury that called `deposit_yield` |
| data[0] | data | i128 | `amount` -- USDC deposited in this call (stroops) |
| data[1] | data | Symbol | `source` -- short label, e.g. `"fees"` or `"yield"` |
| data[2] | data | i128 | `cumulative_yield_deposited` -- running total after deposit |
Expand Down Expand Up @@ -1092,6 +1132,9 @@ operational edge cases (off-chain payment reconciliation, dispute resolution).
| `pause_set` | revenue-pool | `pause()` / `unpause()` |
| `admin_cancelled` | revenue-pool | `cancel_admin_transfer()` |
| `upgraded` | revenue-pool | `upgrade()` |
| `treasury_transfer_started` | revenue-pool | `set_treasury()` |
| `treasury_transfer_completed` | revenue-pool | `accept_treasury()` |
| `treasury_cancelled` | revenue-pool | `cancel_treasury_transfer()` |
| `yield_deposited` | revenue-pool | `deposit_yield()` |
| `admin_broadcast` | revenue-pool | `broadcast()` |
| `payment_received` | settlement | `receive_payment()` |
Expand Down
47 changes: 46 additions & 1 deletion contracts/revenue_pool/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,21 @@ pub fn event_yield_deposited(env: &Env) -> Symbol {
Symbol::new(env, "yield_deposited")
}

/// Returns the Symbol for the `"treasury_transfer_started"` event topic.
pub fn event_treasury_transfer_started(env: &Env) -> Symbol {
Symbol::new(env, "treasury_transfer_started")
}

/// Returns the Symbol for the `"treasury_transfer_completed"` event topic.
pub fn event_treasury_transfer_completed(env: &Env) -> Symbol {
Symbol::new(env, "treasury_transfer_completed")
}

/// Returns the Symbol for the `"treasury_cancelled"` event topic.
pub fn event_treasury_cancelled(env: &Env) -> Symbol {
Symbol::new(env, "treasury_cancelled")
}

/// Returns the Symbol for the `"set_max_distribute"` event topic.
///
/// Emitted when the admin updates the per-leg maximum distribute cap.
Expand Down Expand Up @@ -207,6 +222,33 @@ mod tests {
);
}

#[test]
fn test_event_treasury_transfer_started_bytes() {
let env = Env::default();
assert_eq!(
event_treasury_transfer_started(&env),
Symbol::new(&env, "treasury_transfer_started")
);
}

#[test]
fn test_event_treasury_transfer_completed_bytes() {
let env = Env::default();
assert_eq!(
event_treasury_transfer_completed(&env),
Symbol::new(&env, "treasury_transfer_completed")
);
}

#[test]
fn test_event_treasury_cancelled_bytes() {
let env = Env::default();
assert_eq!(
event_treasury_cancelled(&env),
Symbol::new(&env, "treasury_cancelled")
);
}

/// Snapshot: proves event_set_max_distribute still maps to exactly the bytes for "set_max_distribute".
#[test]
fn test_event_set_max_distribute_bytes() {
Expand Down Expand Up @@ -245,6 +287,9 @@ mod tests {
#[test]
fn test_event_admin_broadcast_bytes() {
let env = Env::default();
assert_eq!(event_admin_broadcast(&env), Symbol::new(&env, "admin_broadcast"));
assert_eq!(
event_admin_broadcast(&env),
Symbol::new(&env, "admin_broadcast")
);
}
}
107 changes: 97 additions & 10 deletions contracts/revenue_pool/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
#![no_std]

#[cfg(test)]
use soroban_sdk::testutils::storage::Instance;
use soroban_sdk::{
contract, contracterror, contractimpl, contracttype, token, Address, BytesN, Env, Map, String, Symbol, Vec,
contract, contracterror, contractimpl, contracttype, token, Address, BytesN, Env, Map, String,
Symbol, Vec,
};

/// Revenue settlement contract: receives USDC from vault deducts and distributes to developers.
Expand All @@ -17,6 +20,8 @@ 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 TREASURY_KEY: &str = "treasury";
const PENDING_TREASURY_KEY: &str = "pending_treasury";
const PAUSE_GUARDIAN_KEY: &str = "pause_guardian";
const USDC_KEY: &str = "usdc";
const MAX_DISTRIBUTE_KEY: &str = "max_distribute";
Expand Down Expand Up @@ -85,7 +90,6 @@ pub struct StorageEntryTtl {
pub bump_amount: u32,
}


/// TTL bump constants for instance storage archival risk mitigation.
/// Soroban archives ledger entries after ~7 days (631 ledgers) of inactivity.
/// Bumping TTL ensures state remains accessible for critical operations.
Expand Down Expand Up @@ -126,6 +130,7 @@ impl RevenuePool {
panic!("revenue pool already initialized");
}
inst.set(&Symbol::new(&env, ADMIN_KEY), &admin);
inst.set(&Symbol::new(&env, TREASURY_KEY), &admin);
inst.set(&Symbol::new(&env, USDC_KEY), &usdc_token);

// Extend TTL on initialization to prevent archival
Expand Down Expand Up @@ -187,6 +192,89 @@ impl RevenuePool {
);
}

/// Return the current treasury address authorized to deposit yield.
///
/// Existing deployments that predate the dedicated treasury key fall back
/// to the admin address, preserving the original behavior where the admin
/// acted as treasury.
pub fn get_treasury(env: Env) -> Address {
env.storage()
.instance()
.get(&Symbol::new(&env, TREASURY_KEY))
.unwrap_or_else(|| Self::get_admin(env))
}

/// Return the pending treasury address, or `None` if no transfer is pending.
pub fn get_pending_treasury(env: Env) -> Option<Address> {
env.storage()
.instance()
.get(&Symbol::new(&env, PENDING_TREASURY_KEY))
}

/// Initiate a two-step treasury rotation.
///
/// Only the current admin may nominate a new treasury. The nominee must call
/// `accept_treasury` before it can deposit yield.
pub fn set_treasury(env: Env, caller: Address, new_treasury: Address) {
caller.require_auth();
let admin = Self::get_admin(env.clone());
if caller != admin {
panic!("{}", ERR_UNAUTHORIZED);
}
let current = Self::get_treasury(env.clone());
if new_treasury == current {
panic!("new treasury is already current treasury");
}

let inst = env.storage().instance();
inst.set(&Symbol::new(&env, PENDING_TREASURY_KEY), &new_treasury);
inst.extend_ttl(LIFETIME_THRESHOLD, BUMP_AMOUNT);
env.events().publish(
(events::event_treasury_transfer_started(&env), caller),
(current, new_treasury),
);
}

/// Accept a pending treasury nomination.
///
/// Only the pending treasury may complete the rotation.
pub fn accept_treasury(env: Env, caller: Address) {
caller.require_auth();
let inst = env.storage().instance();
let pending: Address = inst
.get(&Symbol::new(&env, PENDING_TREASURY_KEY))
.expect("no pending treasury");
if caller != pending {
panic!("unauthorized: caller is not pending treasury");
}

let old = Self::get_treasury(env.clone());
inst.set(&Symbol::new(&env, TREASURY_KEY), &pending);
inst.remove(&Symbol::new(&env, PENDING_TREASURY_KEY));
inst.extend_ttl(LIFETIME_THRESHOLD, BUMP_AMOUNT);
env.events().publish(
(events::event_treasury_transfer_completed(&env), pending),
old,
);
}

/// Cancel a pending treasury rotation. Only the current admin may call this.
pub fn cancel_treasury_transfer(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 pending: Address = inst
.get(&Symbol::new(&env, PENDING_TREASURY_KEY))
.expect("no treasury transfer pending");
inst.remove(&Symbol::new(&env, PENDING_TREASURY_KEY));
inst.extend_ttl(LIFETIME_THRESHOLD, BUMP_AMOUNT);
env.events()
.publish((events::event_treasury_cancelled(&env), caller), pending);
}

/// Return the USDC token address configured for this pool.
///
/// # Returns
Expand Down Expand Up @@ -465,19 +553,19 @@ impl RevenuePool {

/// Deposit accumulated protocol yield into the revenue pool.
///
/// The current admin acts as the treasury authority. The treasury must
/// authorize the call, and USDC is transferred from that treasury address to
/// The configured treasury must authorize the call, and USDC is transferred
/// from that treasury address to
/// this revenue-pool contract. The cumulative deposited-yield metric is
/// updated atomically with the transfer and event emission.
///
/// # Arguments
/// * `env` - The environment running the contract.
/// * `treasury` - Must be the current admin and must authorize the call.
/// * `treasury` - Must be the configured treasury and must authorize the call.
/// * `amount` - USDC amount in base units. Must be positive.
/// * `source` - Short source label for indexers, e.g. `fees` or `yield`.
///
/// # Panics
/// * If `treasury` is not the current admin (`"unauthorized: caller is not admin"`).
/// * If `treasury` is not the configured treasury (`"unauthorized: caller is not treasury"`).
/// * If `amount` is zero or negative (`"amount must be positive"`).
/// * If the cumulative metric would overflow (`"cumulative yield overflow"`).
/// * If the revenue pool has not been initialized.
Expand All @@ -487,9 +575,9 @@ impl RevenuePool {
/// `(amount, source, cumulative_yield_deposited)` as data.
pub fn deposit_yield(env: Env, treasury: Address, amount: i128, source: Symbol) {
treasury.require_auth();
let admin = Self::get_admin(env.clone());
if treasury != admin {
panic!("{}", ERR_UNAUTHORIZED);
let current_treasury = Self::get_treasury(env.clone());
if treasury != current_treasury {
panic!("unauthorized: caller is not treasury");
}
if amount <= 0 {
panic!("{}", ERR_AMOUNT_NOT_POSITIVE);
Expand Down Expand Up @@ -918,7 +1006,6 @@ impl RevenuePool {
}
}


mod events;
/// Split `payments` into consecutive chunks of at most `chunk_size` legs each,
/// preserving order.
Expand Down
Loading
Loading