From 5a716aa1e505086f5d3a5ecec5019d59dbca9fc4 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 08:31:21 -0300 Subject: [PATCH 1/4] feat(oracle): timelock config and admin changes --- Cargo.lock | 1 + contracts/nav-oracle/Cargo.toml | 7 + contracts/nav-oracle/src/governance.rs | 187 ++++ contracts/nav-oracle/src/lib.rs | 217 ++++- contracts/nav-oracle/src/state.rs | 2 +- contracts/nav-oracle/src/test_governance.rs | 962 ++++++++++++++++++++ 6 files changed, 1362 insertions(+), 14 deletions(-) create mode 100644 contracts/nav-oracle/src/governance.rs create mode 100644 contracts/nav-oracle/src/test_governance.rs diff --git a/Cargo.lock b/Cargo.lock index 8b6319a..22a9197 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1214,6 +1214,7 @@ dependencies = [ "bindings", "soroban-sdk", "stellar-access", + "stellar-contract-utils", "stellar-macros", "storage", "timelock", diff --git a/contracts/nav-oracle/Cargo.toml b/contracts/nav-oracle/Cargo.toml index 23e0f66..5061ab9 100644 --- a/contracts/nav-oracle/Cargo.toml +++ b/contracts/nav-oracle/Cargo.toml @@ -16,6 +16,13 @@ stellar-access = { workspace = true } stellar-macros = { workspace = true } bindings = { path = "../../crates/bindings" } timelock = { path = "../../crates/timelock" } +stellar-contract-utils = { workspace = true } [dev-dependencies] soroban-sdk = { workspace = true, features = ["testutils"] } + +[features] +# Builds a stand-in for a later oracle version, used only by the upgrade test. +upgrade_probe = [] +# Runs the test that upgrades the built oracle to its probe build. Build both first. +upgrade_wasm = [] diff --git a/contracts/nav-oracle/src/governance.rs b/contracts/nav-oracle/src/governance.rs new file mode 100644 index 0000000..553af76 --- /dev/null +++ b/contracts/nav-oracle/src/governance.rs @@ -0,0 +1,187 @@ +use soroban_sdk::{contracttype, panic_with_error, Address, BytesN, Env, Symbol, Vec}; +use stellar_access::{ + access_control::{self, AccessControlStorageKey}, + role_transfer, +}; +use stellar_contract_utils::upgradeable; +use timelock::{queue, setup, Clock, Refusal, TimelockHost}; + +use crate::state::OracleConfig; +use crate::{ + store_config, validate_config, ChangeApplied, ChangeCancelled, ChangeProposed, + NavOracleContract, OracleError, SetupFinished, VaultBound, +}; + +/// Seven days of five-second ledgers: how long the next admin has to accept a +/// hand-over. +pub const ADMIN_ACCEPT_LEDGERS: u32 = 7 * 17_280; + +/// A privileged change that waits for the vault's timelock before it lands. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum OracleChange { + Upgrade(BytesN<32>), + GrantRole(Symbol, Address), + RevokeRole(Symbol, Address), + Admin(Address), + Config(OracleConfig), +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OraclePendingChange { + pub id: u32, + pub change: OracleChange, + pub eta: u64, +} + +/// True when `next` changes nothing but a shorter `freshness_duration`, or +/// nothing at all. A shorter window only lets reports go stale sooner: pricing +/// waits and cancellation reopens, so no value moves. Any other change can hold +/// the price away from the real value, a tighter cap included, so it waits. +pub fn shortens_freshness_only(current: &OracleConfig, next: &OracleConfig) -> bool { + let rest_unchanged = *next + == OracleConfig { + freshness_duration: next.freshness_duration, + ..current.clone() + }; + rest_unchanged && next.freshness_duration <= current.freshness_duration +} + +/// The oracle keeps no clock of its own: it reads the vault's. +impl TimelockHost for NavOracleContract { + fn clock(e: &Env) -> Result { + timelock::clock::from_source(e) + } +} + +fn refuse(e: &Env, refusal: Refusal) -> ! { + match refusal { + Refusal::WindingDown => panic_with_error!(e, OracleError::WindDownActive), + Refusal::Paused => panic_with_error!(e, OracleError::VaultPaused), + Refusal::NotElapsed => panic_with_error!(e, OracleError::ChangeNotReady), + Refusal::BelowExitWindow => panic_with_error!(e, OracleError::DelayBelowNotice), + Refusal::NotFound => panic_with_error!(e, OracleError::ChangeNotFound), + Refusal::TooMany => panic_with_error!(e, OracleError::TooManyPendingChanges), + Refusal::SetupOpen => panic_with_error!(e, OracleError::SetupOpen), + Refusal::SetupClosed | Refusal::AlreadyBound => { + panic_with_error!(e, OracleError::SetupClosed) + } + Refusal::Unbound => panic_with_error!(e, OracleError::VaultNotBound), + } +} + +fn vault_clock(e: &Env) -> Clock { + ::clock(e).unwrap_or_else(|r| refuse(e, r)) +} + +/// Direct admin calls are instant only before setup finishes. +pub(crate) fn require_setup_open(e: &Env) { + if !setup::is_open(e) { + panic_with_error!(e, OracleError::UseTimelock); + } +} + +fn upgrade_pending(e: &Env) -> bool { + queue::open_ids(e).iter().any(|id| { + queue::pending::(e, id) + .is_some_and(|(change, _)| matches!(change, OracleChange::Upgrade(_))) + }) +} + +pub(crate) fn bind_vault(e: &Env, vault: &Address) { + setup::bind_source(e, vault).unwrap_or_else(|r| refuse(e, r)); + VaultBound { + vault: vault.clone(), + } + .publish(e); +} + +/// No nominee from setup may accept once changes wait, so a hand-over offered +/// during setup holds it open. +pub(crate) fn finish_setup(e: &Env) { + if setup::is_open(e) + && role_transfer::has_active_pending_transfer(e, &AccessControlStorageKey::PendingAdmin) + { + panic_with_error!(e, OracleError::AdminTransferPending); + } + setup::finish::(e).unwrap_or_else(|r| refuse(e, r)); + SetupFinished {}.publish(e); +} + +pub(crate) fn propose(e: &Env, change: OracleChange) -> u32 { + if let OracleChange::Config(config) = &change { + validate_config(e, config); + } + if matches!(change, OracleChange::Upgrade(_)) && upgrade_pending(e) { + panic_with_error!(e, OracleError::UpgradeAlreadyPending); + } + let id = queue::propose::(e, &change).unwrap_or_else(|r| refuse(e, r)); + let (_, schedule) = + queue::pending::(e, id).unwrap_or_else(|| refuse(e, Refusal::NotFound)); + // No pause has run since the proposal, so the eta needs no second clock read. + ChangeProposed { + id, + change, + eta: schedule.proposed_at.saturating_add(schedule.delay), + } + .publish(e); + id +} + +pub(crate) fn cancel(e: &Env, id: u32) { + queue::cancel(e, id).unwrap_or_else(|r| refuse(e, r)); + ChangeCancelled { id }.publish(e); +} + +pub(crate) fn apply(e: &Env, id: u32) { + let change: OracleChange = + queue::take_ready::(e, id).unwrap_or_else(|r| refuse(e, r)); + let admin = access_control::get_admin(e) + .unwrap_or_else(|| panic_with_error!(e, OracleError::AdminRequired)); + match &change { + OracleChange::Upgrade(hash) => upgradeable::upgrade(e, hash), + OracleChange::GrantRole(role, account) => { + access_control::grant_role_no_auth(e, account, role, &admin) + } + OracleChange::RevokeRole(role, account) => { + access_control::revoke_role_no_auth(e, account, role, &admin) + } + OracleChange::Admin(next) => offer_admin(e, &admin, next), + OracleChange::Config(config) => { + validate_config(e, config); + store_config(e, config); + } + } + ChangeApplied { id, change }.publish(e); +} + +/// The first step of the two-step hand-over, without the admin authorisation +/// `transfer_admin_role` would ask for a second time inside `apply_change`. +fn offer_admin(e: &Env, admin: &Address, next: &Address) { + let live_until = e.ledger().sequence().saturating_add(ADMIN_ACCEPT_LEDGERS); + role_transfer::transfer_role(e, next, &AccessControlStorageKey::PendingAdmin, live_until); + access_control::emit_admin_transfer_initiated(e, admin, next, live_until); +} + +/// Every change still waiting, with the moment it may land on the vault's +/// clock as it reads now. +pub(crate) fn pending(e: &Env) -> Vec { + let mut out = Vec::new(e); + let open = queue::open_ids(e); + if open.is_empty() { + // Skips the vault, which is not bound yet during setup. + return out; + } + let clock = vault_clock(e); + for id in open.iter() { + if let Some((change, schedule)) = queue::pending::(e, id) { + out.push_back(OraclePendingChange { + id, + change, + eta: schedule.eta(&clock), + }); + } + } + out +} diff --git a/contracts/nav-oracle/src/lib.rs b/contracts/nav-oracle/src/lib.rs index 8c6f6f0..bf10ba7 100644 --- a/contracts/nav-oracle/src/lib.rs +++ b/contracts/nav-oracle/src/lib.rs @@ -1,17 +1,22 @@ #![no_std] +mod governance; mod state; -use bindings::OracleFeed; +use bindings::{OracleFeed, VaultBinding}; use soroban_sdk::{ contract, contracterror, contractevent, contractimpl, panic_with_error, Address, Env, Symbol, Vec, }; use stellar_access::access_control::{self, AccessControl}; +use stellar_contract_utils::upgradeable; use stellar_macros::{only_admin, only_role}; use state::{get_config, get_latest, nav_paused, ATTESTER_ROLE, GUARDIAN_ROLE}; +pub use governance::{ + shortens_freshness_only, OracleChange, OraclePendingChange, ADMIN_ACCEPT_LEDGERS, +}; pub use state::{NavReport, OracleConfig, OracleState}; const BPS_DENOM: i128 = 10_000; @@ -43,10 +48,40 @@ pub enum OracleError { NotConsumable = 3007, /// `clear_latest` was called while the NAV was not paused. NavNotPaused = 3008, - /// The oracle must always have an admin. + /// The oracle must always have an admin. Hand it over instead: propose and + /// apply an `Admin` change (`transfer_admin_role` during setup), then the + /// nominee calls `accept_admin_transfer`. AdminRequired = 3009, /// The NAV is paused. Unpause it first; a report never lifts it. NavPaused = 3010, + /// No pending change has this id. + ChangeNotFound = 3011, + /// The change's delay has not elapsed. + ChangeNotReady = 3012, + /// As many changes as the timelock holds already wait. + TooManyPendingChanges = 3013, + /// Changes are instant until setup finishes; there is nothing to propose yet. + SetupOpen = 3014, + /// A direct call cannot make this change. After setup it goes through + /// `propose_change`; setting a role admin is never allowed. + UseTimelock = 3015, + /// Only possible while setup is open, and only once. + SetupClosed = 3016, + /// Setup cannot finish before the oracle is bound to its vault. + VaultNotBound = 3017, + /// The vault is winding down, so no change may be proposed or applied. + WindDownActive = 3018, + /// The vault is paused, which holds every timelocked change. + VaultPaused = 3019, + /// The change's delay is shorter than the vault's notice, so an investor + /// could not complete an exit before it lands. + DelayBelowNotice = 3020, + /// An upgrade already waits. Cancel it before proposing another. + UpgradeAlreadyPending = 3021, + /// An admin hand-over offered during setup is still open. Setup finishes + /// once it is accepted, cancelled or expired, so no nominee from setup can + /// accept after changes start to wait. + AdminTransferPending = 3025, } #[contractevent] @@ -65,6 +100,36 @@ pub struct NavPausedSet { pub paused: bool, } +#[contractevent] +pub struct ChangeProposed { + #[topic] + pub id: u32, + pub change: OracleChange, + pub eta: u64, +} + +#[contractevent] +pub struct ChangeCancelled { + #[topic] + pub id: u32, +} + +#[contractevent] +pub struct ChangeApplied { + #[topic] + pub id: u32, + pub change: OracleChange, +} + +#[contractevent] +pub struct SetupFinished {} + +#[contractevent] +pub struct VaultBound { + #[topic] + pub vault: Address, +} + #[contractevent] pub struct ConfigSet { pub freshness_duration: u64, @@ -79,7 +144,7 @@ fn bps_in_range(bps: u32) -> bool { bps > 0 && bps as i128 <= BPS_DENOM } -fn validate_config(e: &Env, cfg: &OracleConfig) { +pub(crate) fn validate_config(e: &Env, cfg: &OracleConfig) { if cfg.freshness_duration == 0 || cfg.freshness_duration > MAX_FRESHNESS_SECS || cfg.cooldown_secs < MIN_COOLDOWN_SECS @@ -94,6 +159,19 @@ fn validate_config(e: &Env, cfg: &OracleConfig) { } } +pub(crate) fn store_config(e: &Env, config: &OracleConfig) { + state::set_config(e, config); + ConfigSet { + freshness_duration: config.freshness_duration, + cooldown_secs: config.cooldown_secs, + max_up_bps: config.max_up_bps, + max_down_bps: config.max_down_bps, + min_answer: config.min_answer, + max_answer: config.max_answer, + } + .publish(e); +} + fn in_band(cfg: &OracleConfig, nav_per_share: i128) -> bool { nav_per_share >= cfg.min_answer && nav_per_share <= cfg.max_answer } @@ -118,13 +196,50 @@ pub struct NavOracleContract; #[contractimpl(contracttrait)] impl AccessControl for NavOracleContract { + /// Instant during setup; after it, propose the grant through the timelock. + /// Revoking stays instant: taking an attester or a guardian away moves no + /// value. + fn grant_role(e: &Env, account: Address, role: Symbol, caller: Address) { + governance::require_setup_open(e); + storage::bump_instance(e); + access_control::grant_role(e, &account, &role, &caller); + } + + /// Cancelling a pending hand-over (`live_until_ledger == 0`) keeps the + /// current admin, so it stays instant. + fn transfer_admin_role(e: &Env, new_admin: Address, live_until_ledger: u32) { + if live_until_ledger != 0 { + governance::require_setup_open(e); + } + storage::bump_instance(e); + access_control::transfer_admin_role(e, &new_admin, live_until_ledger); + } + + /// Refused. A role admin could grant that role with no delay. + fn set_role_admin(e: &Env, _role: Symbol, _admin_role: Symbol) { + panic_with_error!(e, OracleError::UseTimelock); + } + /// Refused. An oracle with no admin can never be reconfigured, never clear a - /// stuck record and never unpause the NAV again. + /// stuck record and never unpause the NAV again. The way out is a hand-over: + /// propose and apply an `Admin` change (`transfer_admin_role` during setup), + /// then the nominee calls `accept_admin_transfer`. fn renounce_admin(e: &Env) { panic_with_error!(e, OracleError::AdminRequired); } } +#[contractimpl(contracttrait)] +impl VaultBinding for NavOracleContract { + fn vault(e: &Env) -> Option
{ + timelock::setup::source(e) + } + + fn setup_open(e: &Env) -> bool { + timelock::setup::is_open(e) + } +} + #[contractimpl] impl NavOracleContract { pub fn __constructor( @@ -141,6 +256,56 @@ impl NavOracleContract { state::set_config(e, &config); state::set_nav_paused(e, false); + upgradeable::set_schema_version(e, 1); + } + + pub fn schema_version(e: &Env) -> u32 { + upgradeable::get_schema_version(e) + } + + /// Names the vault whose clock this oracle's timelock reads. Once, and only + /// during setup. + #[only_admin] + pub fn bind_vault(e: &Env, vault: Address, caller: Address) { + caller.require_auth(); + storage::bump_instance(e); + governance::bind_vault(e, &vault); + } + + /// Ends the window in which admin changes are instant. It never reopens. + #[only_admin] + pub fn finish_setup(e: &Env, caller: Address) { + caller.require_auth(); + storage::bump_instance(e); + governance::finish_setup(e); + } + + /// Queues a change behind the vault's timelock and returns its id. + #[only_admin] + pub fn propose_change(e: &Env, change: OracleChange, caller: Address) -> u32 { + caller.require_auth(); + storage::bump_instance(e); + governance::propose(e, change) + } + + #[only_admin] + pub fn cancel_change(e: &Env, id: u32, caller: Address) { + caller.require_auth(); + storage::bump_instance(e); + governance::cancel(e, id); + } + + /// Lands a change whose delay has run. Refused while the vault is paused + /// or winding down. + #[only_admin] + pub fn apply_change(e: &Env, id: u32, caller: Address) { + caller.require_auth(); + storage::bump_instance(e); + governance::apply(e, id); + } + + pub fn pending_changes(e: &Env) -> Vec { + governance::pending(e) } pub fn latest(e: &Env) -> NavReport { @@ -240,19 +405,16 @@ impl NavOracleContract { get_config(e) } + /// Instant during setup. Afterwards, instant only when the new config + /// changes nothing but a shorter `freshness_duration`; every other change + /// waits. Propose it as `OracleChange::Config`. #[only_admin] pub fn set_config(e: &Env, config: OracleConfig) { validate_config(e, &config); - state::set_config(e, &config); - ConfigSet { - freshness_duration: config.freshness_duration, - cooldown_secs: config.cooldown_secs, - max_up_bps: config.max_up_bps, - max_down_bps: config.max_down_bps, - min_answer: config.min_answer, - max_answer: config.max_answer, + if !timelock::setup::is_open(e) && !shortens_freshness_only(&get_config(e), &config) { + panic_with_error!(e, OracleError::UseTimelock); } - .publish(e); + store_config(e, &config); } } @@ -277,5 +439,34 @@ impl OracleFeed for NavOracleContract { } } +/// Stands in for a later oracle version in the upgrade test. Never deployed. +#[cfg(feature = "upgrade_probe")] +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum ProbeError { + AlreadyMigrated = 3100, +} + +#[cfg(feature = "upgrade_probe")] +#[contractimpl] +impl NavOracleContract { + #[only_admin] + pub fn migrate(e: &Env, caller: Address) { + caller.require_auth(); + storage::bump_instance(e); + if upgradeable::get_schema_version(e) >= 2 { + panic_with_error!(e, ProbeError::AlreadyMigrated); + } + upgradeable::set_schema_version(e, 2); + } + + pub fn probe(_e: &Env) -> bool { + true + } +} + #[cfg(test)] mod test; +#[cfg(test)] +mod test_governance; diff --git a/contracts/nav-oracle/src/state.rs b/contracts/nav-oracle/src/state.rs index 917f6d8..59af24e 100644 --- a/contracts/nav-oracle/src/state.rs +++ b/contracts/nav-oracle/src/state.rs @@ -28,7 +28,7 @@ pub struct NavReport { } #[contracttype] -#[derive(Clone)] +#[derive(Clone, Debug, Eq, PartialEq)] pub struct OracleConfig { pub freshness_duration: u64, // before nav is considered stale pub cooldown_secs: u64, // before new record is admitted diff --git a/contracts/nav-oracle/src/test_governance.rs b/contracts/nav-oracle/src/test_governance.rs new file mode 100644 index 0000000..ebb1b58 --- /dev/null +++ b/contracts/nav-oracle/src/test_governance.rs @@ -0,0 +1,962 @@ +use soroban_sdk::{ + contract, contractimpl, symbol_short, + testutils::{ + storage::Instance as _, Address as _, Events as _, Ledger as _, MockAuth, MockAuthInvoke, + }, + Address, BytesN, Env, Event as _, IntoVal, +}; +use stellar_access::role_transfer::RoleTransferError; +use storage::INSTANCE_EXTEND_AMOUNT; +use timelock::{Clock, DAY_DURATION, MIN_DELAY}; + +use crate::state::{ATTESTER_ROLE, GUARDIAN_ROLE}; +use crate::{ + shortens_freshness_only, ChangeApplied, ChangeProposed, ConfigSet, NavOracleContract, + NavOracleContractClient, NavReport, OracleChange, OracleConfig, OracleError, OracleState, + ADMIN_ACCEPT_LEDGERS, +}; + +const SCALE: i128 = 1_000_000_000_000_000_000; +const TWO_DAYS_IN_LEDGERS: u32 = 2 * 17_280; + +/// Plays the vault: reports whatever clock a test sets, and counts the reads. +#[contract] +struct ClockStub; + +#[contractimpl] +impl ClockStub { + pub fn set(e: Env, clock: Clock) { + e.storage().instance().set(&symbol_short!("clock"), &clock); + } + + pub fn reads(e: Env) -> u32 { + e.storage() + .instance() + .get(&symbol_short!("reads")) + .unwrap_or(0) + } +} + +#[contractimpl(contracttrait)] +impl timelock::ClockSource for ClockStub { + fn timelock_clock(e: &Env) -> Clock { + let reads = Self::reads(e.clone()) + 1; + e.storage().instance().set(&symbol_short!("reads"), &reads); + e.storage().instance().get(&symbol_short!("clock")).unwrap() + } +} + +type OracleArgs = (Address, Address, Address, OracleConfig); + +struct Fixture<'a> { + e: Env, + oracle: NavOracleContractClient<'a>, + vault: ClockStubClient<'a>, + admin: Address, + attester: Address, + guardian: Address, +} + +impl Fixture<'_> { + fn clock(&self, f: impl FnOnce(&mut Clock)) { + let mut clock = + timelock::ClockSourceClient::new(&self.e, &self.vault.address).timelock_clock(); + f(&mut clock); + self.vault.set(&clock); + } + + fn advance(&self, secs: u64) { + self.e.ledger().with_mut(|l| { + l.timestamp += secs; + l.sequence_number += (secs / 5).max(1) as u32; + }); + } + + fn finish(&self) { + self.oracle.bind_vault(&self.vault.address, &self.admin); + self.oracle.finish_setup(&self.admin); + } + + fn propose(&self, change: OracleChange) -> u32 { + self.oracle.propose_change(&change, &self.admin) + } + + fn attest(&self, nav_per_share: i128) { + self.oracle.attest(&report(nav_per_share), &self.attester); + } + + /// Lands an `Admin` change for `next` and returns the ledger of the offer. + fn offer_admin(&self, next: &Address) -> u32 { + let id = self.propose(OracleChange::Admin(next.clone())); + self.advance(MIN_DELAY); + self.oracle.apply_change(&id, &self.admin); + self.e.ledger().sequence() + } + + fn instance_ttl(&self) -> u32 { + self.e.as_contract(&self.oracle.address, || { + self.e.storage().instance().get_ttl() + }) + } + + /// Lets the instance decay past its extend threshold, then checks the call + /// extends it back to the full TTL. + fn restores_the_instance(&self, call: impl FnOnce()) { + self.e + .ledger() + .with_mut(|l| l.sequence_number += TWO_DAYS_IN_LEDGERS); + assert!(self.instance_ttl() < INSTANCE_EXTEND_AMOUNT); + call(); + assert_eq!(self.instance_ttl(), INSTANCE_EXTEND_AMOUNT); + } +} + +fn config() -> OracleConfig { + OracleConfig { + freshness_duration: 3_600, + cooldown_secs: 60, + max_up_bps: 1_000, + max_down_bps: 1_000, + min_answer: SCALE / 2, + max_answer: SCALE * 100, + } +} + +fn report(nav_per_share: i128) -> NavReport { + NavReport { + nav_per_share, + expires_at: u64::MAX, + timestamp: 0, + } +} + +fn setup_with<'a>(register: impl FnOnce(&Env, OracleArgs) -> Address) -> Fixture<'a> { + let e = Env::default(); + e.mock_all_auths(); + e.ledger().set_timestamp(10_000); + let admin = Address::generate(&e); + let attester = Address::generate(&e); + let guardian = Address::generate(&e); + let oracle = register( + &e, + (admin.clone(), attester.clone(), guardian.clone(), config()), + ); + let vault = ClockStubClient::new(&e, &e.register(ClockStub, ())); + vault.set(&Clock { + delay: MIN_DELAY, + exit_window: 0, + paused_secs: 0, + paused: false, + winding_down: false, + }); + Fixture { + oracle: NavOracleContractClient::new(&e, &oracle), + vault, + admin, + attester, + guardian, + e, + } +} + +fn setup<'a>() -> Fixture<'a> { + setup_with(|e, args| e.register(NavOracleContract, args)) +} + +fn refused( + result: Result, Result>, + expected: OracleError, +) { + assert_eq!(result, Err(Ok(expected.into()))); +} + +#[test] +fn only_a_shorter_freshness_window_is_instant() { + let base = config(); + let waits = [ + OracleConfig { + freshness_duration: 7_200, + ..config() + }, + OracleConfig { + cooldown_secs: 30, + ..config() + }, + OracleConfig { + cooldown_secs: 120, + ..config() + }, + OracleConfig { + max_up_bps: 500, + ..config() + }, + OracleConfig { + max_up_bps: 2_000, + ..config() + }, + OracleConfig { + max_down_bps: 500, + ..config() + }, + OracleConfig { + max_down_bps: 2_000, + ..config() + }, + OracleConfig { + min_answer: SCALE, + ..config() + }, + OracleConfig { + min_answer: SCALE / 4, + ..config() + }, + OracleConfig { + max_answer: SCALE * 50, + ..config() + }, + OracleConfig { + max_answer: SCALE * 200, + ..config() + }, + OracleConfig { + freshness_duration: 1_800, + max_up_bps: 500, + ..config() + }, + ]; + + for next in waits.iter() { + assert!(!shortens_freshness_only(&base, next), "{next:?} waits"); + } + assert!(shortens_freshness_only( + &base, + &OracleConfig { + freshness_duration: 1_800, + ..config() + } + )); + assert!(shortens_freshness_only(&base, &base)); +} + +#[test] +fn a_config_that_tightens_one_field_and_loosens_another_waits() { + let f = setup(); + f.finish(); + let mixed = OracleConfig { + max_up_bps: 500, + freshness_duration: 7_200, + ..config() + }; + + refused(f.oracle.try_set_config(&mixed), OracleError::UseTimelock); + assert_eq!(f.oracle.config(), config()); +} + +#[test] +fn a_shorter_freshness_window_stays_instant_after_setup() { + let f = setup(); + f.finish(); + let shorter = OracleConfig { + freshness_duration: 1_800, + ..config() + }; + + f.oracle.set_config(&shorter); + + assert_eq!(f.oracle.config(), shorter); +} + +#[test] +fn a_shorter_freshness_window_with_any_other_change_waits() { + let f = setup(); + f.finish(); + let shorter_and_tighter = OracleConfig { + freshness_duration: 1_800, + max_down_bps: 500, + ..config() + }; + + refused( + f.oracle.try_set_config(&shorter_and_tighter), + OracleError::UseTimelock, + ); + assert_eq!(f.oracle.config(), config()); +} + +#[test] +fn a_tighter_cap_or_band_waits_after_setup() { + let f = setup(); + f.finish(); + + for tighter in [ + OracleConfig { + max_down_bps: 500, + ..config() + }, + OracleConfig { + cooldown_secs: 120, + ..config() + }, + OracleConfig { + min_answer: SCALE, + ..config() + }, + ] { + refused(f.oracle.try_set_config(&tighter), OracleError::UseTimelock); + } + assert_eq!(f.oracle.config(), config()); +} + +#[test] +fn any_config_is_instant_during_setup() { + let f = setup(); + let loose = OracleConfig { + freshness_duration: 7_200, + ..config() + }; + + f.oracle.set_config(&loose); + + assert_eq!(f.oracle.config(), loose); +} + +#[test] +fn a_loosened_config_lands_through_the_timelock() { + let f = setup(); + f.finish(); + let loose = OracleConfig { + freshness_duration: 7_200, + ..config() + }; + let id = f.propose(OracleChange::Config(loose.clone())); + + f.advance(MIN_DELAY - 1); + refused( + f.oracle.try_apply_change(&id, &f.admin), + OracleError::ChangeNotReady, + ); + f.advance(1); + f.oracle.apply_change(&id, &f.admin); + + assert_eq!(f.oracle.config(), loose); + assert_eq!(f.oracle.pending_changes().len(), 0); +} + +#[test] +fn an_invalid_config_cannot_be_proposed() { + let f = setup(); + f.finish(); + let invalid = OracleConfig { + freshness_duration: 0, + ..config() + }; + + refused( + f.oracle + .try_propose_change(&OracleChange::Config(invalid), &f.admin), + OracleError::InvalidConfig, + ); +} + +#[test] +fn a_config_outside_the_bounds_is_refused_when_it_applies() { + let f = setup(); + f.finish(); + let invalid = OracleConfig { + freshness_duration: 0, + ..config() + }; + // Skips the proposal's check, like a change proposed before a later + // version narrowed the bounds. + let id = f.e.as_contract(&f.oracle.address, || { + timelock::queue::propose::(&f.e, &OracleChange::Config(invalid)) + .unwrap() + }); + f.advance(MIN_DELAY); + + refused( + f.oracle.try_apply_change(&id, &f.admin), + OracleError::InvalidConfig, + ); + assert_eq!(f.oracle.config(), config()); + assert_eq!(f.oracle.pending_changes().len(), 1); +} + +#[test] +fn setup_is_open_until_finished_and_never_reopens() { + let f = setup(); + assert!(f.oracle.setup_open()); + refused( + f.oracle.try_finish_setup(&f.admin), + OracleError::VaultNotBound, + ); + + f.finish(); + + assert!(!f.oracle.setup_open()); + assert_eq!(f.oracle.vault(), Some(f.vault.address.clone())); + refused( + f.oracle.try_finish_setup(&f.admin), + OracleError::SetupClosed, + ); + refused( + f.oracle.try_bind_vault(&Address::generate(&f.e), &f.admin), + OracleError::SetupClosed, + ); +} + +#[test] +fn a_second_vault_cannot_be_bound() { + let f = setup(); + f.oracle.bind_vault(&f.vault.address, &f.admin); + + refused( + f.oracle.try_bind_vault(&Address::generate(&f.e), &f.admin), + OracleError::SetupClosed, + ); + assert_eq!(f.oracle.vault(), Some(f.vault.address.clone())); +} + +#[test] +fn an_address_that_reports_no_clock_cannot_be_bound() { + let f = setup(); + + assert!(f + .oracle + .try_bind_vault(&Address::generate(&f.e), &f.admin) + .is_err()); + assert_eq!(f.oracle.vault(), None); +} + +#[test] +fn only_the_admin_binds_and_finishes() { + let f = setup(); + let stranger = Address::generate(&f.e); + f.e.set_auths(&[]); + + assert!(f + .oracle + .try_bind_vault(&f.vault.address, &stranger) + .is_err()); + assert!(f.oracle.try_finish_setup(&stranger).is_err()); + assert!(f.oracle.setup_open()); +} + +fn implements_the_vault_view() {} + +#[test] +fn the_oracle_answers_the_view_the_vault_reads() { + implements_the_vault_view::(); + let f = setup(); + let view = bindings::VaultBindingClient::new(&f.e, &f.oracle.address); + assert_eq!(view.vault(), None); + assert!(view.setup_open()); + + f.finish(); + + assert_eq!(view.vault(), Some(f.vault.address.clone())); + assert!(!view.setup_open()); +} + +#[test] +fn setup_cannot_finish_while_a_hand_over_is_pending() { + let f = setup(); + let next = Address::generate(&f.e); + f.oracle.bind_vault(&f.vault.address, &f.admin); + f.oracle + .transfer_admin_role(&next, &(f.e.ledger().sequence() + 100)); + + refused( + f.oracle.try_finish_setup(&f.admin), + OracleError::AdminTransferPending, + ); + + f.oracle.transfer_admin_role(&next, &0); + f.oracle.finish_setup(&f.admin); + assert!(f.oracle.try_accept_admin_transfer().is_err()); + assert_eq!(f.oracle.get_admin(), Some(f.admin.clone())); +} + +#[test] +fn an_expired_hand_over_does_not_hold_setup_open() { + let f = setup(); + f.oracle.bind_vault(&f.vault.address, &f.admin); + f.oracle + .transfer_admin_role(&Address::generate(&f.e), &(f.e.ledger().sequence() + 10)); + f.e.ledger().with_mut(|l| l.sequence_number += 11); + + f.oracle.finish_setup(&f.admin); + + assert!(!f.oracle.setup_open()); + assert_eq!( + f.oracle.try_accept_admin_transfer(), + Err(Ok(RoleTransferError::NoPendingTransfer.into())) + ); + assert_eq!(f.oracle.get_admin(), Some(f.admin.clone())); +} + +#[test] +fn nothing_is_proposed_during_setup() { + let f = setup(); + + refused( + f.oracle + .try_propose_change(&OracleChange::Config(config()), &f.admin), + OracleError::SetupOpen, + ); + assert_eq!(f.oracle.pending_changes().len(), 0); +} + +#[test] +fn grants_wait_after_setup_and_land_through_the_timelock() { + let f = setup(); + f.finish(); + let next = Address::generate(&f.e); + + refused( + f.oracle.try_grant_role(&next, &GUARDIAN_ROLE, &f.admin), + OracleError::UseTimelock, + ); + + let grant = f.propose(OracleChange::GrantRole(GUARDIAN_ROLE, next.clone())); + f.advance(MIN_DELAY); + f.oracle.apply_change(&grant, &f.admin); + + f.oracle.pause_nav(&next); + assert_eq!(f.oracle.state(), OracleState::Paused); +} + +#[test] +fn revoking_an_attester_or_a_guardian_stays_instant_after_setup() { + let f = setup(); + f.finish(); + + f.oracle.revoke_role(&f.attester, &ATTESTER_ROLE, &f.admin); + f.oracle.revoke_role(&f.guardian, &GUARDIAN_ROLE, &f.admin); + + assert!(f.oracle.has_role(&f.attester, &ATTESTER_ROLE).is_none()); + assert!(f.oracle.has_role(&f.guardian, &GUARDIAN_ROLE).is_none()); + assert!(f.oracle.try_attest(&report(SCALE), &f.attester).is_err()); + assert!(f.oracle.try_pause_nav(&f.guardian).is_err()); +} + +#[test] +fn only_the_admin_revokes_a_role() { + let f = setup(); + f.finish(); + let stranger = Address::generate(&f.e); + + assert!(f + .oracle + .try_revoke_role(&f.attester, &ATTESTER_ROLE, &stranger) + .is_err()); + f.e.set_auths(&[]); + assert!(f + .oracle + .try_revoke_role(&f.attester, &ATTESTER_ROLE, &f.admin) + .is_err()); + assert!(f.oracle.has_role(&f.attester, &ATTESTER_ROLE).is_some()); +} + +#[test] +fn a_revoke_lands_through_the_timelock() { + let f = setup(); + f.finish(); + + let id = f.propose(OracleChange::RevokeRole(GUARDIAN_ROLE, f.guardian.clone())); + f.advance(MIN_DELAY); + f.oracle.apply_change(&id, &f.admin); + + assert!(f.oracle.has_role(&f.guardian, &GUARDIAN_ROLE).is_none()); +} + +#[test] +fn no_role_ever_gets_a_role_admin() { + let f = setup(); + + refused( + f.oracle.try_set_role_admin(&GUARDIAN_ROLE, &ATTESTER_ROLE), + OracleError::UseTimelock, + ); +} + +#[test] +fn an_admin_hand_over_waits_but_cancelling_one_does_not() { + let f = setup(); + f.finish(); + let next = Address::generate(&f.e); + + refused( + f.oracle + .try_transfer_admin_role(&next, &(f.e.ledger().sequence() + 100)), + OracleError::UseTimelock, + ); + + let id = f.propose(OracleChange::Admin(next.clone())); + f.advance(MIN_DELAY); + f.oracle.apply_change(&id, &f.admin); + f.oracle.transfer_admin_role(&next, &0); + + assert!(f.oracle.try_accept_admin_transfer().is_err()); + assert_eq!(f.oracle.get_admin(), Some(f.admin.clone())); +} + +#[test] +fn an_applied_admin_change_starts_a_hand_over_the_next_admin_accepts() { + let f = setup(); + f.finish(); + let next = Address::generate(&f.e); + let id = f.propose(OracleChange::Admin(next.clone())); + f.advance(MIN_DELAY); + + // The admin authorises this call and nothing else: the hand-over inside it + // asks for no further signature. The entrypoint checks the admin and the + // caller, so the same authorisation appears twice, as simulation records it. + let apply = MockAuthInvoke { + contract: &f.oracle.address, + fn_name: "apply_change", + args: (id, f.admin.clone()).into_val(&f.e), + sub_invokes: &[], + }; + f.e.mock_auths(&[ + MockAuth { + address: &f.admin, + invoke: &apply, + }, + MockAuth { + address: &f.admin, + invoke: &apply, + }, + ]); + f.oracle.apply_change(&id, &f.admin); + + f.e.mock_all_auths(); + assert_eq!(f.oracle.get_admin(), Some(f.admin.clone())); + f.oracle.accept_admin_transfer(); + assert_eq!(f.oracle.get_admin(), Some(next)); +} + +#[test] +fn the_hand_over_offer_is_open_on_its_last_ledger() { + let f = setup(); + f.finish(); + let next = Address::generate(&f.e); + let offered_at = f.offer_admin(&next); + + f.e.ledger() + .with_mut(|l| l.sequence_number = offered_at + ADMIN_ACCEPT_LEDGERS); + f.oracle.accept_admin_transfer(); + + assert_eq!(f.oracle.get_admin(), Some(next)); +} + +#[test] +fn the_hand_over_offer_lasts_seven_days_of_ledgers() { + let f = setup(); + f.finish(); + let offered_at = f.offer_admin(&Address::generate(&f.e)); + + f.e.ledger() + .with_mut(|l| l.sequence_number = offered_at + ADMIN_ACCEPT_LEDGERS + 1); + + assert_eq!( + f.oracle.try_accept_admin_transfer(), + Err(Ok(RoleTransferError::NoPendingTransfer.into())) + ); + assert_eq!(f.oracle.get_admin(), Some(f.admin.clone())); +} + +#[test] +fn the_nav_pause_and_clearing_stay_instant_after_setup() { + let f = setup(); + f.attest(SCALE); + f.finish(); + + f.oracle.pause_nav(&f.guardian); + f.oracle.set_nav_paused(&false, &f.admin); + f.oracle.set_nav_paused(&true, &f.admin); + f.oracle.clear_latest(&f.admin); + + assert_eq!(f.oracle.state(), OracleState::Paused); + assert_eq!( + f.oracle.try_latest().err(), + Some(Ok(OracleError::NoRecord.into())) + ); +} + +#[test] +fn a_vault_pause_moves_the_eta_and_holds_the_change() { + let f = setup(); + f.finish(); + let id = f.propose(OracleChange::Config(OracleConfig { + freshness_duration: 7_200, + ..config() + })); + let eta = f.oracle.pending_changes().get(0).unwrap().eta; + + f.advance(DAY_DURATION); + f.clock(|c| c.paused = true); + f.advance(3 * DAY_DURATION); + f.clock(|c| c.paused_secs = 3 * DAY_DURATION); + f.advance(MIN_DELAY - 4 * DAY_DURATION); + + refused( + f.oracle.try_apply_change(&id, &f.admin), + OracleError::VaultPaused, + ); + + f.clock(|c| c.paused = false); + assert_eq!( + f.oracle.pending_changes().get(0).unwrap().eta, + eta + 3 * DAY_DURATION + ); + refused( + f.oracle.try_apply_change(&id, &f.admin), + OracleError::ChangeNotReady, + ); + + f.advance(3 * DAY_DURATION); + f.oracle.apply_change(&id, &f.admin); + assert_eq!(f.oracle.config().freshness_duration, 7_200); +} + +#[test] +fn nothing_is_proposed_or_applied_while_the_vault_winds_down() { + let f = setup(); + f.finish(); + let id = f.propose(OracleChange::GrantRole( + GUARDIAN_ROLE, + Address::generate(&f.e), + )); + f.clock(|c| c.winding_down = true); + f.advance(MIN_DELAY); + + refused( + f.oracle + .try_propose_change(&OracleChange::Config(config()), &f.admin), + OracleError::WindDownActive, + ); + refused( + f.oracle.try_apply_change(&id, &f.admin), + OracleError::WindDownActive, + ); + f.oracle.cancel_change(&id, &f.admin); +} + +#[test] +fn a_change_shorter_than_a_raised_notice_never_lands() { + let f = setup(); + f.finish(); + let id = f.propose(OracleChange::GrantRole( + GUARDIAN_ROLE, + Address::generate(&f.e), + )); + f.clock(|c| { + c.delay = 30 * DAY_DURATION; + c.exit_window = 20 * DAY_DURATION; + }); + f.advance(30 * DAY_DURATION); + + refused( + f.oracle.try_apply_change(&id, &f.admin), + OracleError::DelayBelowNotice, + ); +} + +#[test] +fn one_upgrade_waits_at_a_time() { + let f = setup(); + f.finish(); + f.propose(OracleChange::Upgrade(BytesN::from_array(&f.e, &[7; 32]))); + + refused( + f.oracle.try_propose_change( + &OracleChange::Upgrade(BytesN::from_array(&f.e, &[9; 32])), + &f.admin, + ), + OracleError::UpgradeAlreadyPending, + ); +} + +#[test] +fn only_the_admin_proposes_cancels_and_applies() { + let f = setup(); + f.finish(); + let stranger = Address::generate(&f.e); + let change = OracleChange::Config(config()); + let id = f.propose(change.clone()); + f.advance(MIN_DELAY); + + f.e.set_auths(&[]); + assert!(f.oracle.try_propose_change(&change, &stranger).is_err()); + assert!(f.oracle.try_cancel_change(&id, &stranger).is_err()); + assert!(f.oracle.try_apply_change(&id, &stranger).is_err()); + assert_eq!(f.oracle.pending_changes().len(), 1); +} + +#[test] +fn proposing_and_applying_publish_the_change() { + let f = setup(); + f.finish(); + let loose = OracleConfig { + freshness_duration: 7_200, + ..config() + }; + let change = OracleChange::Config(loose.clone()); + let eta = f.e.ledger().timestamp() + MIN_DELAY; + + let id = f.propose(change.clone()); + assert_eq!( + f.e.events().all().filter_by_contract(&f.oracle.address), + [ChangeProposed { + id, + change: change.clone(), + eta, + } + .to_xdr(&f.e, &f.oracle.address)] + ); + + f.advance(MIN_DELAY); + f.oracle.apply_change(&id, &f.admin); + assert_eq!( + f.e.events().all().filter_by_contract(&f.oracle.address), + [ + ConfigSet { + freshness_duration: loose.freshness_duration, + cooldown_secs: loose.cooldown_secs, + max_up_bps: loose.max_up_bps, + max_down_bps: loose.max_down_bps, + min_answer: loose.min_answer, + max_answer: loose.max_answer, + } + .to_xdr(&f.e, &f.oracle.address), + ChangeApplied { id, change }.to_xdr(&f.e, &f.oracle.address), + ] + ); +} + +#[test] +fn proposing_reads_the_vault_clock_once() { + let f = setup(); + f.finish(); + let before = f.vault.reads(); + + f.propose(OracleChange::Config(config())); + + assert_eq!(f.vault.reads(), before + 1); +} + +#[test] +fn a_proposal_after_a_past_pause_waits_one_delay_from_now() { + let f = setup(); + f.finish(); + f.clock(|c| c.paused_secs = 3 * DAY_DURATION); + let change = OracleChange::Config(config()); + let eta = f.e.ledger().timestamp() + MIN_DELAY; + + let id = f.propose(change.clone()); + + assert_eq!( + f.e.events().all().filter_by_contract(&f.oracle.address), + [ChangeProposed { id, change, eta }.to_xdr(&f.e, &f.oracle.address)] + ); + assert_eq!(f.oracle.pending_changes().get(0).unwrap().eta, eta); +} + +#[test] +fn the_oracle_exposes_no_immediate_upgrade() { + let f = setup(); + f.finish(); + let upgrader = + stellar_contract_utils::upgradeable::UpgradeableClient::new(&f.e, &f.oracle.address); + + assert!(upgrader + .try_upgrade(&BytesN::from_array(&f.e, &[7; 32]), &f.admin) + .is_err()); + assert_eq!(f.oracle.schema_version(), 1); +} + +#[test] +fn the_schema_version_is_one_after_construction() { + let f = setup(); + + assert_eq!(f.oracle.schema_version(), 1); +} + +#[test] +fn setup_grants_hand_overs_and_changes_restore_the_instance_ttl() { + let f = setup(); + let holder = Address::generate(&f.e); + let next = Address::generate(&f.e); + + f.restores_the_instance(|| f.oracle.grant_role(&holder, &GUARDIAN_ROLE, &f.admin)); + f.restores_the_instance(|| { + f.oracle + .transfer_admin_role(&next, &(f.e.ledger().sequence() + ADMIN_ACCEPT_LEDGERS)) + }); + f.restores_the_instance(|| f.oracle.transfer_admin_role(&next, &0)); + f.restores_the_instance(|| f.oracle.bind_vault(&f.vault.address, &f.admin)); + f.restores_the_instance(|| f.oracle.finish_setup(&f.admin)); + + let change = OracleChange::Config(config()); + let mut id = 0; + f.restores_the_instance(|| id = f.propose(change.clone())); + f.restores_the_instance(|| f.oracle.cancel_change(&id, &f.admin)); + + let id = f.propose(change); + f.advance(MIN_DELAY); + f.restores_the_instance(|| f.oracle.apply_change(&id, &f.admin)); +} + +#[cfg(feature = "upgrade_wasm")] +mod with_wasm { + use super::*; + + mod oracle_v1 { + soroban_sdk::contractimport!(file = "../../target/wasm32v1-none/release/nav_oracle.wasm"); + } + + mod oracle_probe { + soroban_sdk::contractimport!( + file = "../../target/upgrade-probe/wasm32v1-none/release/nav_oracle.wasm" + ); + } + + #[test] + fn the_oracle_upgrades_under_a_live_valuation() { + let f = setup_with(|e, args| e.register(oracle_v1::WASM, args)); + f.attest(SCALE); + f.finish(); + let probe_hash = f.e.deployer().upload_contract_wasm(oracle_probe::WASM); + let id = f.propose(OracleChange::Upgrade(probe_hash)); + f.advance(MIN_DELAY); + + f.oracle.apply_change(&id, &f.admin); + + let probe = oracle_probe::Client::new(&f.e, &f.oracle.address); + assert!(probe.probe()); + assert_eq!(probe.schema_version(), 1); + probe.migrate(&f.admin); + assert_eq!(probe.schema_version(), 2); + assert_eq!( + probe.try_migrate(&f.admin), + Err(Ok(oracle_probe::ProbeError::AlreadyMigrated.into())) + ); + + let kept = f.oracle.latest(); + assert_eq!( + (kept.nav_per_share, kept.timestamp, kept.expires_at), + (SCALE, 10_000, 10_000 + config().freshness_duration) + ); + assert_eq!(f.oracle.config(), config()); + assert_eq!(f.oracle.get_admin(), Some(f.admin.clone())); + assert_eq!(f.oracle.vault(), Some(f.vault.address.clone())); + assert!(!f.oracle.setup_open()); + assert_eq!(f.oracle.pending_changes().len(), 0); + + f.attest(SCALE + SCALE / 20); + assert_eq!(f.oracle.nav_per_share(), SCALE + SCALE / 20); + f.oracle.pause_nav(&f.guardian); + assert_eq!(f.oracle.state(), OracleState::Paused); + } +} From 83b6ac416bcbcf5b7e88818ee6f3008482673ee0 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 08:31:23 -0300 Subject: [PATCH 2/4] feat(harness): finish the oracle's setup on deploy --- scripts/harness/deploy.ts | 13 +++++++++ scripts/harness/extend.ts | 6 +++- scripts/harness/guards.test.ts | 52 ++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/scripts/harness/deploy.ts b/scripts/harness/deploy.ts index ed4e059..5d6bc49 100644 --- a/scripts/harness/deploy.ts +++ b/scripts/harness/deploy.ts @@ -7,6 +7,7 @@ import { basicNodeSigner } from "@stellar/stellar-sdk/contract" import type * as ComplianceModule from "../../app-lib/clients/compliance/dist/index.js" import type * as IdentityVerifierModule from "../../app-lib/clients/identity_verifier/dist/index.js" +import type * as NavOracleModule from "../../app-lib/clients/nav_oracle/dist/index.js" import type * as ShareTokenModule from "../../app-lib/clients/share_token/dist/index.js" import { freshAccounts, fund, type Accounts } from "./lib/accounts.js" @@ -197,6 +198,18 @@ async function grantAndVerify( if (!linked.includes(contracts.share_token)) throw new Error("the share token was not bound to the compliance contract") console.log("share_token bound to compliance") + + const { Client: NavOracle } = (await import( + clientEntry("nav_oracle") + )) as typeof NavOracleModule + const feed = new NavOracle( + clientOptions(net, contracts.nav_oracle, governance), + ) + await (await feed.bind_vault({ vault, caller })).signAndSend() + await (await feed.finish_setup({ caller })).signAndSend() + if ((await feed.setup_open()).result) + throw new Error("the oracle's setup did not finish") + console.log("nav_oracle bound to the vault, setup finished") } async function main(): Promise { diff --git a/scripts/harness/extend.ts b/scripts/harness/extend.ts index 9550319..78cc4d1 100644 --- a/scripts/harness/extend.ts +++ b/scripts/harness/extend.ts @@ -31,7 +31,11 @@ interface DeploymentRecord { // Contracts whose storage holds a timelock change queue, by the name they // deploy under. -const CONTRACTS_WITH_QUEUES = new Set(["async_vault", "share_token"]) +const CONTRACTS_WITH_QUEUES = new Set([ + "async_vault", + "share_token", + "nav_oracle", +]) /** * The timelock's own storage key for a change still waiting: diff --git a/scripts/harness/guards.test.ts b/scripts/harness/guards.test.ts index 8579ccd..3f51e76 100644 --- a/scripts/harness/guards.test.ts +++ b/scripts/harness/guards.test.ts @@ -250,6 +250,58 @@ describe("the share token after setup", () => { }) }) +describe("the oracle after setup", () => { + const caller = () => governance.publicKey() + + it("governance cannot loosen the feed config at once", async () => { + const config = (await feed.config()).result + const o = oracle(c, governance) + expectRefused( + await o.set_config({ + config: { + ...config, + freshness_duration: config.freshness_duration + 60n, + }, + }), + o, + "UseTimelock", + ) + }) + + it("governance cannot tighten the fall cap at once either", async () => { + const config = (await feed.config()).result + const o = oracle(c, governance) + expectRefused( + await o.set_config({ + config: { ...config, max_down_bps: config.max_down_bps - 1 }, + }), + o, + "UseTimelock", + ) + }) + + it("queues a config change behind the vault's delay", async () => { + const config = (await feed.config()).result + const o = oracle(c, governance) + const id = await send( + o.propose_change({ + change: { + tag: "Config", + values: [{ ...config, max_down_bps: config.max_down_bps - 1 }], + }, + caller: caller(), + }), + ) + expectRefused( + await o.apply_change({ id, caller: caller() }), + o, + "ChangeNotReady", + ) + await send(o.cancel_change({ id, caller: caller() })) + expect((await o.pending_changes()).result).toEqual([]) + }) +}) + describe("invalid amounts and duplicate requests in an epoch", () => { it("rejects zero deposit amount", async () => { const v = vault(c, first) From 793592d3265018b2d26de7cefd308a2100ddb5d6 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 08:35:21 -0300 Subject: [PATCH 3/4] docs: put the oracle's admin behind the timelock --- docs/AUTHORIZATION_MATRIX.md | 26 ++++++++++++++++----- docs/TESTING.md | 19 ++++++++++----- docs/kit-spec.md | 31 +++++++++++++++++-------- docs/strata-product-and-architecture.md | 4 ++-- 4 files changed, 56 insertions(+), 24 deletions(-) diff --git a/docs/AUTHORIZATION_MATRIX.md b/docs/AUTHORIZATION_MATRIX.md index 8d24c7a..adfe563 100644 --- a/docs/AUTHORIZATION_MATRIX.md +++ b/docs/AUTHORIZATION_MATRIX.md @@ -37,6 +37,7 @@ In `scripts/harness/deploy.ts` and automated deployment scripts, the separation - The share token role is granted to the deployed vault contract: `token.grant_role({ account: asyncVault.address, role: "manager", caller: governance })`. - After granting, the script checks `token.has_role({ account: vault, role: "manager" })` and stops if the vault does not hold the role. It does not check that no other account holds it. - It then calls `token.bind_vault({ vault, caller: governance })` and `token.finish_setup({ caller: governance })`, and stops if `token.setup_open()` still reads true. From then on the token's admin changes wait for the vault's timelock (§5.2). +- Last, it calls `oracle.bind_vault({ vault, caller: governance })` and `oracle.finish_setup({ caller: governance })`, and stops if `oracle.setup_open()` still reads true. From then on the oracle's admin changes wait for the vault's timelock (§5.3). --- @@ -127,16 +128,29 @@ Tests: [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.r ### 5.3 NavOracle (`contracts/nav-oracle`) -Tests: [`contracts/nav-oracle/src/test.rs`](../contracts/nav-oracle/src/test.rs). +The oracle's `admin` is governance; its attester and guardian are constructor parameters. Until `finish_setup`, admin changes are instant. Setup cannot finish while an admin hand-over offered during setup is pending. After setup, the following wait for the vault's timelock through `propose_change`/`apply_change`: grants, admin hand-over, every `set_config` change except a shorter `freshness_duration`, and upgrades. Revoking `attester` or `guardian`, the NAV pause (`pause_nav`, `set_nav_paused`) and `clear_latest` stay instant. + +Tests: [`contracts/nav-oracle/src/test.rs`](../contracts/nav-oracle/src/test.rs) and [`contracts/nav-oracle/src/test_governance.rs`](../contracts/nav-oracle/src/test_governance.rs). | Entrypoint | Access Control / Caller Auth | State & Precondition Guards | Tested Refusal (Test Name) | |---|---|---|---| | `attest` | `#[only_role(caller, "attester")]` | Refused while the NAV is paused; cooldown, deviation cap, bounds `[min, max]`; expiry capped at the freshness window | `test::attest_is_role_gated`, `test::attesting_is_refused_while_the_nav_is_paused` | -| `pause_nav` | `#[only_role(caller, "guardian")]` | None | `test::pausing_the_nav_is_limited_to_the_guardian` | -| `set_nav_paused` | `#[only_admin]`, `caller.require_auth()` | None | `test::the_guardian_pauses_the_nav_but_unpausing_needs_governance` | -| `clear_latest` | `#[only_admin]`, `caller.require_auth()` | NAV must be paused | `test::only_admin_can_clear_latest`, `test::the_record_clears_only_while_the_nav_is_paused` | -| `set_config` | `#[only_admin]` (no caller argument; the stored admin signs) | Valid bounds | `test::only_admin_can_set_config` | -| `renounce_admin` | Refused always (`OracleError::AdminRequired`) | None | `test::the_oracle_admin_cannot_renounce_itself_away` | +| `pause_nav` | `#[only_role(caller, "guardian")]` | None; instant after setup too | `test::pausing_the_nav_is_limited_to_the_guardian` (the instant case: `test_governance::the_nav_pause_and_clearing_stay_instant_after_setup`) | +| `set_nav_paused` | `#[only_admin]`, `caller.require_auth()` | None; instant after setup too | `test::the_guardian_pauses_the_nav_but_unpausing_needs_governance` | +| `clear_latest` | `#[only_admin]`, `caller.require_auth()` | NAV must be paused; instant after setup too | `test::only_admin_can_clear_latest`, `test::the_record_clears_only_while_the_nav_is_paused` | +| `set_config` | `#[only_admin]` (no caller argument; the stored admin signs) | Valid bounds. Instant while setup is open, or when the change is only a shorter `freshness_duration`; `UseTimelock` otherwise | `test::only_admin_can_set_config`, `test_governance::a_tighter_cap_or_band_waits_after_setup`, `test_governance::a_config_that_tightens_one_field_and_loosens_another_waits` | +| `renounce_admin` | Refused always (`OracleError::AdminRequired`) | None | `test::the_oracle_admin_cannot_renounce_itself_away`; `test_governance::an_applied_admin_change_starts_a_hand_over_the_next_admin_accepts`. An oracle without an admin could never be reconfigured, clear a stuck record or unpause the NAV; hand over by proposing and applying an `Admin` change (`transfer_admin_role` during setup), then the nominee calls `accept_admin_transfer`. | +| `bind_vault` | `#[only_admin]`, `caller.require_auth()` | During setup, once; the vault must answer `timelock_clock` | `test_governance::setup_is_open_until_finished_and_never_reopens`, `test_governance::a_second_vault_cannot_be_bound`, `test_governance::an_address_that_reports_no_clock_cannot_be_bound` | +| `finish_setup` | `#[only_admin]`, `caller.require_auth()` | Needs a bound vault and no pending admin hand-over; once | `test_governance::setup_is_open_until_finished_and_never_reopens`, `test_governance::setup_cannot_finish_while_a_hand_over_is_pending` | +| `propose_change` | `#[only_admin]`, `caller.require_auth()` | After setup; vault not winding down; one upgrade at a time; a `Config` change must pass the constructor's bounds | `test_governance::nothing_is_proposed_during_setup`, `test_governance::an_invalid_config_cannot_be_proposed`, `test_governance::only_the_admin_proposes_cancels_and_applies` | +| `cancel_change` | `#[only_admin]`, `caller.require_auth()` | The change must be pending | `test_governance::only_the_admin_proposes_cancels_and_applies` | +| `apply_change` | `#[only_admin]`, `caller.require_auth()` | Delay run on the vault's clock, vault unpaused and not winding down, delay `>=` the vault's notice; a `Config` change must still pass the bounds | `test_governance::a_vault_pause_moves_the_eta_and_holds_the_change`, `test_governance::nothing_is_proposed_or_applied_while_the_vault_winds_down`, `test_governance::a_change_shorter_than_a_raised_notice_never_lands`, `test_governance::a_config_outside_the_bounds_is_refused_when_it_applies` | +| `pending_changes` | Open view | None | None (view) | +| `grant_role` | Admin, `caller.require_auth()` | Instant during setup; `UseTimelock` after | `test_governance::grants_wait_after_setup_and_land_through_the_timelock` | +| `revoke_role` | Admin, `caller.require_auth()` | Instant: taking an attester or a guardian away moves no value | `test_governance::only_the_admin_revokes_a_role` (the instant case: `test_governance::revoking_an_attester_or_a_guardian_stays_instant_after_setup`) | +| `transfer_admin_role` | Admin | Instant during setup; `UseTimelock` after. A zero `live_until_ledger` cancels at once | `test_governance::an_admin_hand_over_waits_but_cancelling_one_does_not` | +| `set_role_admin` | Refused always (`OracleError::UseTimelock`) | None | `test_governance::no_role_ever_gets_a_role_admin`. A role admin could grant its role with no delay. | +| `upgrade` | Not exposed | Upgrades go through `propose_change(Upgrade)` | `test_governance::the_oracle_exposes_no_immediate_upgrade` | ### 5.4 IdentityVerifier (`contracts/identity-verifier`) diff --git a/docs/TESTING.md b/docs/TESTING.md index c977814..76612a3 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -11,17 +11,20 @@ SOROBAN_SDK_BUILD_SYSTEM_SUPPORTS_SPEC_SHAKING_V2=1 nix develop -c cargo test ``` The variable is needed whenever `cargo` runs directly (see -[AGENTS.md](../AGENTS.md)). The tests that upgrade the vault and the share -token to a real WASM build run only with `--features upgrade_wasm`, after three -builds: the fixture in [`fixtures/upgrade-target`](../fixtures/upgrade-target), -the share token, and the share token's probe build, a stand-in for its next -version. Inside `nix develop`: +[AGENTS.md](../AGENTS.md)). The tests that upgrade the vault, the share token +and the oracle to a real WASM build run only with `--features upgrade_wasm`, +after five builds: the fixture in +[`fixtures/upgrade-target`](../fixtures/upgrade-target), the share token, the +oracle, and a probe build of the token and of the oracle, each a stand-in for +its next version. Inside `nix develop`: ```sh stellar contract build --manifest-path fixtures/upgrade-target/Cargo.toml stellar contract build --package share-token +stellar contract build --package nav-oracle CARGO_TARGET_DIR=target/upgrade-probe stellar contract build --package share-token --features upgrade_probe -SOROBAN_SDK_BUILD_SYSTEM_SUPPORTS_SPEC_SHAKING_V2=1 cargo test -p async-vault --features upgrade_wasm with_wasm +CARGO_TARGET_DIR=target/upgrade-probe stellar contract build --package nav-oracle --features upgrade_probe +SOROBAN_SDK_BUILD_SYSTEM_SUPPORTS_SPEC_SHAKING_V2=1 cargo test -p async-vault -p nav-oracle --features upgrade_wasm with_wasm ``` ## Storage and TTL @@ -43,6 +46,7 @@ contract extends, who pays, and how to restore an archived one. | Reading a vault request, epoch or the instance restores its TTL | [`contracts/async-vault/src/test/ttl.rs`](../contracts/async-vault/src/test/ttl.rs) | `reading_a_decayed_request_restores_its_full_ttl`, `reading_a_decayed_epoch_restores_its_full_ttl`, `any_vault_read_restores_the_instance_ttl` | | A request still claims, and a wind-down position still pays, after most of a month | [`contracts/async-vault/src/test/ttl.rs`](../contracts/async-vault/src/test/ttl.rs), [`contracts/async-vault/src/test/wind_down.rs`](../contracts/async-vault/src/test/wind_down.rs) | `a_pending_request_still_claims_after_most_of_a_month`, `a_position_survives_the_ledger_advancing` | | The oracle's latest valuation keeps a full TTL while it is read | [`contracts/nav-oracle/src/test.rs`](../contracts/nav-oracle/src/test.rs) | `the_latest_valuation_keeps_a_full_ttl_while_it_is_read` | +| The oracle's setup, grant, hand-over and change calls extend its instance | [`contracts/nav-oracle/src/test_governance.rs`](../contracts/nav-oracle/src/test_governance.rs) | `setup_grants_hand_overs_and_changes_restore_the_instance_ttl` | | An epoch that was never written reads as nothing | [`contracts/async-vault/src/test/constructor.rs`](../contracts/async-vault/src/test/constructor.rs) | `unwritten_epoch_reads_as_none`, `epoch_zero_is_the_absent_id` | The test `Env` does not archive an entry whose TTL runs out; it still reads it. @@ -70,6 +74,7 @@ nothing. | Share token: only the right role mints, burns, pauses, freezes or moves shares | [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.rs) | `unauthorized_caller_cannot_mint`, `unauthorized_caller_cannot_burn`, `unauthorized_caller_cannot_forced_transfer`, `unauthorized_caller_cannot_freeze_or_recover` | | Share token: only the admin binds the vault, finishes setup and runs the timelock | [`contracts/share-token/src/test_governance.rs`](../contracts/share-token/src/test_governance.rs) | `only_the_admin_binds_and_finishes`, `only_the_admin_proposes_cancels_and_applies` | | Oracle: only the attester posts, only the guardian pauses the NAV | [`contracts/nav-oracle/src/test.rs`](../contracts/nav-oracle/src/test.rs) | `attest_is_role_gated`, `pausing_the_nav_is_limited_to_the_guardian`, `only_admin_can_set_config` | +| Oracle: only the admin binds the vault, finishes setup, runs the timelock and revokes a role | [`contracts/nav-oracle/src/test_governance.rs`](../contracts/nav-oracle/src/test_governance.rs) | `only_the_admin_binds_and_finishes`, `only_the_admin_proposes_cancels_and_applies`, `only_the_admin_revokes_a_role` | | Identity verifier: only the admin edits the allowlist | [`contracts/identity-verifier/src/test.rs`](../contracts/identity-verifier/src/test.rs) | `only_admin_can_allow` | ## Ledger time and sequence @@ -85,6 +90,7 @@ so time rules and TTL can be reached in one test. | Oracle freshness, cooldown and its own timestamp | [`contracts/nav-oracle/src/test.rs`](../contracts/nav-oracle/src/test.rs) | `heartbeat_expiry_makes_it_stale`, `attest_enforces_cooldown_and_deviation`, `attest_stamps_its_own_timestamp`, `a_reporter_expiry_past_the_window_is_cut_to_the_window`, `two_reports_in_one_ledger_are_refused_at_the_shortest_cooldown` | | Upgrade and wind-down timelocks follow the ledger clock | [`contracts/async-vault/src/test/upgrade.rs`](../contracts/async-vault/src/test/upgrade.rs), [`contracts/async-vault/src/test/wind_down.rs`](../contracts/async-vault/src/test/wind_down.rs) | `applying_before_the_eta_is_refused`, `a_pause_freezes_the_upgrade_timelock_clock`, `governance_proposes_and_anyone_activates_after_the_delay` | | The share token's changes wait on the vault's clock: a vault pause moves the eta, a wind-down refuses them | [`contracts/share-token/src/test_governance.rs`](../contracts/share-token/src/test_governance.rs) | `a_grant_lands_one_vault_delay_after_it_was_proposed`, `a_vault_pause_moves_the_eta_and_holds_the_change`, `nothing_is_proposed_or_applied_while_the_vault_winds_down` | +| The oracle's changes wait on the vault's clock the same way | [`contracts/nav-oracle/src/test_governance.rs`](../contracts/nav-oracle/src/test_governance.rs) | `a_loosened_config_lands_through_the_timelock`, `a_vault_pause_moves_the_eta_and_holds_the_change`, `nothing_is_proposed_or_applied_while_the_vault_winds_down` | | Timing rules hold for any clock values (property tests) | [`contracts/async-vault/src/test/timing.rs`](../contracts/async-vault/src/test/timing.rs) | `standard_priceable_at_never_precedes_the_close`, `a_notice_that_would_overflow_saturates` | ## Events @@ -109,3 +115,4 @@ contract and oracle, plus a Stellar Asset Contract for the deposit asset. | The vault reads the oracle and refuses a stale or paused feed | [`contracts/async-vault/src/test/oracle_pricing.rs`](../contracts/async-vault/src/test/oracle_pricing.rs) | `the_epoch_is_priced_at_the_attestation`, `fulfill_is_rejected_once_the_feed_goes_stale`, `a_paused_feed_is_refused_by_the_vault_not_the_oracle` | | Upgrading replaces the vault's code and migrates once | [`contracts/async-vault/src/test/upgrade.rs`](../contracts/async-vault/src/test/upgrade.rs) (needs `upgrade_wasm`) | `applying_a_wasm_change_replaces_the_code`, `a_migration_refuses_a_second_run` | | The share token upgrades through the vault's timelock under live balances, an open deposit and an open redemption, and migrates once | [`contracts/async-vault/src/test/upgrade.rs`](../contracts/async-vault/src/test/upgrade.rs) (needs `upgrade_wasm`) | `the_token_upgrades_under_live_balances_and_open_requests` | +| The oracle upgrades through the vault's timelock under a live valuation, keeps its config, roles and vault, and migrates once | [`contracts/nav-oracle/src/test_governance.rs`](../contracts/nav-oracle/src/test_governance.rs) (needs `upgrade_wasm`) | `the_oracle_upgrades_under_a_live_valuation` | diff --git a/docs/kit-spec.md b/docs/kit-spec.md index 62b3e86..0185227 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -92,6 +92,7 @@ The contracts enforce a set of mathematical and state-machine invariants across - One code change (upgrade or upgrade delay) waits at a time (`UpgradeProposalExists`). - Renouncing admin is permanently refused (`AdminRequired`). - Once its one-time setup closes, the share token's upgrades, grants, `manager` revocations, admin hand-overs and compliance or verifier swaps wait on the vault's clock under the same rules; a direct call is refused with `UseTimelock`. Revoking `compliance` stays instant. + - Once its one-time setup closes, the oracle's upgrades, grants, admin hand-overs and every config change except a shorter `freshness_duration` wait on the vault's clock under the same rules; a direct call is refused with `UseTimelock`. Revoking `attester` or `guardian`, the NAV pause and `clear_latest` stay instant. ### Invariant 9: Wind-Down Pro-Rata Solvency - **Definition**: In an active wind-down, total round payouts across all share claims can never exceed the capital credited to that round. @@ -157,7 +158,7 @@ The `async_vault` constructor strictly verifies pairwise separation across autho | **Deposit Cap** | `set_deposit_cap(cap: Option)` | Governance | Maximum capacity for deposited capital. `None` = unbounded. Refuses negative cap. | | **Wind-Down Delay** | `set_wind_down_delay(secs: u64)` | Governance | Minimum notice before wind-down activation. Default: `0`. Capped at 90 days (`MAX_WIND_DOWN_DELAY`). | | **Upgrade Delay** | `propose_change(UpgradeDelay(secs))` → `apply_change(id)` | Governance | Timelock delay for wasm upgrades. Must be $\ge 7 \text{ days}$, $\le 90 \text{ days}$, and $\ge \text{notice}$. | -| **Oracle Configuration** | `set_config(config: OracleConfig)` | Governance | Updates oracle parameters. Follows same validation rules as constructor. | +| **Oracle Configuration** | `set_config(config: OracleConfig)`; after setup, `propose_change(Config(config))` → `apply_change(id)` | Governance | Updates oracle parameters within the constructor's bounds. `set_config` is instant during the oracle's setup. After it, `set_config` is instant only for a shorter `freshness_duration`: later reports go stale sooner, and no value moves. Every other change, tighter limits included, goes through `propose_change(Config(config))` → `apply_change(id)`, and the bounds are checked again when it applies. A tighter fall cap or a longer cooldown can hold the price above a real loss, and a narrower band can stop pricing. | --- @@ -213,8 +214,17 @@ Strata distributes control across five native Stellar multisig authorities and p | **`NavOracle`** | `attest` | Attester | `#[only_role(caller, "attester")]` | Refused if the NAV is paused | | **`NavOracle`** | `pause_nav` | Guardian | `#[only_role(caller, "guardian")]` | N/A | | **`NavOracle`** | `set_nav_paused` | Governance | `#[only_admin]` | N/A | -| **`NavOracle`** | `set_config` | Governance | `#[only_admin]` | No | +| **`NavOracle`** | `set_config` | Governance | `#[only_admin]`; instant during setup, then only for a shorter `freshness_duration`; otherwise `propose_change` | No | | **`NavOracle`** | `clear_latest` | Governance | `#[only_admin]` (requires NAV paused) | No | +| **`NavOracle`** | `bind_vault` | Governance (oracle admin) | `#[only_admin]`; during setup, once | No | +| **`NavOracle`** | `finish_setup` | Governance | `#[only_admin]`; needs a bound vault and no pending admin hand-over | No | +| **`NavOracle`** | `propose_change` | Governance | `#[only_admin]`; after setup | No | +| **`NavOracle`** | `cancel_change` | Governance | `#[only_admin]` | No | +| **`NavOracle`** | `apply_change` | Governance | `#[only_admin]`; after the vault's delay | Refused while the vault is paused or winding down | +| **`NavOracle`** | `pending_changes` | Permissionless | None (view) | No | +| **`NavOracle`** | `grant_role`, `transfer_admin_role` | Governance | Instant during setup, then `propose_change` (a zero `live_until_ledger` cancels at once) | No | +| **`NavOracle`** | `revoke_role` | Governance | Instant (`attester`, `guardian`) | No | +| **`NavOracle`** | `set_role_admin` | None | Always refused (`UseTimelock`) | N/A | | **`ShareToken`** | `pause` | Admin (Governance) | `#[only_admin]`, `caller.require_auth()` | N/A | | **`ShareToken`** | `unpause` | Admin (Governance) | `#[only_admin]`, `caller.require_auth()` | N/A | | **`ShareToken`** | `mint` | Manager (Vault) | `#[only_role(operator, "manager")]` | Bypasses token pause | @@ -247,6 +257,7 @@ A clean deployment executes in three sequenced phases: - Call `ShareToken::bind_vault(vault_address)`, then `ShareToken::finish_setup()`. The token's admin changes are instant until then and wait for the vault's timelock afterwards. - Call `IdentityVerifier::allow(vault_address, true)`, signed by the compliance key, so the vault can return escrowed shares on cancelled redemptions. - Call `Compliance::bind_token(share_token_address)` to link SEP-57 rule verification. + - Call `NavOracle::bind_vault(vault_address)`, then `NavOracle::finish_setup()`. The oracle's admin changes are instant until then and wait for the vault's timelock afterwards. 3. **Phase 3: Operational Parameterization** - Governance calls `set_notice(secs)` and `set_custodian(custodian_address)`. - Governance sets `deposit_cap` if initial capital intake is bounded. @@ -263,7 +274,7 @@ instance is archived refuses every call. | Contract | What it extends | When | To how long | |---|---|---|---| | `async_vault` | Its instance, and every persistent entry it reads or writes: epochs, requests, settlements, wind-down positions, pending governance changes | Every call that reads or writes the entry | 30 days (518,400 ledgers), once less than 29 days remain | -| `nav_oracle` | Its instance and the latest report | `attest` extends both; every other call extends whichever of the two it reads or writes | Same | +| `nav_oracle` | Its instance, the latest report, and each pending governance change it reads or writes | `attest` extends the instance and the report; `bind_vault`, `finish_setup`, `grant_role`, `transfer_admin_role`, `propose_change`, `cancel_change` and `apply_change` extend the instance; every other call extends whichever entry it reads or writes | Same | | `share_token` | Its instance, and each pending governance change it reads or writes | Every call that moves shares or changes token settings: `transfer`, `transfer_from`, `approve`, `mint`, `burn`, `forced_transfer`, freezes, recovery, `set_compliance`, `set_identity_verifier`, `pause`, `unpause`, `grant_role`, `revoke_role`, `transfer_admin_role`, `bind_vault`, `finish_setup`, `propose_change`, `cancel_change`, `apply_change` | Same | | `identity_verifier` | Its instance and each allowlist entry | `allow`, `is_allowed`, `verify_identity` | Same | | `compliance` | Its instance | Every hook the token calls (`can_create`, `can_transfer`, `created`, `transferred`, `destroyed`) and its admin calls | Same | @@ -274,8 +285,8 @@ shorter). They are fixed in code: a governance setting would add an admin power for little gain. **Pending governance changes.** A change waiting in the timelock of -`async_vault` or `share_token` lives as a persistent entry, extended to 30 days -when it is proposed. The wait itself can run up to 90 days +`async_vault`, `share_token` or `nav_oracle` lives as a persistent entry, +extended to 30 days when it is proposed. The wait itself can run up to 90 days (`timelock::MAX_DELAY`), so a change nobody touches can archive before it is due. Nothing is lost: the call that applies it restores the archived entry itself and pays the restore fee, the same as any other archived entry. @@ -334,8 +345,8 @@ A feed that implements `OracleFeed` can replace `nav-oracle`; it must report `Stale` once its report outlives `freshness_duration`, and `attested_at` must be the ledger time the report was accepted, not a time the reporter supplies. -Two more traits connect the share token to the vault's timelock. The vault -implements `ClockSource` (`crates/timelock`), and the share token reads its -`timelock_clock` to time its own changes. The share token implements -`VaultBinding` (`crates/bindings`): `vault` names the vault it is bound to, and -`setup_open` says whether its admin changes are still instant. +Two more traits connect the share token and the oracle to the vault's +timelock. The vault implements `ClockSource` (`crates/timelock`), and the share +token and the oracle read its `timelock_clock` to time their own changes. Both +implement `VaultBinding` (`crates/bindings`): `vault` names the vault each is +bound to, and `setup_open` says whether its admin changes are still instant. diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index 5ba6595..035e4eb 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -285,7 +285,7 @@ number with a proof reference. | Component | Role | Controls | | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Vault** | Request lifecycle, epoch sealing by its manager role, pricing, split reserve accounting, custodian transfers, upgrade control | One authority per privileged entrypoint; upgrades behind a governance timelock no shorter than the notice, bounded between 7 and 90 days. The share token upgrades under the same timelock. Administrative handover follows a strict two-step transfer pattern; renouncing admin is permanently refused (`AdminRequired`) | +| **Vault** | Request lifecycle, epoch sealing by its manager role, pricing, split reserve accounting, custodian transfers, upgrade control | One authority per privileged entrypoint; upgrades behind a governance timelock no shorter than the notice, bounded between 7 and 90 days. The share token and the oracle upgrade under the same timelock. Administrative handover follows a strict two-step transfer pattern; renouncing admin is permanently refused (`AdminRequired`) | | **Share token** | OZ SEP-41 + SEP-57 RWA extensions: freeze, forced transfer, recovery, identity and compliance checks on every transfer, independent transfer pause (admin-gated; governance by deploy) | Manager role held only by the vault, for mint, burn and escrow; compliance role held by the compliance authority, for freeze, forced transfer and recovery; swapping the compliance or identity-verifier contract needs the admin and, after a one-time setup, waits for the vault's timelock | | **Token roles** | Access-control roles on the share token, checked directly via native access control: `manager` mints, burns and escrows; `compliance` freezes, forces transfers and recovers | Granted and revoked by the token admin (governance by deploy). After a one-time setup, a grant, revoking `manager` and an admin handover wait for the vault's timelock; revoking `compliance` stays instant | | **Compliance module** | Implements the SEP-57 identity and rules interfaces the share token consults, with the allowlist as its only rule | Managed by the compliance authority via direct role access; replaceable by OZ's identity verifier and compliance contracts (with RWA Wizard modules) without touching the token | @@ -588,7 +588,7 @@ public surface given the vault address. | Boundary | Risk | Mitigation | | ---------------------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Attestation authority | Wrong or compromised reports misprice requests | Multisig reporter; the deviation cap bounds a single report in both directions, and a downward cap of 100% leaves `min_answer` as the only floor; the cooldown bounds frequency; a sustained sequence of biased reports within the cap remains possible, is bounded in speed, and is the monitoring plan's primary alert, with the guardian pause as the reactive control. Residual risk: value transfer between entry and exit cohorts | -| Governance keys | Malicious upgrade | Timelock on every code change and on the delay itself, bounded by a minimum no shorter than the standing notice and a maximum ceiling (90 days). A pause freezes the timelock clock and pushes the ETA by the duration paused, guaranteeing that investors have an unpaused exit window before any code change applies; applying while paused is refused; nothing is proposed or applied while the vault winds down. The share token's upgrades, grants, `manager` revocations, admin handovers and compliance or verifier swaps wait on the same clock once its setup closes. | +| Governance keys | Malicious upgrade | Timelock on every code change and on the delay itself, bounded by a minimum no shorter than the standing notice and a maximum ceiling (90 days). A pause freezes the timelock clock and pushes the ETA by the duration paused, guaranteeing that investors have an unpaused exit window before any code change applies; applying while paused is refused; nothing is proposed or applied while the vault winds down. The share token's upgrades, grants, `manager` revocations, admin handovers and compliance or verifier swaps wait on the same clock once its setup closes. So do the oracle's upgrades, grants, admin handovers and every guardrail change except a shorter freshness window; its NAV pause, `clear_latest` and revoking its attester or guardian stay instant. | | Treasury keys | Reserve drained | Only free reserve is movable, only to the genesis-configured custodian, verified on-chain; outbound transfers are blocked while anything is uncovered, and escrowed subscriptions never leave the vault | | Guardian keys | Griefing via pause | Guardian can only pause the vault, which stops deposit requests, epoch pricing and upgrade application and freezes the upgrade timelock; it cannot halt redemptions, custodian deployments, or payable claims, nor move funds; governance unpauses and rotates the vault role. It holds no role on the share token: the token's transfer pause is admin-gated (governance by deploy), not a guardian lever. No contract keeps governance and guardian apart | | Share-token pause | Cancellations and secondary transfers halted | Admin-gated (governance by deploy; the token has no guardian role); it refuses SEP-41 transfers and escrowed-share returns only, never pricing, claims, mint, burn or forced transfer; only the token admin lifts it (§8.5) | From 84a31853213c3a3d33ba3e71c8650b9a7deca744 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 09:11:30 -0300 Subject: [PATCH 4/4] fix(oracle): address timelock review round 1 --- README.md | 14 +-- contracts/async-vault/src/test/upgrade.rs | 45 ++++++++++ contracts/nav-oracle/src/test_governance.rs | 94 +++++++++++++++++--- contracts/share-token/src/test_governance.rs | 13 --- docs/AUTHORIZATION_MATRIX.md | 6 +- docs/TESTING.md | 4 +- docs/kit-spec.md | 2 +- 7 files changed, 139 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 18bc405..1353f7b 100644 --- a/README.md +++ b/README.md @@ -112,16 +112,18 @@ Output of a real run from a fresh clone. Contract IDs differ on every run. deploying to local (http://localhost:8000/rpc) funded 13 accounts -asset contract CB7IYQNW5Z5NY4RLR3PRNDAI6DNCEXD6I7NRWQZSGBNQ6YFANFPZ7ZSE -compliance CBNEUKZOUMYIGZYCCEKNVRMLPBOCMRSE32XRGEL64QKYAHOAQTL4R2I4 -identity_verifier CBZC4FWQD33HC4TKOIODFLX7H527XVP6RL346LPYSLE2GUKMFN7HT6TZ -share_token CD6ISWB4DFY6XQEXS4ECBWA3TDGOUTK7SEACMVBUEQ3JFW65XMG43QFV -nav_oracle CCAMOCE2U5LBMEYDNXAMOZZSGDTX6A5DNQRXCYMUIYFTPRKZS6QUCDKD -async_vault CAZPVYS3JITMZYE2CYBOKJ2FBQ46RK4E4NBDHA53RSV7ICUXFZOOJJYR +asset contract CDPQAISI45SS2INROW6YGBUJUZ23IO2IQ4OW4YN3TFCX6RDR4I4DGC45 +compliance CASCZWH44CPGC26FIEBDZKXHWLXEPF2SLCHBTIOMAH33TWA76P3V2QAW +identity_verifier CCGG7IHN4D5P43DA32N2SVQM62ZSPBYY45AUY7AIO6U4TVKWP7X7C4CW +share_token CC3OIIFWYZPRGYWX4GS2NTHLOD3OHMIZY6SAWD3W4LKZNU4ZMLZNSOP2 +nav_oracle CD4MEW2NM4WDXBGA4V53RCQTMXXKXIWXLBAXX6U4XYER343T7AEU52WP +async_vault CBUOZYNWDS3BJ7W4JCZOHPKMWAOQLKPEOB7IY6GTPHJGQU7Q6ZHS2VPU clients generated vault holds share_token manager role +share_token bound to the vault, setup finished vault is allowlisted share_token bound to compliance +nav_oracle bound to the vault, setup finished wrote deployed.local.json ``` diff --git a/contracts/async-vault/src/test/upgrade.rs b/contracts/async-vault/src/test/upgrade.rs index 9c30f5d..4006519 100644 --- a/contracts/async-vault/src/test/upgrade.rs +++ b/contracts/async-vault/src/test/upgrade.rs @@ -412,6 +412,7 @@ fn the_vault_reports_its_clock_to_the_token_and_the_oracle() { #[cfg(feature = "upgrade_wasm")] mod with_wasm { use share_token::TokenChange; + use soroban_sdk::xdr::{ContractEventBody, ScError, ScErrorCode, ScErrorType, ScSymbol, ScVal}; use super::*; @@ -429,6 +430,50 @@ mod with_wasm { ); } + /// What a caller sees when the host refuses a call: every host error is + /// narrowed to this one. + fn host_refusal() -> soroban_sdk::Error { + soroban_sdk::Error::from_type_and_code(ScErrorType::Context, ScErrorCode::InvalidAction) + } + + /// Whether the host refused a call to `function` because the contract + /// exports no function of that name. The reason is only in the + /// diagnostic events. + fn exports_no(e: &Env, function: &str) -> bool { + let missing = ScVal::Error(ScError::WasmVm(ScErrorCode::MissingValue)); + let name = ScVal::Symbol(ScSymbol(function.try_into().unwrap())); + e.host() + .get_diagnostic_events() + .unwrap() + .0 + .iter() + .any(|event| { + let ContractEventBody::V0(body) = &event.event.body; + body.topics.contains(&missing) + && matches!(&body.data, ScVal::Vec(Some(args)) if args.contains(&name)) + }) + } + + #[test] + fn the_token_exposes_no_immediate_upgrade() { + let f = setup_with(open_feed(), |e, args| e.register(token_v1::WASM, args)); + let probe_hash = f.e.deployer().upload_contract_wasm(token_probe::WASM); + let upgrader = + stellar_contract_utils::upgradeable::UpgradeableClient::new(&f.e, &f.share.address); + + assert_eq!( + upgrader.try_upgrade(&probe_hash, &f.admin), + Err(Ok(host_refusal())) + ); + assert!(exports_no(&f.e, "upgrade")); + assert_eq!( + token_probe::Client::new(&f.e, &f.share.address).try_probe(), + Err(Ok(host_refusal())) + ); + assert!(exports_no(&f.e, "probe")); + assert_eq!(f.share.schema_version(), 1); + } + #[test] fn the_token_upgrades_under_live_balances_and_open_requests() { let f = setup_with(open_feed(), |e, args| e.register(token_v1::WASM, args)); diff --git a/contracts/nav-oracle/src/test_governance.rs b/contracts/nav-oracle/src/test_governance.rs index ebb1b58..3cdbcb5 100644 --- a/contracts/nav-oracle/src/test_governance.rs +++ b/contracts/nav-oracle/src/test_governance.rs @@ -267,7 +267,7 @@ fn a_shorter_freshness_window_stays_instant_after_setup() { } #[test] -fn a_shorter_freshness_window_with_any_other_change_waits() { +fn a_shorter_freshness_window_with_a_tighter_field_waits() { let f = setup(); f.finish(); let shorter_and_tighter = OracleConfig { @@ -283,6 +283,39 @@ fn a_shorter_freshness_window_with_any_other_change_waits() { assert_eq!(f.oracle.config(), config()); } +#[test] +fn a_shorter_freshness_window_with_a_looser_field_waits() { + let f = setup(); + f.finish(); + let shorter_and_looser = OracleConfig { + freshness_duration: 1_800, + max_up_bps: 2_000, + ..config() + }; + + refused( + f.oracle.try_set_config(&shorter_and_looser), + OracleError::UseTimelock, + ); + assert_eq!(f.oracle.config(), config()); +} + +#[test] +fn a_freshness_cut_below_the_cooldown_is_refused() { + let f = setup(); + f.finish(); + let below_the_cooldown = OracleConfig { + freshness_duration: config().cooldown_secs - 1, + ..config() + }; + + refused( + f.oracle.try_set_config(&below_the_cooldown), + OracleError::InvalidConfig, + ); + assert_eq!(f.oracle.config(), config()); +} + #[test] fn a_tighter_cap_or_band_waits_after_setup() { let f = setup(); @@ -863,19 +896,6 @@ fn a_proposal_after_a_past_pause_waits_one_delay_from_now() { assert_eq!(f.oracle.pending_changes().get(0).unwrap().eta, eta); } -#[test] -fn the_oracle_exposes_no_immediate_upgrade() { - let f = setup(); - f.finish(); - let upgrader = - stellar_contract_utils::upgradeable::UpgradeableClient::new(&f.e, &f.oracle.address); - - assert!(upgrader - .try_upgrade(&BytesN::from_array(&f.e, &[7; 32]), &f.admin) - .is_err()); - assert_eq!(f.oracle.schema_version(), 1); -} - #[test] fn the_schema_version_is_one_after_construction() { let f = setup(); @@ -910,6 +930,8 @@ fn setup_grants_hand_overs_and_changes_restore_the_instance_ttl() { #[cfg(feature = "upgrade_wasm")] mod with_wasm { + use soroban_sdk::xdr::{ContractEventBody, ScError, ScErrorCode, ScErrorType, ScSymbol, ScVal}; + use super::*; mod oracle_v1 { @@ -922,6 +944,50 @@ mod with_wasm { ); } + /// What a caller sees when the host refuses a call: every host error is + /// narrowed to this one. + fn host_refusal() -> soroban_sdk::Error { + soroban_sdk::Error::from_type_and_code(ScErrorType::Context, ScErrorCode::InvalidAction) + } + + /// Whether the host refused a call to `function` because the contract + /// exports no function of that name. The reason is only in the + /// diagnostic events. + fn exports_no(e: &Env, function: &str) -> bool { + let missing = ScVal::Error(ScError::WasmVm(ScErrorCode::MissingValue)); + let name = ScVal::Symbol(ScSymbol(function.try_into().unwrap())); + e.host() + .get_diagnostic_events() + .unwrap() + .0 + .iter() + .any(|event| { + let ContractEventBody::V0(body) = &event.event.body; + body.topics.contains(&missing) + && matches!(&body.data, ScVal::Vec(Some(args)) if args.contains(&name)) + }) + } + + #[test] + fn the_oracle_exposes_no_immediate_upgrade() { + let f = setup_with(|e, args| e.register(oracle_v1::WASM, args)); + let probe_hash = f.e.deployer().upload_contract_wasm(oracle_probe::WASM); + let upgrader = + stellar_contract_utils::upgradeable::UpgradeableClient::new(&f.e, &f.oracle.address); + + assert_eq!( + upgrader.try_upgrade(&probe_hash, &f.admin), + Err(Ok(host_refusal())) + ); + assert!(exports_no(&f.e, "upgrade")); + assert_eq!( + oracle_probe::Client::new(&f.e, &f.oracle.address).try_probe(), + Err(Ok(host_refusal())) + ); + assert!(exports_no(&f.e, "probe")); + assert_eq!(f.oracle.schema_version(), 1); + } + #[test] fn the_oracle_upgrades_under_a_live_valuation() { let f = setup_with(|e, args| e.register(oracle_v1::WASM, args)); diff --git a/contracts/share-token/src/test_governance.rs b/contracts/share-token/src/test_governance.rs index 522a27a..2e5e07e 100644 --- a/contracts/share-token/src/test_governance.rs +++ b/contracts/share-token/src/test_governance.rs @@ -328,19 +328,6 @@ fn an_expired_hand_over_does_not_hold_setup_open() { assert_eq!(f.token.get_admin(), Some(f.admin.clone())); } -#[test] -fn the_token_exposes_no_immediate_upgrade() { - let f = setup(); - f.finish(); - let upgrader = - stellar_contract_utils::upgradeable::UpgradeableClient::new(&f.e, &f.token.address); - - assert!(upgrader - .try_upgrade(&BytesN::from_array(&f.e, &[7; 32]), &f.admin) - .is_err()); - assert_eq!(f.token.schema_version(), 1); -} - #[test] fn no_role_ever_gets_a_role_admin() { let f = setup(); diff --git a/docs/AUTHORIZATION_MATRIX.md b/docs/AUTHORIZATION_MATRIX.md index adfe563..937ba7b 100644 --- a/docs/AUTHORIZATION_MATRIX.md +++ b/docs/AUTHORIZATION_MATRIX.md @@ -124,7 +124,7 @@ Tests: [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.r | `revoke_role` | Admin, `caller.require_auth()` | `compliance`: instant. `manager`: instant during setup, `UseTimelock` after | `test_governance::grants_and_manager_revokes_wait_after_setup` (the instant case: `test_governance::revoking_compliance_stays_instant_after_setup`) | | `transfer_admin_role` | Admin | Instant during setup; `UseTimelock` after. A zero `live_until_ledger` cancels at once | `test_governance::an_admin_hand_over_waits_but_cancelling_one_does_not` | | `set_role_admin` | Refused always (`ShareTokenError::UseTimelock`) | None | `test_governance::no_role_ever_gets_a_role_admin`. A role admin could grant its role with no delay. | -| `upgrade` | Not exposed | Upgrades go through `propose_change(Upgrade)` | `test_governance::the_token_exposes_no_immediate_upgrade` | +| `upgrade` | Not exposed | Upgrades go through `propose_change(Upgrade)` | `test::upgrade::with_wasm::the_token_exposes_no_immediate_upgrade` in `contracts/async-vault` (needs `upgrade_wasm`): the built token refuses `upgrade` with a real uploaded hash because it exports no such function | ### 5.3 NavOracle (`contracts/nav-oracle`) @@ -138,7 +138,7 @@ Tests: [`contracts/nav-oracle/src/test.rs`](../contracts/nav-oracle/src/test.rs) | `pause_nav` | `#[only_role(caller, "guardian")]` | None; instant after setup too | `test::pausing_the_nav_is_limited_to_the_guardian` (the instant case: `test_governance::the_nav_pause_and_clearing_stay_instant_after_setup`) | | `set_nav_paused` | `#[only_admin]`, `caller.require_auth()` | None; instant after setup too | `test::the_guardian_pauses_the_nav_but_unpausing_needs_governance` | | `clear_latest` | `#[only_admin]`, `caller.require_auth()` | NAV must be paused; instant after setup too | `test::only_admin_can_clear_latest`, `test::the_record_clears_only_while_the_nav_is_paused` | -| `set_config` | `#[only_admin]` (no caller argument; the stored admin signs) | Valid bounds. Instant while setup is open, or when the change is only a shorter `freshness_duration`; `UseTimelock` otherwise | `test::only_admin_can_set_config`, `test_governance::a_tighter_cap_or_band_waits_after_setup`, `test_governance::a_config_that_tightens_one_field_and_loosens_another_waits` | +| `set_config` | `#[only_admin]` (no caller argument; the stored admin signs) | Valid bounds. Instant while setup is open, or when the change is only a shorter `freshness_duration`; `UseTimelock` otherwise. Applying a `Config` change sets every field, freshness included; to keep a cut made after the proposal, cancel the queued change and propose again | `test::only_admin_can_set_config`, `test_governance::a_tighter_cap_or_band_waits_after_setup`, `test_governance::a_config_that_tightens_one_field_and_loosens_another_waits`, `test_governance::a_shorter_freshness_window_with_a_looser_field_waits`, `test_governance::a_freshness_cut_below_the_cooldown_is_refused` | | `renounce_admin` | Refused always (`OracleError::AdminRequired`) | None | `test::the_oracle_admin_cannot_renounce_itself_away`; `test_governance::an_applied_admin_change_starts_a_hand_over_the_next_admin_accepts`. An oracle without an admin could never be reconfigured, clear a stuck record or unpause the NAV; hand over by proposing and applying an `Admin` change (`transfer_admin_role` during setup), then the nominee calls `accept_admin_transfer`. | | `bind_vault` | `#[only_admin]`, `caller.require_auth()` | During setup, once; the vault must answer `timelock_clock` | `test_governance::setup_is_open_until_finished_and_never_reopens`, `test_governance::a_second_vault_cannot_be_bound`, `test_governance::an_address_that_reports_no_clock_cannot_be_bound` | | `finish_setup` | `#[only_admin]`, `caller.require_auth()` | Needs a bound vault and no pending admin hand-over; once | `test_governance::setup_is_open_until_finished_and_never_reopens`, `test_governance::setup_cannot_finish_while_a_hand_over_is_pending` | @@ -150,7 +150,7 @@ Tests: [`contracts/nav-oracle/src/test.rs`](../contracts/nav-oracle/src/test.rs) | `revoke_role` | Admin, `caller.require_auth()` | Instant: taking an attester or a guardian away moves no value | `test_governance::only_the_admin_revokes_a_role` (the instant case: `test_governance::revoking_an_attester_or_a_guardian_stays_instant_after_setup`) | | `transfer_admin_role` | Admin | Instant during setup; `UseTimelock` after. A zero `live_until_ledger` cancels at once | `test_governance::an_admin_hand_over_waits_but_cancelling_one_does_not` | | `set_role_admin` | Refused always (`OracleError::UseTimelock`) | None | `test_governance::no_role_ever_gets_a_role_admin`. A role admin could grant its role with no delay. | -| `upgrade` | Not exposed | Upgrades go through `propose_change(Upgrade)` | `test_governance::the_oracle_exposes_no_immediate_upgrade` | +| `upgrade` | Not exposed | Upgrades go through `propose_change(Upgrade)` | `test_governance::with_wasm::the_oracle_exposes_no_immediate_upgrade` (needs `upgrade_wasm`): the built oracle refuses `upgrade` with a real uploaded hash because it exports no such function | ### 5.4 IdentityVerifier (`contracts/identity-verifier`) diff --git a/docs/TESTING.md b/docs/TESTING.md index 76612a3..4cc71dc 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -114,5 +114,5 @@ contract and oracle, plus a Stellar Asset Contract for the deposit asset. | The vault checks the identity verifier at request; the share token checks it on mint and transfer | [`contracts/async-vault/src/test/compliance.rs`](../contracts/async-vault/src/test/compliance.rs), [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.rs) | `an_investor_who_was_never_allowlisted_is_refused_before_any_cash_moves`, `a_frozen_or_delisted_holder_cannot_transfer_shares`, `manager_mints_to_allowlisted_account` | | The vault reads the oracle and refuses a stale or paused feed | [`contracts/async-vault/src/test/oracle_pricing.rs`](../contracts/async-vault/src/test/oracle_pricing.rs) | `the_epoch_is_priced_at_the_attestation`, `fulfill_is_rejected_once_the_feed_goes_stale`, `a_paused_feed_is_refused_by_the_vault_not_the_oracle` | | Upgrading replaces the vault's code and migrates once | [`contracts/async-vault/src/test/upgrade.rs`](../contracts/async-vault/src/test/upgrade.rs) (needs `upgrade_wasm`) | `applying_a_wasm_change_replaces_the_code`, `a_migration_refuses_a_second_run` | -| The share token upgrades through the vault's timelock under live balances, an open deposit and an open redemption, and migrates once | [`contracts/async-vault/src/test/upgrade.rs`](../contracts/async-vault/src/test/upgrade.rs) (needs `upgrade_wasm`) | `the_token_upgrades_under_live_balances_and_open_requests` | -| The oracle upgrades through the vault's timelock under a live valuation, keeps its config, roles and vault, and migrates once | [`contracts/nav-oracle/src/test_governance.rs`](../contracts/nav-oracle/src/test_governance.rs) (needs `upgrade_wasm`) | `the_oracle_upgrades_under_a_live_valuation` | +| The share token upgrades through the vault's timelock under live balances, an open deposit and an open redemption, and migrates once; called directly, `upgrade` does not exist | [`contracts/async-vault/src/test/upgrade.rs`](../contracts/async-vault/src/test/upgrade.rs) (needs `upgrade_wasm`) | `the_token_upgrades_under_live_balances_and_open_requests`, `the_token_exposes_no_immediate_upgrade` | +| The oracle upgrades through the vault's timelock under a live valuation, keeps its config, roles and vault, and migrates once; called directly, `upgrade` does not exist | [`contracts/nav-oracle/src/test_governance.rs`](../contracts/nav-oracle/src/test_governance.rs) (needs `upgrade_wasm`) | `the_oracle_upgrades_under_a_live_valuation`, `the_oracle_exposes_no_immediate_upgrade` | diff --git a/docs/kit-spec.md b/docs/kit-spec.md index 0185227..2a37912 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -158,7 +158,7 @@ The `async_vault` constructor strictly verifies pairwise separation across autho | **Deposit Cap** | `set_deposit_cap(cap: Option)` | Governance | Maximum capacity for deposited capital. `None` = unbounded. Refuses negative cap. | | **Wind-Down Delay** | `set_wind_down_delay(secs: u64)` | Governance | Minimum notice before wind-down activation. Default: `0`. Capped at 90 days (`MAX_WIND_DOWN_DELAY`). | | **Upgrade Delay** | `propose_change(UpgradeDelay(secs))` → `apply_change(id)` | Governance | Timelock delay for wasm upgrades. Must be $\ge 7 \text{ days}$, $\le 90 \text{ days}$, and $\ge \text{notice}$. | -| **Oracle Configuration** | `set_config(config: OracleConfig)`; after setup, `propose_change(Config(config))` → `apply_change(id)` | Governance | Updates oracle parameters within the constructor's bounds. `set_config` is instant during the oracle's setup. After it, `set_config` is instant only for a shorter `freshness_duration`: later reports go stale sooner, and no value moves. Every other change, tighter limits included, goes through `propose_change(Config(config))` → `apply_change(id)`, and the bounds are checked again when it applies. A tighter fall cap or a longer cooldown can hold the price above a real loss, and a narrower band can stop pricing. | +| **Oracle Configuration** | `set_config(config: OracleConfig)`; after setup, `propose_change(Config(config))` → `apply_change(id)` | Governance | Updates oracle parameters within the constructor's bounds. `set_config` is instant during the oracle's setup. After it, `set_config` is instant only for a shorter `freshness_duration`: later reports go stale sooner, and no value moves. Every other change, tighter limits included, goes through `propose_change(Config(config))` → `apply_change(id)`, and the bounds are checked again when it applies. A tighter fall cap or a longer cooldown can hold the price above a real loss, and a narrower band can stop pricing. Applying a `Config` change sets every field, freshness included; to keep a cut made after the proposal, cancel the queued change and propose again. | ---