From 4685e9775d159755df33b9ba18d6be02fab98796 Mon Sep 17 00:00:00 2001 From: smartdev Date: Fri, 25 Sep 2026 22:56:48 +0100 Subject: [PATCH] Fix #649,650,651,656 --- .github/workflows/deny.yml | 5 + contracts/batch-processor/src/lib.rs | 111 ++++++++++++-- contracts/common/src/lib.rs | 2 + contracts/common/src/pause.rs | 215 +++++++++++++++++++++++++++ contracts/common/src/ttl.rs | 46 ++++++ contracts/factory/src/lib.rs | 16 +- contracts/factory/src/pause.rs | 30 +++- contracts/factory/src/ttl.rs | 20 +-- contracts/governor/src/lib.rs | 28 ++-- contracts/governor/src/ttl.rs | 20 ++- contracts/oracle/src/lib.rs | 101 +++++++++---- contracts/stream/src/lib.rs | 18 ++- contracts/stream/src/ttl.rs | 18 ++- contracts/token-vault/src/errors.rs | 2 + contracts/token-vault/src/events.rs | 11 ++ contracts/token-vault/src/lib.rs | 65 +++++--- contracts/token-vault/src/storage.rs | 7 +- contracts/token-vault/src/ttl.rs | 12 ++ deny.toml | 9 +- scripts/deploy.sh | 13 +- scripts/upgrade.sh | 66 ++++++-- 21 files changed, 675 insertions(+), 140 deletions(-) create mode 100644 contracts/common/src/pause.rs create mode 100644 contracts/common/src/ttl.rs create mode 100644 contracts/token-vault/src/ttl.rs diff --git a/.github/workflows/deny.yml b/.github/workflows/deny.yml index 4e75ff3c..12de13c4 100644 --- a/.github/workflows/deny.yml +++ b/.github/workflows/deny.yml @@ -11,8 +11,13 @@ jobs: runs-on: ubuntu-latest strategy: matrix: + # Every check configured in deny.toml must appear here, or a section can + # silently stop being enforced: `[bans]` was configured but never listed + # in the matrix, so duplicate-crate / banned-crate regressions reached + # main unblocked (issue #656). checks: - advisories + - bans - licenses steps: - uses: actions/checkout@v4 diff --git a/contracts/batch-processor/src/lib.rs b/contracts/batch-processor/src/lib.rs index 472a391f..27558eb5 100644 --- a/contracts/batch-processor/src/lib.rs +++ b/contracts/batch-processor/src/lib.rs @@ -3,8 +3,11 @@ #[cfg(test)] mod tests; -use drip_common::is_zero_address; -use soroban_sdk::{contract, contracterror, contractimpl, token, Address, Env, Symbol, Vec}; +use drip_common::{is_zero_address, ttl}; +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, symbol_short, token, Address, BytesN, Env, + Symbol, Vec, +}; /// Maximum number of transfers permitted in a single batch. const MAX_BATCH_SIZE: u32 = 100; @@ -20,19 +23,19 @@ const MAX_BATCH_SIZE: u32 = 100; /// [`BatchTransferProcessor::version`]. const VERSION: u32 = 1; -/// Errors returned by [`BatchTransferProcessor::process_batch`]. +/// Instance-storage key space. /// -/// # Validation order -/// The numeric order of the variants **is** the check order: `process_batch` -/// checks `1`, then `2`, then `3`, and so on, and returns the first failure -/// without evaluating the rest. A client pre-checking a batch before -/// submitting it should apply the same sequence (length → size → per-amount → -/// total → token), mirroring the validation list the README documents for -/// `DripFactory::create_stream`. A batch that is both too large and contains -/// a zero amount always reports `BatchTooLarge`, never `InvalidAmount`. -/// -/// All five checks run before `funder.require_auth()` and before any token -/// movement. +/// The processor is stateless with respect to transfers — every call recomputes +/// its batch total from the arguments — but it does hold one durable value: the +/// admin that may replace the contract's own WASM (issue #651). Without a +/// stored authority an `upgrade` entry point would have to be permissionless, +/// which would let anyone replace the code that custodies funds in flight. +#[contracttype] +#[derive(Clone)] +pub enum DataKey { + /// Address allowed to call `upgrade`. Set by `initialize`. + Admin, +} #[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] #[repr(u32)] @@ -49,13 +52,57 @@ pub enum Error { /// Checked fifth (last, immediately before auth): `token` is the /// all-zero Stellar address, which cannot be a SEP-41 token contract. InvalidToken = 5, + /// The caller is not the admin stored by `initialize`, so it may not + /// replace the contract's WASM. + NotAuthorized = 6, + /// The WASM hash provided to `upgrade` is all zeros (invalid). + InvalidWasmHash = 7, + /// `initialize` was called on a processor that already has an admin. + AlreadyInitialized = 8, + /// `upgrade` was called before `initialize` has set an admin. + NotInitialized = 9, } #[contract] pub struct BatchTransferProcessor; +/// Emitted by `initialize` when the processor's admin is first set. +fn event_initialized(env: &Env, admin: &Address) { + env.events() + .publish((symbol_short!("init"), admin.clone()), admin.clone()); +} + +/// Emitted by `upgrade` after the processor's own WASM has been replaced. +/// +/// Topics: `("upgraded", caller)` — the admin that authorized the swap. +/// Data: `upgraded_at` — the ledger timestamp at which the swap took effect. +fn event_upgraded(env: &Env, caller: &Address, upgraded_at: u64) { + env.events() + .publish((symbol_short!("upgraded"), caller.clone()), upgraded_at); +} + #[contractimpl] impl BatchTransferProcessor { + /// One-time setup: record the admin allowed to `upgrade` this contract. + /// + /// `process_batch` is permissionless and works without initialization; this + /// call only establishes who may replace the implementation. Guarded + /// against re-initialization so a second call cannot hand the upgrade + /// authority to a different address after the fact. + /// + /// # Errors + /// + /// - `AlreadyInitialized` — an admin is already recorded. + pub fn initialize(env: Env, admin: Address) -> Result<(), Error> { + if env.storage().instance().has(&DataKey::Admin) { + return Err(Error::AlreadyInitialized); + } + ttl::bump_instance(&env); + env.storage().instance().set(&DataKey::Admin, &admin); + event_initialized(&env, &admin); + Ok(()) + } + /// Transfer tokens from `funder` to each address in `recipients`. /// /// # Auth @@ -225,4 +272,40 @@ impl BatchTransferProcessor { pub fn version(_env: Env) -> u32 { VERSION } + + // ── Self-upgrade (admin-gated) ────────────────────────────────────────── + + /// Replace this contract's own WASM bytecode. + /// + /// The new WASM must already be uploaded to the ledger (via + /// `stellar contract upload`); only the hash is passed here. Gated on the + /// admin recorded by [`Self::initialize`], mirroring + /// `DripGovernor::upgrade` and `DripFactory::upgrade_self` — which this + /// contract previously lacked entirely (issue #651). The gate matters here + /// for the same reason it does elsewhere: the processor custodies the whole + /// batch total between the inbound pull and the outbound fan-out, so a + /// replaced implementation runs with funds in flight. + /// + /// An all-zero hash is rejected: `update_current_contract_wasm` with it + /// would replace the contract with something that cannot transfer. + pub fn upgrade(env: Env, caller: Address, new_wasm_hash: BytesN<32>) -> Result<(), Error> { + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(Error::NotInitialized)?; + if caller != admin { + return Err(Error::NotAuthorized); + } + caller.require_auth(); + + if new_wasm_hash == BytesN::from_array(&env, &[0u8; 32]) { + return Err(Error::InvalidWasmHash); + } + + ttl::bump_instance(&env); + env.deployer().update_current_contract_wasm(new_wasm_hash); + event_upgraded(&env, &caller, env.ledger().timestamp()); + Ok(()) + } } diff --git a/contracts/common/src/lib.rs b/contracts/common/src/lib.rs index 638c0b69..8debf4d4 100644 --- a/contracts/common/src/lib.rs +++ b/contracts/common/src/lib.rs @@ -2,7 +2,9 @@ //! Shared constants and utilities for the Drip protocol contracts. +pub mod pause; pub mod rbac; +pub mod ttl; use soroban_sdk::{Address, Env}; diff --git a/contracts/common/src/pause.rs b/contracts/common/src/pause.rs new file mode 100644 index 00000000..3be6df73 --- /dev/null +++ b/contracts/common/src/pause.rs @@ -0,0 +1,215 @@ +//! Shared emergency-pause helpers for the Drip protocol contracts. +//! +//! Every contract in the protocol can be halted by its authority +//! (`pause`/`unpause`/`is_paused`) and every state-mutating entry point is +//! expected to reject the call while halted. The gate itself is the same +//! everywhere — read one instance-storage flag, and refuse to proceed when it +//! is set — but it was previously open-coded per contract, with each copy +//! naming the flag key differently and mapping the failure to its own error +//! variant. A copy that silently loses its guard is a protocol-halt failure +//! mode, so the gate lives here once. +//! +//! Contracts keep their own `Error` enum (every code means something different +//! per contract), so [`PausedError`] is mapped at each call site: +//! +//! ```ignore +//! drip_common::pause::require_not_paused(env, &DataKey::Paused) +//! .map_err(|_| Error::ContractPaused)?; +//! ``` +//! +//! The flag is *not* gated here — reading and writing it is the contract's +//! business (only its pause authority may flip it), so [`is_paused`] and +//! [`set_paused`] are low-level storage helpers like the per-contract ones they +//! replace. + +use soroban_sdk::Env; + +use crate::rbac::StorageKey; + +/// Errors the shared pause gate can return. +/// +/// Each contract maps this onto its own `Error` variant (typically +/// `ContractPaused`), so no `#[contracterror]` attribute is needed here — the +/// same approach `drip_common::rbac::RbacError` takes. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum PausedError { + /// The contract is under an emergency pause, so the call is refused. + ContractPaused, +} + +/// Reads the emergency-pause flag stored under `paused_key` in `instance()` +/// storage. +/// +/// Defaults to `false` when the key was never written, so a contract deployed +/// before the pause feature existed is treated as running normally rather than +/// halting every call. +pub fn is_paused(env: &Env, paused_key: &K) -> bool { + env.storage().instance().get(paused_key).unwrap_or(false) +} + +/// Writes the emergency-pause flag to `instance()` storage. +/// +/// Performs no authorization and no state-transition validation: the caller +/// (the contract's own `pause`/`unpause`) owns both, and is responsible for +/// emitting the matching event and bumping TTL. +pub fn set_paused(env: &Env, paused_key: &K, paused: bool) { + env.storage().instance().set(paused_key, &paused); +} + +/// The pause gate: `Ok(())` when the contract is running, `Err` while halted. +/// +/// Place it at the top of a state-mutating entry point — before validation, +/// storage reads, and TTL payment — so a halted protocol rejects the call +/// immediately and cheaply, and so no state is touched on the rejected path. +pub fn require_not_paused(env: &Env, paused_key: &K) -> Result<(), PausedError> { + require_not_paused_flag(is_paused(env, paused_key)) +} + +/// [`require_not_paused`] for a flag the caller already has in memory — e.g. a +/// field inside a stored struct rather than a standalone instance key +/// (`DripStream` keeps its pause bit in the consolidated `StreamInfo`). +/// +/// Accepting the flag instead of re-reading storage keeps the gate free to use +/// where the contract has already loaded the record it needs to gate on. +pub fn require_not_paused_flag(paused: bool) -> Result<(), PausedError> { + if paused { + Err(PausedError::ContractPaused) + } else { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::{contract, contractimpl, contracttype}; + + // ── Contract frame ──────────────────────────────────────────────────────── + // + // `pause` is storage-only shared code and owns no `#[contract]` of its own, + // so the suite supplies the frame `soroban_sdk` requires before any + // instance-storage access can run. The host is registered per test purely + // so the frame has instance storage to open. + + #[contract] + struct PauseTestHost; + + #[contractimpl] + impl PauseTestHost { + /// Never invoked. Present only because registering a contract requires + /// at least one callable entry point. + pub fn noop(_env: Env) {} + } + + fn in_contract(env: &Env, f: impl FnOnce() -> T) -> T { + env.as_contract(&env.register_contract(None, PauseTestHost), f) + } + + // ── Minimal key space ───────────────────────────────────────────────────── + // + // Mirrors the rbac suite: the helpers are generic over the flag key, so the + // tests supply their own `#[contracttype]` key instead of reaching into a + // consumer contract's `DataKey`. + + #[contracttype] + #[derive(Clone, Debug, Eq, PartialEq)] + enum TestKey { + Paused, + StreamAddr(u64), + } + + const PAUSED: TestKey = TestKey::Paused; + + // ── The flag is readable and writable, defaulting to running ────────────── + + #[test] + fn unset_flag_reads_as_not_paused() { + let env = Env::default(); + + in_contract(&env, || { + // Absent key means "running normally" — a contract deployed before + // the pause feature existed must not be halted by a missing entry. + assert!(!is_paused(&env, &PAUSED)); + }); + } + + #[test] + fn set_paused_round_trips_and_clears() { + let env = Env::default(); + + in_contract(&env, || { + set_paused(&env, &PAUSED, true); + assert!(is_paused(&env, &PAUSED)); + + set_paused(&env, &PAUSED, false); + assert!(!is_paused(&env, &PAUSED)); + }); + } + + // ── require_not_paused ──────────────────────────────────────────────────── + + #[test] + fn require_not_paused_accepts_a_running_contract() { + let env = Env::default(); + + in_contract(&env, || { + assert_eq!(require_not_paused(&env, &PAUSED), Ok(())); + }); + } + + #[test] + fn require_not_paused_rejects_a_halted_contract() { + let env = Env::default(); + + in_contract(&env, || { + set_paused(&env, &PAUSED, true); + assert_eq!( + require_not_paused(&env, &PAUSED), + Err(PausedError::ContractPaused) + ); + }); + } + + /// The two entry points must agree: the key-based gate is a thin wrapper + /// over the flag-based one, and a consumer that already holds the flag + /// (inside a loaded struct) has to reach the same verdict. + #[test] + fn flag_based_gate_matches_the_key_based_gate() { + let env = Env::default(); + + in_contract(&env, || { + for paused in [false, true] { + set_paused(&env, &PAUSED, paused); + assert_eq!( + require_not_paused(&env, &PAUSED), + require_not_paused_flag(paused) + ); + } + }); + } + + // ── Generic over the flag key ───────────────────────────────────────────── + + /// `DripStream` gates on a pause bit held inside its loaded `StreamInfo` + /// rather than a standalone instance key, and the factory/oracle gate on + /// `DataKey::Paused`. Both key shapes must work through the same helper, + /// which is what the generic bound buys. + #[test] + fn gate_is_generic_over_the_flag_key() { + let env = Env::default(); + let stream_key = TestKey::StreamAddr(7); + + in_contract(&env, || { + assert_eq!(require_not_paused(&env, &stream_key), Ok(())); + + set_paused(&env, &stream_key, true); + assert_eq!( + require_not_paused(&env, &stream_key), + Err(PausedError::ContractPaused) + ); + + // Two distinct keys hold independent flags. + assert!(!is_paused(&env, &PAUSED)); + }); + } +} diff --git a/contracts/common/src/ttl.rs b/contracts/common/src/ttl.rs new file mode 100644 index 00000000..6ab19a9c --- /dev/null +++ b/contracts/common/src/ttl.rs @@ -0,0 +1,46 @@ +//! Shared storage-TTL extension helpers for the Drip protocol contracts. +//! +//! Every contract that keeps state in `instance()` storage must renew that +//! storage on its state-mutating paths, or an idle contract is archived by the +//! host and every later call fails with an opaque "entry archived" error. The +//! threshold/extend-to pair that drives those renewals is protocol-wide policy, +//! so it lives here — once — instead of being restated in each contract's +//! `ttl.rs` (four near-identical `bump()` wrappers plus an inline copy in +//! `TwapOracle`, which had drifted onto its own hardcoded literals). +//! +//! A per-contract `ttl.rs` may still exist to keep a crate-local call-site +//! name stable, but it must delegate here rather than re-deriving the +//! constants: a duplicated literal is exactly the kind of drift this module +//! exists to prevent. + +use soroban_sdk::Env; + +use crate::rbac::StorageKey; + +pub use crate::{TTL_EXTEND_TO, TTL_THRESHOLD}; + +/// Extends the contract's instance storage TTL to [`TTL_EXTEND_TO`] whenever +/// the remaining TTL drops below [`TTL_THRESHOLD`]. +/// +/// Called from every state-mutating entry point. A no-op when the contract has +/// no instance storage open (e.g. a read-only invocation), so it is always +/// safe to call first. +pub fn bump_instance(env: &Env) { + env.storage() + .instance() + .extend_ttl(TTL_THRESHOLD, TTL_EXTEND_TO); +} + +/// Extends the TTL of a single `persistent()` entry under `key` on the same +/// schedule as [`bump_instance`]. +/// +/// Persistent entries are per-entity registry rows (e.g. the factory's +/// `StreamAddr(id)` map) and are not covered by an instance bump, so each one +/// has to be renewed on access. Callers must confirm the entry exists first +/// (`has` / `get`) — `extend_ttl` on a missing entry is a host error, not a +/// silent no-op. +pub fn bump_persistent(env: &Env, key: &K) { + env.storage() + .persistent() + .extend_ttl(key, TTL_THRESHOLD, TTL_EXTEND_TO); +} diff --git a/contracts/factory/src/lib.rs b/contracts/factory/src/lib.rs index 1728d890..e388db63 100644 --- a/contracts/factory/src/lib.rs +++ b/contracts/factory/src/lib.rs @@ -113,9 +113,7 @@ impl DripFactory { // rejects new streams immediately, without pulling a deposit or paying // a TTL extension. Already-deployed streams are independent contracts // and are unaffected by this flag. - if pause::is_paused(&env) { - return Err(Error::ContractPaused); - } + pause::require_not_paused(&env)?; // ── Validation ─────────────────────────────────────────────────── let now = Self::validate_stream_request( @@ -420,9 +418,7 @@ impl DripFactory { // ── Auth / pause ───────────────────────────────────────────────── sender.require_auth(); - if pause::is_paused(&env) { - return Err(Error::ContractPaused); - } + pause::require_not_paused(&env)?; // ── Fetch governor config once for the whole batch ─────────────── let governor: Address = env @@ -737,9 +733,7 @@ impl DripFactory { // Block upgrades while the factory is under an emergency pause. // A paused factory should accept no state mutations at all, even // from the governor, so the halt remains comprehensive. - if pause::is_paused(&env) { - return Err(Error::ContractPaused); - } + pause::require_not_paused(&env)?; ttl::bump_instance(&env); ttl::bump_persistent_bucket(&env); @@ -783,9 +777,7 @@ impl DripFactory { return Err(Error::InvalidWasmHash); } - if pause::is_paused(&env) { - return Err(Error::ContractPaused); - } + pause::require_not_paused(&env)?; let stored_version: u32 = env .storage() diff --git a/contracts/factory/src/pause.rs b/contracts/factory/src/pause.rs index b7f869ee..a57012f5 100644 --- a/contracts/factory/src/pause.rs +++ b/contracts/factory/src/pause.rs @@ -1,5 +1,14 @@ +//! Emergency-pause flag storage and gating for `DripFactory`. +//! +//! The flag read/write and the "reject while halted" gate are protocol-wide +//! policy and live in [`drip_common::pause`]; this module only keeps the +//! crate-local call-site names (`pause::is_paused`, `pause::set_paused`, +//! `pause::require_not_paused`) unchanged — it must not restate the gate +//! (issue #650). + use soroban_sdk::Env; +use crate::errors::Error; use crate::storage::DataKey; /// Reads the emergency-pause flag. @@ -8,10 +17,7 @@ use crate::storage::DataKey; /// factory that was initialized before this feature existed. This keeps the /// flag backward-compatible: an absent entry means "running normally". pub fn is_paused(env: &Env) -> bool { - env.storage() - .instance() - .get(&DataKey::Paused) - .unwrap_or(false) + drip_common::pause::is_paused(env, &DataKey::Paused) } /// Writes the emergency-pause flag directly to instance storage. @@ -22,9 +28,19 @@ pub fn is_paused(env: &Env) -> bool { /// enforce state invariants (such as checking if the factory is already paused /// or unpaused). /// -/// Callers (e.g., [`DripFactory::pause`] and [`DripFactory::unpause`] in `lib.rs`) -/// are responsible for verifying caller authorization, checking current pause +/// Callers (e.g., [`DripFactory::pause`](crate::DripFactory::pause) and +/// [`DripFactory::unpause`](crate::DripFactory::unpause) in `lib.rs`) are +/// responsible for verifying caller authorization, checking current pause /// state transitions, updating TTL, and emitting public events. pub fn set_paused(env: &Env, paused: bool) { - env.storage().instance().set(&DataKey::Paused, &paused); + drip_common::pause::set_paused(env, &DataKey::Paused, paused); +} + +/// The pause gate: `Err(ContractPaused)` while the factory is halted. +/// +/// Delegates to [`drip_common::pause::require_not_paused`] and maps the shared +/// error onto the factory's own `Error`, so the halt check is byte-for-byte the +/// same gate every sibling contract runs. +pub fn require_not_paused(env: &Env) -> Result<(), Error> { + drip_common::pause::require_not_paused(env, &DataKey::Paused).map_err(|_| Error::ContractPaused) } diff --git a/contracts/factory/src/ttl.rs b/contracts/factory/src/ttl.rs index 25de37b3..875aa80d 100644 --- a/contracts/factory/src/ttl.rs +++ b/contracts/factory/src/ttl.rs @@ -1,11 +1,19 @@ +//! Instance and persistent TTL management for `DripFactory`. +//! +//! The threshold/extend-to values are protocol-wide policy and live in +//! [`drip_common::ttl`], so every contract renews storage on the same +//! schedule. This module only keeps the crate-local call-site names +//! (`ttl::bump_instance`, `ttl::bump_persistent`) unchanged — it must not +//! restate the constants (issue #649). + use soroban_sdk::Env; use crate::storage::DataKey; // Exposed as `pub` so callers can reuse the same threshold/extend-to values // for the persistent BySender/ByRecipient/StreamAddr registry entries. -pub use drip_common::TTL_EXTEND_TO as EXTEND_TO; -pub use drip_common::TTL_THRESHOLD as THRESHOLD; +pub use drip_common::ttl::{bump_instance, bump_persistent}; +pub use drip_common::{TTL_EXTEND_TO as EXTEND_TO, TTL_THRESHOLD as THRESHOLD}; /// How many persistent entries the bounded walker bumps per call. Sized so /// the gas cost of any single `pause`/`unpause`/`upgrade_stream_wasm` @@ -14,10 +22,6 @@ pub use drip_common::TTL_THRESHOLD as THRESHOLD; /// eventually covered across calls. pub const BATCH_LIMIT: u32 = 8; -pub fn bump_instance(env: &Env) { - env.storage().instance().extend_ttl(THRESHOLD, EXTEND_TO); -} - /// Bounded TTL walker. /// /// Advances `DataKey::LastBumpedId` by `BATCH_LIMIT`, wrapping around modulo @@ -64,9 +68,7 @@ pub fn bump_persistent_bucket(env: &Env) -> u32 { let id: u64 = next % count; let key = DataKey::StreamAddr(id); if env.storage().persistent().has(&key) { - env.storage() - .persistent() - .extend_ttl(&key, THRESHOLD, EXTEND_TO); + bump_persistent(env, &key); touched += 1; } new_last = id; diff --git a/contracts/governor/src/lib.rs b/contracts/governor/src/lib.rs index 60f30fc1..6b8af3a0 100644 --- a/contracts/governor/src/lib.rs +++ b/contracts/governor/src/lib.rs @@ -38,20 +38,26 @@ use storage::DataKey; /// Defaults to `false` when the key has never been set — e.g. a governor /// that was initialized before this feature existed. This keeps the flag /// backward-compatible: an absent entry means "running normally". +/// +/// Delegates to [`drip_common::pause::is_paused`], which owns the flag +/// read so every sibling contract reads it identically. fn is_paused(env: &Env) -> bool { - env.storage() - .instance() - .get(&DataKey::Paused) - .unwrap_or(false) + drip_common::pause::is_paused(env, &DataKey::Paused) +} + +/// Writes the emergency-pause flag. Only `governor_pause`/`governor_unpause` +/// call this, and both have already gated the caller and the transition. +fn set_paused(env: &Env, paused: bool) { + drip_common::pause::set_paused(env, &DataKey::Paused, paused); } /// Assert that the governor is not paused. Returns `ContractPaused` if it is. +/// +/// Delegates to the protocol-wide gate [`drip_common::pause::require_not_paused`] +/// and maps the shared `PausedError` onto the governor's own `Error` — the same +/// gate `DripFactory`, `TwapOracle`, and `TokenVault` run. fn assert_not_paused(env: &Env) -> Result<(), Error> { - if is_paused(env) { - Err(Error::ContractPaused) - } else { - Ok(()) - } + drip_common::pause::require_not_paused(env, &DataKey::Paused).map_err(|_| Error::ContractPaused) } #[contract] @@ -172,7 +178,7 @@ impl DripGovernor { if is_paused(&env) { return Err(Error::AlreadyPaused); } - env.storage().instance().set(&DataKey::Paused, &true); + set_paused(&env, true); events::paused(&env, &caller, env.ledger().timestamp()); Ok(()) } @@ -185,7 +191,7 @@ impl DripGovernor { if !is_paused(&env) { return Err(Error::NotPaused); } - env.storage().instance().set(&DataKey::Paused, &false); + set_paused(&env, false); events::unpaused(&env, &caller, env.ledger().timestamp()); Ok(()) } diff --git a/contracts/governor/src/ttl.rs b/contracts/governor/src/ttl.rs index 5578a625..31228109 100644 --- a/contracts/governor/src/ttl.rs +++ b/contracts/governor/src/ttl.rs @@ -1,9 +1,13 @@ -use soroban_sdk::Env; +//! Instance TTL management for `DripGovernor`. +//! +//! The threshold/extend-to values are protocol-wide policy and live in +//! [`drip_common::ttl`], so every contract renews instance storage on the same +//! schedule. This module only keeps the crate-local `ttl::bump` call sites +//! unchanged — it must not restate the constants (issue #649). -use drip_common::{TTL_EXTEND_TO, TTL_THRESHOLD}; - -pub fn bump(env: &Env) { - env.storage() - .instance() - .extend_ttl(TTL_THRESHOLD, TTL_EXTEND_TO); -} +/// Extends the governor's instance storage TTL. +/// +/// Re-exported from [`drip_common::ttl::bump_instance`]; kept under the +/// `bump` name because the governor's rbac `on_success` hook is wired to +/// `Some(ttl::bump)` (see `drip_common::rbac::require_role_or_admin`). +pub use drip_common::ttl::bump_instance as bump; diff --git a/contracts/oracle/src/lib.rs b/contracts/oracle/src/lib.rs index 2f94aaeb..7c64c1da 100644 --- a/contracts/oracle/src/lib.rs +++ b/contracts/oracle/src/lib.rs @@ -1,18 +1,10 @@ #![no_std] -use drip_common::rbac; +use drip_common::{pause, rbac, ttl}; use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, symbol_short, Address, Env, Vec, + contract, contracterror, contractimpl, contracttype, symbol_short, Address, BytesN, Env, Vec, }; -/// TTL extension constants matching the convention used across sibling -/// contracts (factory, governor, stream). `bump_instance` is called from -/// every state-mutating entry point so instance storage entries -/// (`DataKey::Admin`, `DataKey::Config`, `DataKey::Price`, etc.) never -/// silently archive during idle periods. -const THRESHOLD: u32 = 100_000; -const EXTEND_TO: u32 = 200_000; - /// Maximum number of distinct price feeders the oracle will track. Caps the /// `DataKey::Submitters` list and the per-feeder `Submission` loop in /// `get_twap_price` (one storage `get` per entry). Without a cap the list @@ -25,8 +17,16 @@ const EXTEND_TO: u32 = 200_000; /// (32 gets + insertion sort). const MAX_SUBMITTERS: u32 = 32; +/// Extends the instance storage TTL so the oracle's entries +/// (`DataKey::Admin`, `DataKey::Config`, `DataKey::Price`, etc.) never silently +/// archive during idle periods. Called from every state-mutating entry point. +/// +/// The threshold/extend-to pair is protocol-wide policy and lives in +/// [`drip_common::ttl`] — the oracle previously restated it as its own +/// hardcoded literals, which could drift away from the sibling contracts' +/// values (issue #649). fn bump_instance(env: &Env) { - env.storage().instance().extend_ttl(THRESHOLD, EXTEND_TO); + ttl::bump_instance(env); } /// Protocol administration roles for the oracle. @@ -235,6 +235,8 @@ pub enum Error { /// the configured `min_submitters` quorum requires, so the TWAP median is /// not reliable enough to return (issue #661). InsufficientQuorum = 1020, + /// The WASM hash provided to `upgrade` is all zeros (invalid). + InvalidWasmHash = 1021, } #[contract] @@ -475,7 +477,7 @@ impl TwapOracle { /// malicious submissions at submission time rather than letting them /// propagate into the TWAP window — see issue #226. pub fn submit_price(env: Env, caller: Address, price: u64) -> Result<(), Error> { - if is_paused(&env) { + if require_not_paused(&env).is_err() { events::price_rejected(&env, &caller, price, symbol_short!("paused")); return Err(Error::ContractPaused); } @@ -542,11 +544,7 @@ impl TwapOracle { env.storage() .persistent() .set(&DataKey::Submission(caller.clone()), &data); - env.storage().persistent().extend_ttl( - &DataKey::Submission(caller.clone()), - THRESHOLD, - EXTEND_TO, - ); + ttl::bump_persistent(&env, &DataKey::Submission(caller.clone())); add_submitter(&env, &caller)?; events::price_submitted(&env, &caller, price, now); @@ -769,6 +767,40 @@ impl TwapOracle { pub fn is_paused(env: Env) -> bool { is_paused(&env) } + + // ── Self-upgrade (Admin-gated) ───────────────────────────────────────── + + /// Replace this contract's own WASM bytecode. + /// + /// The new WASM must already be uploaded to the ledger (via + /// `stellar contract upload`); only the hash is passed here. Gated on + /// `Role::Admin` (not `Pauser`, and not a bare `PriceFeeder`) so a + /// compromised or abandoned ops key cannot silently swap the + /// implementation — the oracle is the price source for + /// `calculate_fiat_stream_payout`, so a substituted build can misprice + /// every downstream payout. + /// + /// Mirrors `DripGovernor::upgrade` and `DripFactory::upgrade_self`, which + /// the oracle previously lacked entirely (issue #651). + /// + /// Blocked while the oracle is paused, matching those two — a halted + /// protocol should accept no code changes. + /// + /// An all-zero hash is rejected: `update_current_contract_wasm` with it + /// would replace the contract with something that cannot serve a price. + pub fn upgrade(env: Env, caller: Address, new_wasm_hash: BytesN<32>) -> Result<(), Error> { + require_role(&env, &caller, Role::Admin)?; + require_not_paused(&env)?; + + if new_wasm_hash == BytesN::from_array(&env, &[0u8; 32]) { + return Err(Error::InvalidWasmHash); + } + + bump_instance(&env); + env.deployer().update_current_contract_wasm(new_wasm_hash); + events::upgraded(&env, &caller, env.ledger().timestamp()); + Ok(()) + } } // ── Internal RBAC helpers (delegate to drip_common::rbac) ───────────────── @@ -888,9 +920,7 @@ fn add_submitter(env: &Env, account: &Address) -> Result<(), Error> { env.storage() .persistent() .set(&DataKey::Submitters, &submitters); - env.storage() - .persistent() - .extend_ttl(&DataKey::Submitters, THRESHOLD, EXTEND_TO); + ttl::bump_persistent(env, &DataKey::Submitters); Ok(()) } @@ -925,9 +955,7 @@ fn remove_submitter(env: &Env, account: &Address) { env.storage() .persistent() .set(&DataKey::Submitters, &updated); - env.storage() - .persistent() - .extend_ttl(&DataKey::Submitters, THRESHOLD, EXTEND_TO); + ttl::bump_persistent(env, &DataKey::Submitters); // Refresh legacy single-value price slot to the most recent remaining submission let mut latest: Option = None; @@ -1058,15 +1086,27 @@ fn load_price_status(env: &Env) -> Result { }) } +/// Reads the emergency-pause flag (absent ⇒ running normally). +/// +/// Delegates to [`drip_common::pause::is_paused`], which owns the flag read so +/// every sibling contract reads it identically (issue #650). fn is_paused(env: &Env) -> bool { - env.storage() - .instance() - .get(&DataKey::Paused) - .unwrap_or(false) + pause::is_paused(env, &DataKey::Paused) } +/// Writes the emergency-pause flag. Only `pause`/`unpause` call this, and both +/// have already gated the caller and the transition. fn set_paused(env: &Env, paused: bool) { - env.storage().instance().set(&DataKey::Paused, &paused); + pause::set_paused(env, &DataKey::Paused, paused); +} + +/// The pause gate: `Err(ContractPaused)` while the oracle is halted. +/// +/// Delegates to the protocol-wide gate [`drip_common::pause::require_not_paused`] +/// and maps the shared `PausedError` onto the oracle's own `Error` — the same +/// gate `DripFactory`, `DripGovernor`, and `TokenVault` run. +fn require_not_paused(env: &Env) -> Result<(), Error> { + pause::require_not_paused(env, &DataKey::Paused).map_err(|_| Error::ContractPaused) } /// Execute `f` under the re-entrancy guard, releasing the lock afterwards. @@ -1128,6 +1168,11 @@ mod events { ); } + pub fn upgraded(env: &Env, caller: &Address, upgraded_at: u64) { + env.events() + .publish((symbol_short!("upgraded"), caller.clone()), upgraded_at); + } + pub fn price_submitted(env: &Env, caller: &Address, price: u64, timestamp: u64) { env.events().publish( (symbol_short!("priced"), caller.clone()), diff --git a/contracts/stream/src/lib.rs b/contracts/stream/src/lib.rs index 59da8373..d2c2a998 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_address; +use drip_common::{is_zero_address, pause}; pub use errors::Error; use storage::{DataKey, StreamInfo, FLAG_CLAWBACK_ENABLED, FLAG_PAUSED}; @@ -19,6 +19,18 @@ use storage::{DataKey, StreamInfo, FLAG_CLAWBACK_ENABLED, FLAG_PAUSED}; #[contract] pub struct DripStream; +/// The pause gate: `Err(NotPaused)` while the stream is halted. +/// +/// A stream keeps its pause bit inside the consolidated `StreamInfo` record +/// rather than under a standalone instance key, so this delegates to the +/// flag-based form of the protocol-wide gate +/// ([`drip_common::pause::require_not_paused_flag`]) on the already-loaded +/// record — the same gate the factory, governor, oracle, and vault run +/// (issue #650). +fn require_not_paused(info: &StreamInfo) -> Result<(), Error> { + pause::require_not_paused_flag(info.is_paused()).map_err(|_| Error::NotPaused) +} + /// Check that `caller` is either the stream's `sender` or a delegated /// `operator`, then consume the caller's auth. Returns `NotAuthorized` /// when `caller` matches neither role or fails the auth check. @@ -628,9 +640,7 @@ impl DripStream { let info = state::load(env); state::assert_not_cancelled(&info)?; - if info.is_paused() { - return Err(Error::NotPaused); - } + require_not_paused(&info)?; if !info.is_clawback_enabled() { return Err(Error::ClawbackDisabled); } diff --git a/contracts/stream/src/ttl.rs b/contracts/stream/src/ttl.rs index 50c2c197..5fc9761c 100644 --- a/contracts/stream/src/ttl.rs +++ b/contracts/stream/src/ttl.rs @@ -1,6 +1,9 @@ -use soroban_sdk::Env; - -use drip_common::{TTL_EXTEND_TO, TTL_THRESHOLD}; +//! Instance TTL management for `DripStream`. +//! +//! The threshold/extend-to values are protocol-wide policy and live in +//! [`drip_common::ttl`], so every contract renews instance storage on the same +//! schedule. This module only keeps the crate-local `ttl::bump` call sites +//! unchanged — it must not restate the constants (issue #649). /// Maximum safe duration a stream may remain paused before the instance /// storage TTL window is no longer sufficient to resume it safely. @@ -11,8 +14,7 @@ use drip_common::{TTL_EXTEND_TO, TTL_THRESHOLD}; /// call can run. pub const MAX_PAUSE_SECS: u64 = 2_592_000; // 30 days -pub fn bump(env: &Env) { - env.storage() - .instance() - .extend_ttl(TTL_THRESHOLD, TTL_EXTEND_TO); -} +/// Extends the stream's instance storage TTL. +/// +/// Re-exported from [`drip_common::ttl::bump_instance`] — see the module docs. +pub use drip_common::ttl::bump_instance as bump; diff --git a/contracts/token-vault/src/errors.rs b/contracts/token-vault/src/errors.rs index c9f43e63..699fe942 100644 --- a/contracts/token-vault/src/errors.rs +++ b/contracts/token-vault/src/errors.rs @@ -29,4 +29,6 @@ pub enum Error { DepositTransferFailed = 13, /// The token transfer did not move exactly the expected amount for `withdraw`. WithdrawTransferFailed = 14, + /// The WASM hash provided to `upgrade` is all zeros (invalid). + InvalidWasmHash = 15, } diff --git a/contracts/token-vault/src/events.rs b/contracts/token-vault/src/events.rs index 9e5fe19b..ef0ee30f 100644 --- a/contracts/token-vault/src/events.rs +++ b/contracts/token-vault/src/events.rs @@ -118,6 +118,17 @@ pub fn unpaused(env: &Env, caller: &Address, resumed_at: u64) { .publish((symbol_short!("unpaused"), caller.clone()), resumed_at); } +/// Emitted by `upgrade` after the vault's own WASM has been replaced. +/// +/// Topics: `("upgraded", caller)` — the owner that authorized the swap. +/// Data: `upgraded_at` — the ledger timestamp at which the swap took effect. +/// Mirrors `DripGovernor`/`TwapOracle`, so indexers can follow the vault's +/// implementation history (issue #651). +pub fn upgraded(env: &Env, caller: &Address, upgraded_at: u64) { + env.events() + .publish((symbol_short!("upgraded"), caller.clone()), upgraded_at); +} + /// Emitted by `propose_owner` (step 1 of the 2-step owner transfer). /// /// Topics: `("propose", caller)` — the current owner. diff --git a/contracts/token-vault/src/lib.rs b/contracts/token-vault/src/lib.rs index a6bd0baa..ad8c63cc 100644 --- a/contracts/token-vault/src/lib.rs +++ b/contracts/token-vault/src/lib.rs @@ -5,29 +5,22 @@ mod events; mod storage; #[cfg(test)] mod tests; +mod ttl; -use drip_common::{is_zero_address, TTL_EXTEND_TO, TTL_THRESHOLD}; +use drip_common::is_zero_address; use errors::Error; -use soroban_sdk::{contract, contractimpl, token, Address, Env}; +use soroban_sdk::{contract, contractimpl, token, Address, BytesN, Env}; use storage::{ get_max_limit, get_operator, get_operator_withdraw_limit, get_owner, get_pending_owner, get_pending_owner_proposer, get_token, is_paused, remove_operator, remove_pending_owner, remove_pending_owner_proposer, set_max_limit, set_operator, set_operator_withdraw_limit, - set_owner, set_paused, set_pending_owner, set_pending_owner_proposer, set_token, + set_owner, set_paused, set_pending_owner, set_pending_owner_proposer, set_token, DataKey, }; +use ttl::bump_instance; #[contract] pub struct TokenVault; -/// Extends the instance storage TTL to ensure vault state remains active and -/// does not archive during idle periods. Matches the TTL management pattern -/// across sibling contracts (DripFactory, DripGovernor, TwapOracle). -fn bump_instance(env: &Env) { - env.storage() - .instance() - .extend_ttl(TTL_THRESHOLD, TTL_EXTEND_TO); -} - fn token_client(env: &Env) -> Result, Error> { let token_addr = get_token(env).ok_or(Error::NotInitialized)?; Ok(token::Client::new(env, &token_addr)) @@ -57,12 +50,12 @@ fn require_owner_or_operator(env: &Env, caller: &Address, owner: &Address) -> Re } /// Short-circuit helper: reject any state-mutating call while paused. +/// +/// Delegates to the protocol-wide gate in [`drip_common::pause`], mapping the +/// shared `PausedError` onto the vault's own `Error::ContractPaused` — the same +/// gate `DripFactory`, `DripGovernor`, and `TwapOracle` run. fn assert_not_paused(env: &Env) -> Result<(), Error> { - if is_paused(env) { - Err(Error::ContractPaused) - } else { - Ok(()) - } + drip_common::pause::require_not_paused(env, &DataKey::Paused).map_err(|_| Error::ContractPaused) } #[contractimpl] @@ -411,4 +404,42 @@ impl TokenVault { pub fn is_paused(env: Env) -> bool { is_paused(&env) } + + // ── Self-upgrade (owner-gated) ────────────────────────────────────────── + + /// Replace this contract's own WASM bytecode. + /// + /// The new WASM must already be uploaded to the ledger (via + /// `stellar contract upload`); only the hash is passed here. Gated on the + /// vault owner — the same authority that can already drain the vault via + /// `withdraw`, so an owner able to take the funds can also replace the + /// contract. A delegated `operator` is deliberately *not* enough, matching + /// `set_operator`'s documented scope (day-to-day ops, not ownership). + /// + /// Mirrors `DripGovernor::upgrade` and `TwapOracle::upgrade`, which the + /// vault previously lacked entirely (issue #651). + /// + /// Blocked while the vault is paused, matching those two — a halted + /// contract should accept no code changes. + /// + /// An all-zero hash is rejected: `update_current_contract_wasm` with it + /// would replace the contract with something that cannot release escrowed + /// funds. + pub fn upgrade(env: Env, caller: Address, new_wasm_hash: BytesN<32>) -> Result<(), Error> { + let owner = get_owner(&env).ok_or(Error::NotInitialized)?; + if caller != owner { + return Err(Error::NotAuthorized); + } + caller.require_auth(); + assert_not_paused(&env)?; + + if new_wasm_hash == BytesN::from_array(&env, &[0u8; 32]) { + return Err(Error::InvalidWasmHash); + } + + bump_instance(&env); + env.deployer().update_current_contract_wasm(new_wasm_hash); + events::upgraded(&env, &caller, env.ledger().timestamp()); + Ok(()) + } } diff --git a/contracts/token-vault/src/storage.rs b/contracts/token-vault/src/storage.rs index f74995f6..a8580ced 100644 --- a/contracts/token-vault/src/storage.rs +++ b/contracts/token-vault/src/storage.rs @@ -98,14 +98,11 @@ pub fn get_operator_withdraw_limit(env: &Env) -> Option { } pub fn set_paused(env: &Env, paused: bool) { - env.storage().instance().set(&DataKey::Paused, &paused); + drip_common::pause::set_paused(env, &DataKey::Paused, paused); } pub fn is_paused(env: &Env) -> bool { - env.storage() - .instance() - .get(&DataKey::Paused) - .unwrap_or(false) + drip_common::pause::is_paused(env, &DataKey::Paused) } pub fn set_pending_owner(env: &Env, owner: &Address) { diff --git a/contracts/token-vault/src/ttl.rs b/contracts/token-vault/src/ttl.rs new file mode 100644 index 00000000..6efbde82 --- /dev/null +++ b/contracts/token-vault/src/ttl.rs @@ -0,0 +1,12 @@ +//! Instance TTL management for `TokenVault`. +//! +//! The threshold/extend-to values are protocol-wide policy and live in +//! [`drip_common::ttl`], so every contract renews instance storage on the same +//! schedule. This module only keeps the crate-local `ttl::bump_instance` call +//! sites unchanged — it must not restate the constants (issue #649). + +/// Extends the vault's instance storage TTL so its state stays live and does +/// not archive during idle periods. +/// +/// Re-exported from [`drip_common::ttl::bump_instance`] — see the module docs. +pub use drip_common::ttl::bump_instance; diff --git a/deny.toml b/deny.toml index dbb82ba7..c197ac06 100644 --- a/deny.toml +++ b/deny.toml @@ -1,7 +1,10 @@ # cargo-deny configuration # # Run locally: cargo deny check -# CI runs: cargo deny check licenses advisories +# CI runs: cargo deny check advisories bans licenses +# (`.github/workflows/deny.yml` — every section configured here must +# be listed in that workflow's matrix, or it silently stops being +# enforced; see issue #656.) [graph] targets = [ @@ -31,5 +34,9 @@ confidence-threshold = 0.8 [bans] # No crate bans configured yet; the goal is license/advisory checking. +# Enforced in CI since issue #656 — `cargo deny check bans` fails the build on +# a denied crate or wildcard hit. `multiple-versions` stays a warning because +# the dependency tree legitimately carries several semver-compatible versions +# of transitive crates (e.g. `syn`), and failing on that would be noise. multiple-versions = "warn" wildcards = "allow" diff --git a/scripts/deploy.sh b/scripts/deploy.sh index d3ca1b3a..c28615f4 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -307,8 +307,17 @@ else --admin "$AUTHORITY" fi -# BatchTransferProcessor is stateless — it has no `initialize` entry point, -# so uploading + deploying is the whole setup. +echo "⚙️ Initialising BatchTransferProcessor…" +# `process_batch` is permissionless and works uninitialised; `initialize` only +# records the admin allowed to call `upgrade` on the processor (issue #651). +if state_has batchProcessorInitialized; then + echo " already done on a previous run — skipped." +else + initialize_contract "BatchTransferProcessor" batchProcessorInitialized \ + --id "$BATCH_PROCESSOR_ID" \ + -- initialize \ + --admin "$AUTHORITY" +fi TOKEN_ADDRESS="${TOKEN_ADDRESS:-}" if [[ -z "$TOKEN_ADDRESS" ]]; then diff --git a/scripts/upgrade.sh b/scripts/upgrade.sh index cf31f05c..004d26a0 100755 --- a/scripts/upgrade.sh +++ b/scripts/upgrade.sh @@ -1,18 +1,17 @@ #!/usr/bin/env bash -# upgrade.sh — Upload new WASM and upgrade factory or governor. +# upgrade.sh — Upload new WASM and upgrade any deployed contract. # # Usage: # ./scripts/upgrade.sh testnet factory # ./scripts/upgrade.sh testnet governor +# ./scripts/upgrade.sh testnet oracle +# ./scripts/upgrade.sh testnet batch-processor +# ./scripts/upgrade.sh testnet token-vault # -# oracle, batch-processor, and token-vault are deployed by deploy.sh (#295) -# but are NOT supported here: none of their contracts currently expose an -# `upgrade` entry point (only DripFactory and DripGovernor do — see -# `pub fn upgrade` in contracts/factory/src/lib.rs and -# contracts/governor/src/lib.rs). Adding upgrade support for them means -# adding an admin-gated upgrade() function to each contract first, which is -# a contract-logic change, not a scripting one — tracked separately rather -# than faked here. +# Every deployed contract exposes an admin/owner-gated `upgrade` entry point +# (issue #651). The BatchTransferProcessor's gate is the admin recorded by its +# `initialize` (deploy.sh passes the deploy authority), so that contract must +# have been initialized before it can be upgraded. set -euo pipefail @@ -29,6 +28,13 @@ fi FACTORY_ID=$(jq -r '.factory' "$IDS_FILE") GOVERNOR_ID=$(jq -r '.governor' "$IDS_FILE") +ORACLE_ID=$(jq -r '.oracle' "$IDS_FILE") +BATCH_PROCESSOR_ID=$(jq -r '.batchProcessor' "$IDS_FILE") +TOKEN_VAULT_ID=$(jq -r '.tokenVault' "$IDS_FILE") + +# The upgrade authorities: governor address for the factory, deploy/admin +# address for the oracle, batch processor, and token vault. +AUTHORITY=$(stellar keys address dev 2>/dev/null || stellar keys address alice) echo "🔨 Building contracts…" cd "$ROOT_DIR" @@ -60,12 +66,44 @@ elif [[ "$CONTRACT" == "governor" ]]; then -- upgrade --new_wasm_hash "$NEW_HASH" echo "✅ DripGovernor upgraded." -elif [[ "$CONTRACT" == "oracle" || "$CONTRACT" == "batch-processor" || "$CONTRACT" == "token-vault" ]]; then - echo "❌ '$CONTRACT' has no on-chain 'upgrade' entry point yet — only 'factory' and 'governor' do." >&2 - echo " See the note at the top of this script." >&2 - exit 1 +elif [[ "$CONTRACT" == "oracle" ]]; then + echo "📤 Uploading new TwapOracle WASM…" + NEW_HASH=$(stellar contract upload \ + --wasm "$WASM_DIR/drip_oracle.wasm" \ + --network "$NETWORK" --source dev --quiet) + echo " New hash: $NEW_HASH" + stellar contract invoke \ + --id "$ORACLE_ID" \ + --network "$NETWORK" --source dev \ + -- upgrade --caller "$AUTHORITY" --new_wasm_hash "$NEW_HASH" + echo "✅ TwapOracle upgraded." + +elif [[ "$CONTRACT" == "batch-processor" ]]; then + echo "📤 Uploading new BatchTransferProcessor WASM…" + NEW_HASH=$(stellar contract upload \ + --wasm "$WASM_DIR/drip_batch_processor.wasm" \ + --network "$NETWORK" --source dev --quiet) + echo " New hash: $NEW_HASH" + stellar contract invoke \ + --id "$BATCH_PROCESSOR_ID" \ + --network "$NETWORK" --source dev \ + -- upgrade --caller "$AUTHORITY" --new_wasm_hash "$NEW_HASH" + echo "✅ BatchTransferProcessor upgraded." + +elif [[ "$CONTRACT" == "token-vault" ]]; then + echo "📤 Uploading new TokenVault WASM…" + NEW_HASH=$(stellar contract upload \ + --wasm "$WASM_DIR/token_vault.wasm" \ + --network "$NETWORK" --source dev --quiet) + echo " New hash: $NEW_HASH" + stellar contract invoke \ + --id "$TOKEN_VAULT_ID" \ + --network "$NETWORK" --source dev \ + -- upgrade --caller "$AUTHORITY" --new_wasm_hash "$NEW_HASH" + echo "✅ TokenVault upgraded." else - echo "❌ Unknown contract '$CONTRACT'. Use 'factory' or 'governor'." >&2 + echo "❌ Unknown contract '$CONTRACT'. Use 'factory', 'governor', 'oracle'," \ + "'batch-processor', or 'token-vault'." >&2 exit 1 fi