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