From 500d87152b14f372915f7ce8f6ed3a6232f56122 Mon Sep 17 00:00:00 2001 From: Donald Date: Mon, 31 Aug 2026 09:00:56 +0100 Subject: [PATCH] feat(stream): expose current operator in StreamInfo and on-chain getter (#396) --- Cargo.lock | 1 + contracts/common/src/rbac.rs | 7 ++----- contracts/governor/src/role.rs | 10 +++------ contracts/governor/src/storage.rs | 1 + contracts/oracle/src/lib.rs | 1 + contracts/stream/src/lib.rs | 13 ++++++++---- contracts/stream/src/state.rs | 1 + contracts/stream/src/storage.rs | 35 +++++++++++++++++-------------- contracts/stream/src/tests.rs | 30 +++++++++++++++++++++++--- 9 files changed, 64 insertions(+), 35 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5cabb671..71497e29 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -406,6 +406,7 @@ dependencies = [ name = "drip-oracle" version = "0.1.0" dependencies = [ + "drip-common", "soroban-sdk", ] diff --git a/contracts/common/src/rbac.rs b/contracts/common/src/rbac.rs index 7fb7556f..0182e194 100644 --- a/contracts/common/src/rbac.rs +++ b/contracts/common/src/rbac.rs @@ -53,8 +53,7 @@ use soroban_sdk::{Address, Env, IntoVal, TryFromVal, Val, Vec as SorobanVec}; /// Convenience bound for any type usable as an instance-storage key. /// /// Every `#[contracttype]` enum/struct satisfies this automatically. -pub trait StorageKey: - IntoVal + TryFromVal + Clone {} +pub trait StorageKey: IntoVal + TryFromVal + Clone {} impl + TryFromVal + Clone> StorageKey for T {} @@ -171,9 +170,7 @@ where if count <= 1 { return Err(RbacError::LastAdmin); } - env.storage() - .instance() - .set(admin_count_key, &(count - 1)); + env.storage().instance().set(admin_count_key, &(count - 1)); } env.storage().instance().remove(role_key); // Rebuild the members index without this account. diff --git a/contracts/governor/src/role.rs b/contracts/governor/src/role.rs index 546e0a54..b3ffc3e5 100644 --- a/contracts/governor/src/role.rs +++ b/contracts/governor/src/role.rs @@ -30,6 +30,7 @@ pub fn has_role(env: &Env, role: Role, account: &Address) -> bool { } /// Number of accounts currently holding `Role::Admin` (zero pre-initialization). +#[allow(dead_code)] pub fn admin_count(env: &Env) -> u32 { rbac::admin_count(env, &DataKey::AdminCount) } @@ -74,13 +75,8 @@ pub fn revoke(env: &Env, role: Role, account: &Address) -> Result { /// Requires that `caller` both authorized the transaction and holds `role`, /// then bumps instance TTL. Every role-gated write funnels through here. pub fn require_role(env: &Env, caller: &Address, role: Role) -> Result<(), Error> { - rbac::require_role( - env, - caller, - &role_key(role, caller), - Some(ttl::bump), - ) - .map_err(|_| Error::NotAuthorized) + rbac::require_role(env, caller, &role_key(role, caller), Some(ttl::bump)) + .map_err(|_| Error::NotAuthorized) } /// Returns every account currently holding `role`. diff --git a/contracts/governor/src/storage.rs b/contracts/governor/src/storage.rs index 1a231065..696855e2 100644 --- a/contracts/governor/src/storage.rs +++ b/contracts/governor/src/storage.rs @@ -35,6 +35,7 @@ pub struct RoleKey { } #[contracttype] +#[derive(Clone)] pub enum DataKey { /// Fee in basis points (e.g. 30 = 0.3%) FeeBps, diff --git a/contracts/oracle/src/lib.rs b/contracts/oracle/src/lib.rs index 476ee140..ac731dd1 100644 --- a/contracts/oracle/src/lib.rs +++ b/contracts/oracle/src/lib.rs @@ -59,6 +59,7 @@ pub struct RoleKey { } #[contracttype] +#[derive(Clone)] pub enum DataKey { Admin, Config, diff --git a/contracts/stream/src/lib.rs b/contracts/stream/src/lib.rs index 0db38951..67b087e8 100644 --- a/contracts/stream/src/lib.rs +++ b/contracts/stream/src/lib.rs @@ -11,7 +11,7 @@ mod ttl; use soroban_sdk::{contract, contractimpl, panic_with_error, token, Address, Env}; -use drip_common::is_zero_stellar_account; +use drip_common::is_zero_address; pub use errors::Error; use storage::{DataKey, StreamInfo, FLAG_CANCELLED, FLAG_CLAWBACK_ENABLED, FLAG_PAUSED}; @@ -85,7 +85,7 @@ impl DripStream { // * create a self-stream (recipient == sender). // `is_zero_stellar_account` is the exact same helper the factory uses // (contracts/common/src/lib.rs), so both paths reject identical inputs. - if is_zero_stellar_account(&env, &recipient) || recipient == sender { + if is_zero_address(&env, &recipient) || recipient == sender { panic_with_error!(&env, Error::InvalidRecipient); } @@ -159,6 +159,7 @@ impl DripStream { flags, withdrawn: 0, paused_at: 0, + operator: None, }, ); } @@ -704,7 +705,7 @@ impl DripStream { /// Only the sender may call this. The operator has no power over /// withdrawals (which are recipient-only) or recipient transfers. pub fn set_operator(env: Env, caller: Address, operator: Address) -> Result<(), Error> { - let info = state::load(&env); + let mut info = state::load(&env); state::assert_not_cancelled(&info)?; if caller != info.sender { return Err(Error::NotAuthorized); @@ -724,13 +725,15 @@ impl DripStream { } env.storage().instance().set(&DataKey::Operator, &operator); + info.operator = Some(operator.clone()); + state::save(&env, &info); events::operator_set(&env, &caller, &operator); Ok(()) } /// Sender revokes the operator, removing all delegated sender rights. pub fn revoke_operator(env: Env, caller: Address) -> Result<(), Error> { - let info = state::load(&env); + let mut info = state::load(&env); state::assert_not_cancelled(&info)?; if caller != info.sender { return Err(Error::NotAuthorized); @@ -739,6 +742,8 @@ impl DripStream { ttl::bump(&env); env.storage().instance().remove(&DataKey::Operator); + info.operator = None; + state::save(&env, &info); events::operator_revoked(&env, &caller); Ok(()) } diff --git a/contracts/stream/src/state.rs b/contracts/stream/src/state.rs index 3ecf5db6..cc5eff19 100644 --- a/contracts/stream/src/state.rs +++ b/contracts/stream/src/state.rs @@ -41,6 +41,7 @@ pub fn load(env: &Env) -> StreamInfo { withdrawn: s.get(&DataKey::Withdrawn).unwrap_or(0), paused_at: s.get(&DataKey::PausedAt).unwrap_or(0), flags, + operator: s.get(&DataKey::Operator), } } diff --git a/contracts/stream/src/storage.rs b/contracts/stream/src/storage.rs index c8e549af..38728ccf 100644 --- a/contracts/stream/src/storage.rs +++ b/contracts/stream/src/storage.rs @@ -1,16 +1,15 @@ -use soroban_sdk:{; -use soroban_sdk:{contracttype, Address, Env}; +use soroban_sdk::{contracttype, Address, Env}; // Bit-flags packed into `StreamInfo::flags`. Kept `pub` so cross-crate regression tests (e.g. `tests/audit_round_2_regression.rs::pause_resume_*`) // and the `info().is_paused()`/`is_cancelled()`/`is_clawback_enabled()` getters -// can use them, but marked `#kdoc(hidden)` to keep the rustdoc contract API +// can use them, but marked `#[doc(hidden)]` to keep the rustdoc contract API // surface clean. Off-chain callers should use the `is_*()` getters rather than // reading the bit values directly. -[#doc(hidden)] +#[doc(hidden)] pub const FLAG_PAUSED: u32 = 1; -[@#doc(hidden)] -pub const FLAG_CLAWBACK_ENABLED : u32 = 1 << 1; -[#doc(hidden)] +#[doc(hidden)] +pub const FLAG_CLAWBACK_ENABLED: u32 = 1 << 1; +#[doc(hidden)] pub const FLAG_CANCELLED: u32 = 1 << 2; // Current storage layout version for this contract. @@ -19,10 +18,10 @@ pub const FLAG_CANCELLED: u32 = 1 << 2; pub const CURRENT_STORAGE_VERSION: u32 = 1; // Reentrancy guard states. -pub const GUARD_NOT_ENTERED : u32 = 0; -pub const GUARD_ENTERED : u32 = 1; +pub const GUARD_NOT_ENTERED: u32 = 0; +pub const GUARD_ENTERED: u32 = 1; -[#contracttype] +#[contracttype] pub enum DataKey { Sender, Recipient, @@ -37,7 +36,7 @@ pub enum DataKey { Cancelled, /// Single-key representation of all stream fields. /// Replaces the 11 individual keys above for new writes - loaded in one - /// storage read instead of eleven(. + /// storage read instead of eleven. Config, /// Monotonic identifier attached to every contract event. /// @@ -58,8 +57,8 @@ pub enum DataKey { Operator, } -[#contracttype] -[y#derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] pub struct StreamInfo { pub sender: Address, pub recipient: Address, @@ -70,6 +69,7 @@ pub struct StreamInfo { pub withdrawn: i128, pub paused_at: u64, pub flags: u32, + pub operator: Option
, } impl StreamInfo { @@ -82,10 +82,10 @@ impl StreamInfo { } pub fn is_clawback_enabled(&self) -> bool { - (self.flags & FLAG_ClAWBACK_ENABLED) != 0 + (self.flags & FLAG_CLAWBACK_ENABLED) != 0 } - /// Marks the stream as cancelled by setting the `FLAJ_CANCELLED` bit. + /// Marks the stream as cancelled by setting the `FLAG_CANCELLED` bit. pub fn mark_cancelled(&mut self) { self.flags |= FLAG_CANCELLED; } @@ -93,7 +93,10 @@ impl StreamInfo { /// Reads the current reentrancy guard state. pub fn read_guard(env: &Env) -> u32 { - env.storage().instance().get(&DataKey::Guard).unwrap_or(GUARD_NOT_ENTERED) + env.storage() + .instance() + .get(&DataKey::Guard) + .unwrap_or(GUARD_NOT_ENTERED) } /// Sets the reentrancy guard state. diff --git a/contracts/stream/src/tests.rs b/contracts/stream/src/tests.rs index d48f4e39..cc0fcf9f 100644 --- a/contracts/stream/src/tests.rs +++ b/contracts/stream/src/tests.rs @@ -638,6 +638,7 @@ fn info_returns_correct_initial_state() { assert!(!inf.is_cancelled()); assert!(inf.is_clawback_enabled()); assert_eq!(inf.withdrawn, 0); + assert_eq!(inf.operator, None); } #[test] @@ -1101,7 +1102,8 @@ fn set_operator_sets_address() { let s = Setup::new(100, 3600, false); let operator = Address::generate(&s.env); s.client.set_operator(&s.sender, &operator); - assert_eq!(s.client.operator(), Some(operator)); + assert_eq!(s.client.operator(), Some(operator.clone())); + assert_eq!(s.client.info().operator, Some(operator)); } #[test] @@ -1129,9 +1131,11 @@ fn set_operator_requires_revoke_before_replacement() { let op2 = Address::generate(&s.env); s.client.set_operator(&s.sender, &op1); assert_eq!(s.client.operator(), Some(op1.clone())); + assert_eq!(s.client.info().operator, Some(op1.clone())); let result = s.client.try_set_operator(&s.sender, &op2); assert_eq!(result, Err(Ok(Error::OperatorAlreadySet))); - assert_eq!(s.client.operator(), Some(op1)); + assert_eq!(s.client.operator(), Some(op1.clone())); + assert_eq!(s.client.info().operator, Some(op1)); } #[test] @@ -1139,9 +1143,29 @@ fn revoke_operator_removes_address() { let s = Setup::new(100, 3600, false); let operator = Address::generate(&s.env); s.client.set_operator(&s.sender, &operator); - assert_eq!(s.client.operator(), Some(operator)); + assert_eq!(s.client.operator(), Some(operator.clone())); + assert_eq!(s.client.info().operator, Some(operator)); s.client.revoke_operator(&s.sender); assert_eq!(s.client.operator(), None); + assert_eq!(s.client.info().operator, None); +} + +#[test] +fn operator_persists_across_mutations() { + let s = Setup::new(100, 3600, false); + let operator = Address::generate(&s.env); + s.client.set_operator(&s.sender, &operator); + + s.advance_secs(100); + s.client.withdraw(&5_000); + assert_eq!(s.client.info().operator, Some(operator.clone())); + + s.client.pause(&operator); + assert_eq!(s.client.info().operator, Some(operator.clone())); + + s.advance_secs(100); + s.client.resume(&operator); + assert_eq!(s.client.info().operator, Some(operator)); } #[test]