diff --git a/contracts/governor/src/events.rs b/contracts/governor/src/events.rs index 14cfabc0..084c4aae 100644 --- a/contracts/governor/src/events.rs +++ b/contracts/governor/src/events.rs @@ -1,4 +1,4 @@ -use soroban_sdk::{symbol_short, Address, Env}; +use soroban_sdk::{symbol_short, Address, Env, Symbol}; use crate::Role; @@ -72,6 +72,14 @@ pub fn propose_authority(env: &Env, caller: &Address, new_authority: &Address) { ); } +/// Emitted when an `Admin` cancels a pending authority proposal. +pub fn revoke_propose_authority(env: &Env, caller: &Address, pending_authority: &Address) { + env.events().publish( + (Symbol::new(env, "authority_proposal_revoked"), caller.clone()), + pending_authority.clone(), + ); +} + /// Emitted when the pending authority accepts the transfer. /// /// # Indexer & Context Note diff --git a/contracts/governor/src/lib.rs b/contracts/governor/src/lib.rs index 68d6b44e..4232296d 100644 --- a/contracts/governor/src/lib.rs +++ b/contracts/governor/src/lib.rs @@ -30,7 +30,7 @@ use drip_common::is_zero_address; pub use config::GovernorConfig; pub use errors::Error; -pub use role::Role; +pub use role::{Role, RoleMembersPage}; use storage::DataKey; pub use storage::{Proposal, ProposalStatus}; @@ -112,6 +112,16 @@ impl DripGovernor { config::load(&env) } + /// Current protocol fee recipient, without fetching the full config. + pub fn fee_recipient(env: Env) -> Result { + Ok(config::load(&env)?.fee_recipient) + } + + /// Current protocol fee in basis points, without fetching the full config. + pub fn fee_bps(env: Env) -> Result { + Ok(config::load(&env)?.fee_bps) + } + /// Read-only: current minimum stream duration in seconds. /// /// Focused accessor for callers that only need this one field, avoiding @@ -131,13 +141,17 @@ impl DripGovernor { role::has_role(&env, role, &account) } - /// Returns every account currently holding `role`. + /// Returns a page of accounts holding `role`, plus the total member count. /// - /// Enables on-chain role-membership auditing without replaying every - /// `grant_role`/`revoke_role` event from genesis. The index is maintained - /// automatically by `grant_role` and `revoke_role`. - pub fn role_members(env: Env, role: Role) -> Vec
{ - role::role_members(&env, role) + /// The page is capped at 100 accounts. The index is maintained by the + /// single-account and batch role mutation methods. + pub fn role_members( + env: Env, + role: Role, + offset: u32, + limit: u32, + ) -> RoleMembersPage { + role::role_members(&env, role, offset, limit) } /// Current maximum stream duration in seconds, without fetching the full @@ -313,6 +327,45 @@ impl DripGovernor { Ok(()) } + /// Grants `role` to each account. Only an `Admin` may call this. + /// + /// Emits the same per-account event as `grant_role`, only for new grants. + pub fn grant_role_batch( + env: Env, + caller: Address, + role: Role, + accounts: Vec
, + ) -> Result<(), Error> { + role::require_role(&env, &caller, Role::Admin)?; + for index in 0..accounts.len() { + let account = accounts.get(index).unwrap(); + if role::grant(&env, role, &account) { + events::grant_role(&env, &caller, role, &account); + } + } + Ok(()) + } + + /// Revokes `role` from each account. Only an `Admin` may call this. + /// + /// Preserves `revoke_role` idempotency and last-admin protection, and + /// emits one event for each account whose role was actually removed. + pub fn revoke_role_batch( + env: Env, + caller: Address, + role: Role, + accounts: Vec
, + ) -> Result<(), Error> { + role::require_role(&env, &caller, Role::Admin)?; + for index in 0..accounts.len() { + let account = accounts.get(index).unwrap(); + if role::revoke(&env, role, &account)? { + events::revoke_role(&env, &caller, role, &account); + } + } + Ok(()) + } + /// Hands the full `Admin` role from `caller` to `new_authority`. /// /// Grants first so the subsequent revoke can never trip the `LastAdmin` @@ -369,6 +422,19 @@ impl DripGovernor { Ok(()) } + /// Cancels the pending authority transfer. Only an `Admin` may call this. + pub fn revoke_propose_authority(env: Env, caller: Address) -> Result<(), Error> { + role::require_role(&env, &caller, Role::Admin)?; + let storage = env.storage().instance(); + let pending: Address = storage + .get(&DataKey::PendingAuthority) + .ok_or(Error::NoPendingAuthority)?; + storage.remove(&DataKey::PendingAuthority); + storage.remove(&DataKey::PendingAuthorityProposer); + events::revoke_propose_authority(&env, &caller, &pending); + Ok(()) + } + /// Accept the pending authority transfer (step 2 of 2). /// /// Must be called by the pending authority address itself. This completes diff --git a/contracts/governor/src/role.rs b/contracts/governor/src/role.rs index 1d3e9b57..192f1bed 100644 --- a/contracts/governor/src/role.rs +++ b/contracts/governor/src/role.rs @@ -1,4 +1,4 @@ -use soroban_sdk::{Address, Env, Vec as SorobanVec}; +use soroban_sdk::{contracttype, Address, Env, Vec}; use drip_common::rbac; @@ -9,6 +9,16 @@ use crate::Error; /// Re-export so callers using `role::Role` continue to work unchanged. pub use crate::storage::Role; +/// A page of role members, with the total count for pagination. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RoleMembersPage { + pub members: Vec
, + pub total: u32, +} + +const MAX_ROLE_MEMBERS_PAGE_SIZE: u32 = 100; + // ── Key helpers ──────────────────────────────────────────────────────────── fn role_key(role: Role, account: &Address) -> DataKey { @@ -105,12 +115,22 @@ pub fn require_role(env: &Env, caller: &Address, role: Role) -> Result<(), Error .map_err(|_| Error::NotAuthorized) } -/// Returns every account currently holding `role`. +/// Returns a page of accounts currently holding `role`. /// /// Reads from the `RoleMembers` index maintained by `grant`/`revoke`. -/// Returns an empty vector if no accounts hold the role. -pub fn role_members(env: &Env, role: Role) -> SorobanVec
{ - rbac::role_members(env, &members_key(role)) +/// The result is capped at 100 members and includes the total member count. +pub fn role_members(env: &Env, role: Role, offset: u32, limit: u32) -> RoleMembersPage { + let all_members = rbac::role_members(env, &members_key(role)); + let total = all_members.len(); + let start = offset.min(total); + let end = start + .saturating_add(limit.min(MAX_ROLE_MEMBERS_PAGE_SIZE)) + .min(total); + let mut members = Vec::new(env); + for index in start..end { + members.push_back(all_members.get(index).unwrap()); + } + RoleMembersPage { members, total } } /// Requires that `caller` authorized the transaction and holds `role` **or** diff --git a/contracts/stream/src/lib.rs b/contracts/stream/src/lib.rs index 7d11b4df..4da88a9c 100644 --- a/contracts/stream/src/lib.rs +++ b/contracts/stream/src/lib.rs @@ -1230,6 +1230,36 @@ impl DripStream { state::load(&env) } + /// Read-only: the stream sender. + pub fn sender(env: Env) -> Address { + state::load(&env).sender + } + + /// Read-only: the stream recipient. + pub fn recipient(env: Env) -> Address { + state::load(&env).recipient + } + + /// Read-only: the token escrowed by this stream. + pub fn token(env: Env) -> Address { + state::load(&env).token + } + + /// Read-only: the stream's current rate per second. + pub fn rate_per_second(env: Env) -> i128 { + state::load(&env).rate_per_second + } + + /// Read-only: whether this stream has no scheduled end time. + pub fn is_open_ended(env: Env) -> bool { + state::load(&env).end_time == 0 + } + + /// Read-only: maximum safe pause duration, in seconds. + pub fn max_pause_secs(_env: Env) -> u64 { + ttl::MAX_PAUSE_SECS + } + /// Latest committed event sequence. /// /// Event consumers can compare this value with the last sequence they