From caf88cb16b560dcca2c337bcff113f95cde2cd1e Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:17:52 -0300 Subject: [PATCH 001/147] feat(timelock): add a shared timelock crate --- Cargo.lock | 9 +++ crates/timelock/Cargo.toml | 17 +++++ crates/timelock/src/clock.rs | 39 +++++++++++ crates/timelock/src/delay.rs | 10 +++ crates/timelock/src/keys.rs | 17 +++++ crates/timelock/src/lib.rs | 23 +++++++ crates/timelock/src/pause.rs | 35 ++++++++++ crates/timelock/src/queue.rs | 80 ++++++++++++++++++++++ crates/timelock/src/refusal.rs | 23 +++++++ crates/timelock/src/schedule.rs | 39 +++++++++++ crates/timelock/src/setup.rs | 41 ++++++++++++ crates/timelock/src/test/clock.rs | 27 ++++++++ crates/timelock/src/test/delay.rs | 10 +++ crates/timelock/src/test/mod.rs | 84 +++++++++++++++++++++++ crates/timelock/src/test/pause.rs | 21 ++++++ crates/timelock/src/test/queue.rs | 99 ++++++++++++++++++++++++++++ crates/timelock/src/test/schedule.rs | 88 +++++++++++++++++++++++++ crates/timelock/src/test/setup.rs | 71 ++++++++++++++++++++ 18 files changed, 733 insertions(+) create mode 100644 crates/timelock/Cargo.toml create mode 100644 crates/timelock/src/clock.rs create mode 100644 crates/timelock/src/delay.rs create mode 100644 crates/timelock/src/keys.rs create mode 100644 crates/timelock/src/lib.rs create mode 100644 crates/timelock/src/pause.rs create mode 100644 crates/timelock/src/queue.rs create mode 100644 crates/timelock/src/refusal.rs create mode 100644 crates/timelock/src/schedule.rs create mode 100644 crates/timelock/src/setup.rs create mode 100644 crates/timelock/src/test/clock.rs create mode 100644 crates/timelock/src/test/delay.rs create mode 100644 crates/timelock/src/test/mod.rs create mode 100644 crates/timelock/src/test/pause.rs create mode 100644 crates/timelock/src/test/queue.rs create mode 100644 crates/timelock/src/test/schedule.rs create mode 100644 crates/timelock/src/test/setup.rs diff --git a/Cargo.lock b/Cargo.lock index 2eda79d5..cde39f7f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2175,6 +2175,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "timelock" +version = "0.1.0" +dependencies = [ + "proptest", + "soroban-sdk", + "storage", +] + [[package]] name = "tinyvec" version = "1.12.0" diff --git a/crates/timelock/Cargo.toml b/crates/timelock/Cargo.toml new file mode 100644 index 00000000..ada90bb8 --- /dev/null +++ b/crates/timelock/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "timelock" +version = "0.1.0" +edition = { workspace = true } +license = { workspace = true } +publish = false + +[lib] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } +storage = { path = "../storage" } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } +proptest = "1" diff --git a/crates/timelock/src/clock.rs b/crates/timelock/src/clock.rs new file mode 100644 index 00000000..e9471570 --- /dev/null +++ b/crates/timelock/src/clock.rs @@ -0,0 +1,39 @@ +use soroban_sdk::{contracttrait, contracttype, Env}; + +use crate::refusal::Refusal; +use crate::setup; + +/// The state of the contract that owns the clock, as every queue on that clock +/// reads it. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Clock { + /// Seconds a change proposed now waits. + pub delay: u64, + /// The longest an exit takes at the source. A change never waits less. + pub exit_window: u64, + /// Seconds the source has spent paused, a running pause included. + pub paused_secs: u64, + pub paused: bool, + pub winding_down: bool, +} + +/// Implemented by the contract that owns the clock, so the contracts bound to +/// it can read it. +#[contracttrait] +pub trait ClockSource { + fn timelock_clock(e: &Env) -> Clock; +} + +/// Every contract that keeps a change queue implements this. The queue and the +/// setup window read the clock only through it. +pub trait TimelockHost { + fn clock(e: &Env) -> Result; +} + +/// The clock of the source this contract is bound to. `Err(Refusal::Unbound)` +/// when none is bound. +pub fn from_source(e: &Env) -> Result { + let source = setup::source(e).ok_or(Refusal::Unbound)?; + Ok(ClockSourceClient::new(e, &source).timelock_clock()) +} diff --git a/crates/timelock/src/delay.rs b/crates/timelock/src/delay.rs new file mode 100644 index 00000000..ca560fa0 --- /dev/null +++ b/crates/timelock/src/delay.rs @@ -0,0 +1,10 @@ +pub const DAY_DURATION: u64 = 24 * 60 * 60; +/// Short enough to fix a defect in a week, long enough for anyone who +/// dislikes a change to leave before it lands. +pub const MIN_DELAY: u64 = 7 * DAY_DURATION; +/// No delay can wall a contract off from future changes. +pub const MAX_DELAY: u64 = 90 * DAY_DURATION; + +pub fn in_bounds(delay: u64) -> bool { + (MIN_DELAY..=MAX_DELAY).contains(&delay) +} diff --git a/crates/timelock/src/keys.rs b/crates/timelock/src/keys.rs new file mode 100644 index 00000000..d5ae78f5 --- /dev/null +++ b/crates/timelock/src/keys.rs @@ -0,0 +1,17 @@ +// The lint fires on the code `contracttype` generates, which an allow on the enum does not reach. +#![allow(clippy::enum_variant_names)] + +use soroban_sdk::contracttype; + +/// Each name becomes a storage key in the contract that keeps the queue, so +/// the prefix keeps these apart from the contract's own keys. +#[contracttype] +pub(crate) enum Key { + TimelockNextId, + TimelockOpen, + TimelockPending(u32), + TimelockSource, + TimelockSetupDone, + TimelockPausedSecs, + TimelockPausedSince, +} diff --git a/crates/timelock/src/lib.rs b/crates/timelock/src/lib.rs new file mode 100644 index 00000000..eb3d3139 --- /dev/null +++ b/crates/timelock/src/lib.rs @@ -0,0 +1,23 @@ +//! A timelock for privileged changes. One contract owns the clock: its delay, +//! its exit window, its pause time and whether it winds down. That contract and +//! every contract bound to it queue their changes against the same clock. + +#![no_std] + +pub mod clock; +pub mod delay; +mod keys; +pub mod pause; +pub mod queue; +pub mod refusal; +pub mod schedule; +pub mod setup; + +pub use clock::{Clock, ClockSource, ClockSourceClient, TimelockHost}; +pub use delay::{DAY_DURATION, MAX_DELAY, MIN_DELAY}; +pub use queue::MAX_PENDING; +pub use refusal::Refusal; +pub use schedule::Schedule; + +#[cfg(test)] +mod test; diff --git a/crates/timelock/src/pause.rs b/crates/timelock/src/pause.rs new file mode 100644 index 00000000..51f1b363 --- /dev/null +++ b/crates/timelock/src/pause.rs @@ -0,0 +1,35 @@ +//! Pause accounting for the contract that owns the clock. The contracts bound +//! to it read the total through `ClockSource`. + +use soroban_sdk::Env; + +use crate::keys::Key; + +/// A second start while paused changes nothing. +pub fn start(e: &Env) { + let since: Option = storage::get_instance(e, &Key::TimelockPausedSince); + if since.is_none() { + storage::set_instance(e, &Key::TimelockPausedSince, &e.ledger().timestamp()); + } +} + +/// Banks the time since `start`. An end with no pause running changes nothing. +pub fn end(e: &Env) { + let since: Option = storage::get_instance(e, &Key::TimelockPausedSince); + if let Some(since) = since { + let total = closed_secs(e).saturating_add(e.ledger().timestamp().saturating_sub(since)); + storage::set_instance(e, &Key::TimelockPausedSecs, &total); + e.storage().instance().remove(&Key::TimelockPausedSince); + } +} + +/// Seconds spent paused so far, a running pause included. +pub fn paused_secs(e: &Env) -> u64 { + let since: Option = storage::get_instance(e, &Key::TimelockPausedSince); + let running = since.map_or(0, |s| e.ledger().timestamp().saturating_sub(s)); + closed_secs(e).saturating_add(running) +} + +fn closed_secs(e: &Env) -> u64 { + storage::get_instance(e, &Key::TimelockPausedSecs).unwrap_or(0) +} diff --git a/crates/timelock/src/queue.rs b/crates/timelock/src/queue.rs new file mode 100644 index 00000000..d1adc354 --- /dev/null +++ b/crates/timelock/src/queue.rs @@ -0,0 +1,80 @@ +use core::fmt::Debug; + +use soroban_sdk::{Env, TryFromVal, Val, Vec}; + +use crate::clock::TimelockHost; +use crate::keys::Key; +use crate::refusal::Refusal; +use crate::schedule::Schedule; +use crate::setup; + +/// Changes that may wait at once. Bounds every walk over them. +pub const MAX_PENDING: u32 = 16; + +/// The ids of the changes waiting, oldest first. +pub fn open_ids(e: &Env) -> Vec { + storage::get_instance(e, &Key::TimelockOpen).unwrap_or_else(|| Vec::new(e)) +} + +pub fn pending(e: &Env, id: u32) -> Option<(C, Schedule)> +where + C: TryFromVal, + >::Error: Debug, +{ + storage::get_persistent(e, &Key::TimelockPending(id)) +} + +/// Queues a change behind the host's current delay and returns its id. +pub fn propose(e: &Env, change: &C) -> Result +where + C: Clone, + Val: TryFromVal, +{ + if setup::is_open(e) { + return Err(Refusal::SetupOpen); + } + let clock = H::clock(e)?; + if clock.winding_down { + return Err(Refusal::WindingDown); + } + let mut open = open_ids(e); + if open.len() >= MAX_PENDING { + return Err(Refusal::TooMany); + } + let id: u32 = storage::get_instance(e, &Key::TimelockNextId).unwrap_or(1); + let schedule = Schedule { + proposed_at: e.ledger().timestamp(), + delay: clock.delay, + paused_secs: clock.paused_secs, + }; + storage::set_persistent(e, &Key::TimelockPending(id), &(change.clone(), schedule)); + open.push_back(id); + storage::set_instance(e, &Key::TimelockOpen, &open); + storage::set_instance(e, &Key::TimelockNextId, &(id + 1)); + Ok(id) +} + +pub fn cancel(e: &Env, id: u32) -> Result<(), Refusal> { + remove(e, id) +} + +/// Removes and returns the change if it may land now. +pub fn take_ready(e: &Env, id: u32) -> Result +where + C: TryFromVal, + >::Error: Debug, +{ + let (change, schedule): (C, Schedule) = pending(e, id).ok_or(Refusal::NotFound)?; + schedule.check_ready(e.ledger().timestamp(), &H::clock(e)?)?; + remove(e, id)?; + Ok(change) +} + +fn remove(e: &Env, id: u32) -> Result<(), Refusal> { + let mut open = open_ids(e); + let index = open.first_index_of(id).ok_or(Refusal::NotFound)?; + open.remove(index); + storage::set_instance(e, &Key::TimelockOpen, &open); + e.storage().persistent().remove(&Key::TimelockPending(id)); + Ok(()) +} diff --git a/crates/timelock/src/refusal.rs b/crates/timelock/src/refusal.rs new file mode 100644 index 00000000..2d1ae57b --- /dev/null +++ b/crates/timelock/src/refusal.rs @@ -0,0 +1,23 @@ +/// Why the timelock refused. Each contract maps these onto its own error +/// codes, so its codes stay stable. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Refusal { + /// The contract that owns the clock is winding down: nothing is proposed + /// or applied. + WindingDown, + Paused, + /// The change's delay, plus the time paused since, has not run yet. + NotElapsed, + /// The change's delay is shorter than the current exit window. + BelowExitWindow, + /// No pending change has this id. + NotFound, + /// `MAX_PENDING` changes already wait. + TooMany, + /// Changes are instant until setup finishes, so none is queued before. + SetupOpen, + SetupClosed, + /// No clock source is bound. + Unbound, + AlreadyBound, +} diff --git a/crates/timelock/src/schedule.rs b/crates/timelock/src/schedule.rs new file mode 100644 index 00000000..ef12d694 --- /dev/null +++ b/crates/timelock/src/schedule.rs @@ -0,0 +1,39 @@ +use soroban_sdk::contracttype; + +use crate::clock::Clock; +use crate::refusal::Refusal; + +/// When a change was proposed, and the clock it started from. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Schedule { + pub proposed_at: u64, + pub delay: u64, + pub paused_secs: u64, +} + +impl Schedule { + /// The moment the change may land, pushed back by every second the source + /// has spent paused since it was proposed. + pub fn eta(&self, clock: &Clock) -> u64 { + self.proposed_at + .saturating_add(self.delay) + .saturating_add(clock.paused_secs.saturating_sub(self.paused_secs)) + } + + pub fn check_ready(&self, now: u64, clock: &Clock) -> Result<(), Refusal> { + if clock.winding_down { + return Err(Refusal::WindingDown); + } + if clock.paused { + return Err(Refusal::Paused); + } + if self.delay < clock.exit_window { + return Err(Refusal::BelowExitWindow); + } + if now < self.eta(clock) { + return Err(Refusal::NotElapsed); + } + Ok(()) + } +} diff --git a/crates/timelock/src/setup.rs b/crates/timelock/src/setup.rs new file mode 100644 index 00000000..195ad642 --- /dev/null +++ b/crates/timelock/src/setup.rs @@ -0,0 +1,41 @@ +//! The window after deployment in which wiring is instant and no change is +//! queued. It closes once and never reopens. + +use soroban_sdk::{Address, Env}; + +use crate::clock::{ClockSourceClient, TimelockHost}; +use crate::keys::Key; +use crate::refusal::Refusal; + +pub fn is_open(e: &Env) -> bool { + !storage::get_instance::<_, bool>(e, &Key::TimelockSetupDone).unwrap_or(false) +} + +/// The clock source this contract is bound to. +pub fn source(e: &Env) -> Option
{ + storage::get_instance(e, &Key::TimelockSource) +} + +/// Binds this contract to a clock source, once and only during setup. +pub fn bind_source(e: &Env, source: &Address) -> Result<(), Refusal> { + if !is_open(e) { + return Err(Refusal::SetupClosed); + } + if self::source(e).is_some() { + return Err(Refusal::AlreadyBound); + } + // An address that does not answer as a clock source fails the call here. + ClockSourceClient::new(e, source).timelock_clock(); + storage::set_instance(e, &Key::TimelockSource, source); + Ok(()) +} + +/// Closes the window for good, once the host can read its clock. +pub fn finish(e: &Env) -> Result<(), Refusal> { + if !is_open(e) { + return Err(Refusal::SetupClosed); + } + H::clock(e)?; + storage::set_instance(e, &Key::TimelockSetupDone, &true); + Ok(()) +} diff --git a/crates/timelock/src/test/clock.rs b/crates/timelock/src/test/clock.rs new file mode 100644 index 00000000..cec86539 --- /dev/null +++ b/crates/timelock/src/test/clock.rs @@ -0,0 +1,27 @@ +use super::*; +use crate::clock::from_source; +use crate::setup::bind_source; + +#[test] +fn an_unbound_contract_has_no_clock() { + let e = Env::default(); + let follower = e.register(Follower, ()); + e.as_contract(&follower, || { + assert_eq!(from_source(&e), Err(Refusal::Unbound)); + }); +} + +#[test] +fn a_bound_contract_reads_its_source_clock_as_it_changes() { + let (e, source) = owner(); + let follower = e.register(Follower, ()); + e.as_contract(&follower, || bind_source(&e, &source).unwrap()); + + let mut reported = clock(MIN_DELAY); + reported.exit_window = 3_600; + reported.paused_secs = 42; + reported.paused = true; + e.as_contract(&source, || set_clock(&e, &reported)); + + assert_eq!(e.as_contract(&follower, || from_source(&e)), Ok(reported)); +} diff --git a/crates/timelock/src/test/delay.rs b/crates/timelock/src/test/delay.rs new file mode 100644 index 00000000..c16a7eb7 --- /dev/null +++ b/crates/timelock/src/test/delay.rs @@ -0,0 +1,10 @@ +use crate::delay::in_bounds; +use crate::DAY_DURATION; + +#[test] +fn a_delay_from_seven_to_ninety_days_is_in_bounds() { + assert!(!in_bounds(7 * DAY_DURATION - 1)); + assert!(in_bounds(7 * DAY_DURATION)); + assert!(in_bounds(90 * DAY_DURATION)); + assert!(!in_bounds(90 * DAY_DURATION + 1)); +} diff --git a/crates/timelock/src/test/mod.rs b/crates/timelock/src/test/mod.rs new file mode 100644 index 00000000..9d8e2b85 --- /dev/null +++ b/crates/timelock/src/test/mod.rs @@ -0,0 +1,84 @@ +extern crate std; + +mod clock; +mod delay; +mod pause; +mod queue; +mod schedule; +mod setup; + +use soroban_sdk::{contract, contractimpl, contracttype, testutils::Ledger as _, Address, Env}; + +use crate::{Clock, ClockSource, Refusal, TimelockHost, MIN_DELAY}; + +#[contracttype] +enum TestKey { + Clock, +} + +/// Owns its clock. A test sets the clock it reports. +#[contract] +struct Owner; + +impl TimelockHost for Owner { + fn clock(e: &Env) -> Result { + Ok(e.storage() + .instance() + .get(&TestKey::Clock) + .unwrap_or_else(|| clock(MIN_DELAY))) + } +} + +#[contractimpl(contracttrait)] +impl ClockSource for Owner { + fn timelock_clock(e: &Env) -> Clock { + ::clock(e).unwrap() + } +} + +/// Reads its clock from the source it is bound to. +#[contract] +struct Follower; + +impl TimelockHost for Follower { + fn clock(e: &Env) -> Result { + crate::clock::from_source(e) + } +} + +#[contractimpl] +impl Follower { + pub fn bind(e: Env, source: Address) { + crate::setup::bind_source(&e, &source).unwrap(); + } +} + +fn clock(delay: u64) -> Clock { + Clock { + delay, + exit_window: 0, + paused_secs: 0, + paused: false, + winding_down: false, + } +} + +/// Makes the owner report this clock. Runs inside the owner's frame. +fn set_clock(e: &Env, clock: &Clock) { + e.storage().instance().set(&TestKey::Clock, clock); +} + +/// An owner at ledger time 1_000, its setup window still open. +fn owner() -> (Env, Address) { + let e = Env::default(); + e.ledger().set_timestamp(1_000); + let id = e.register(Owner, ()); + (e, id) +} + +/// An owner past setup, so its queue takes changes. +fn ready_owner() -> (Env, Address) { + let (e, id) = owner(); + e.as_contract(&id, || crate::setup::finish::(&e).unwrap()); + (e, id) +} diff --git a/crates/timelock/src/test/pause.rs b/crates/timelock/src/test/pause.rs new file mode 100644 index 00000000..bb68ab88 --- /dev/null +++ b/crates/timelock/src/test/pause.rs @@ -0,0 +1,21 @@ +use super::*; +use crate::pause; + +#[test] +fn pause_time_accumulates_across_pauses() { + let (e, id) = owner(); + e.as_contract(&id, || { + pause::start(&e); + e.ledger().set_timestamp(1_100); + assert_eq!(pause::paused_secs(&e), 100); + pause::start(&e); + pause::end(&e); + e.ledger().set_timestamp(2_000); + pause::start(&e); + e.ledger().set_timestamp(2_050); + pause::end(&e); + pause::end(&e); + + assert_eq!(pause::paused_secs(&e), 150); + }); +} diff --git a/crates/timelock/src/test/queue.rs b/crates/timelock/src/test/queue.rs new file mode 100644 index 00000000..179bdd0f --- /dev/null +++ b/crates/timelock/src/test/queue.rs @@ -0,0 +1,99 @@ +use soroban_sdk::{testutils::Address as _, vec}; + +use super::*; +use crate::queue::{cancel, open_ids, pending, propose, take_ready, MAX_PENDING}; + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +enum Change { + Upgrade(u32), + Rotate(Address), +} + +#[test] +fn a_change_lands_one_delay_after_it_was_proposed() { + let (e, id) = ready_owner(); + e.as_contract(&id, || { + let change_id = propose::(&e, &Change::Upgrade(2)).unwrap(); + + assert_eq!( + take_ready::(&e, change_id), + Err(Refusal::NotElapsed) + ); + e.ledger().set_timestamp(1_000 + MIN_DELAY); + assert_eq!( + take_ready::(&e, change_id), + Ok(Change::Upgrade(2)) + ); + assert_eq!(pending::(&e, change_id), None); + }); +} + +#[test] +fn nothing_is_proposed_while_setup_is_open() { + let (e, id) = owner(); + e.as_contract(&id, || { + assert_eq!( + propose::(&e, &Change::Upgrade(2)), + Err(Refusal::SetupOpen) + ); + assert_eq!(open_ids(&e).len(), 0); + }); +} + +#[test] +fn nothing_is_proposed_during_a_wind_down() { + let (e, id) = ready_owner(); + e.as_contract(&id, || { + let mut c = clock(MIN_DELAY); + c.winding_down = true; + set_clock(&e, &c); + + assert_eq!( + propose::(&e, &Change::Upgrade(2)), + Err(Refusal::WindingDown) + ); + assert_eq!(open_ids(&e).len(), 0); + }); +} + +#[test] +fn several_changes_wait_at_once_and_cancel_independently() { + let (e, id) = ready_owner(); + e.as_contract(&id, || { + let first = propose::(&e, &Change::Upgrade(2)).unwrap(); + let second = propose::(&e, &Change::Rotate(Address::generate(&e))).unwrap(); + assert_ne!(first, second); + + cancel(&e, first).unwrap(); + + assert_eq!(open_ids(&e), vec![&e, second]); + assert_eq!(cancel(&e, first), Err(Refusal::NotFound)); + assert!(pending::(&e, second).is_some()); + }); +} + +#[test] +fn the_queue_holds_at_most_max_pending() { + let (e, id) = ready_owner(); + e.as_contract(&id, || { + for n in 0..MAX_PENDING { + propose::(&e, &Change::Upgrade(n)).unwrap(); + } + assert_eq!( + propose::(&e, &Change::Upgrade(99)), + Err(Refusal::TooMany) + ); + }); +} + +#[test] +fn a_cancelled_id_is_never_handed_out_again() { + let (e, id) = ready_owner(); + e.as_contract(&id, || { + let first = propose::(&e, &Change::Upgrade(1)).unwrap(); + cancel(&e, first).unwrap(); + let second = propose::(&e, &Change::Upgrade(1)).unwrap(); + assert!(second > first); + }); +} diff --git a/crates/timelock/src/test/schedule.rs b/crates/timelock/src/test/schedule.rs new file mode 100644 index 00000000..1540842d --- /dev/null +++ b/crates/timelock/src/test/schedule.rs @@ -0,0 +1,88 @@ +use proptest::prelude::*; + +use super::*; +use crate::{Schedule, DAY_DURATION, MAX_DELAY}; + +#[test] +fn time_spent_paused_does_not_count_toward_the_delay() { + let schedule = Schedule { + proposed_at: 1_000, + delay: MIN_DELAY, + paused_secs: 50, + }; + let mut c = clock(MIN_DELAY); + c.paused_secs = 50 + 3 * DAY_DURATION; + + assert_eq!(schedule.eta(&c), 1_000 + MIN_DELAY + 3 * DAY_DURATION); + assert_eq!( + schedule.check_ready(1_000 + MIN_DELAY, &c), + Err(Refusal::NotElapsed) + ); + assert_eq!( + schedule.check_ready(1_000 + MIN_DELAY + 3 * DAY_DURATION, &c), + Ok(()) + ); +} + +#[test] +fn nothing_lands_while_paused_or_winding_down() { + let schedule = Schedule { + proposed_at: 0, + delay: MIN_DELAY, + paused_secs: 0, + }; + let mut paused = clock(MIN_DELAY); + paused.paused = true; + let mut winding = clock(MIN_DELAY); + winding.winding_down = true; + let mut both = paused.clone(); + both.winding_down = true; + + assert_eq!( + schedule.check_ready(MAX_DELAY, &paused), + Err(Refusal::Paused) + ); + assert_eq!( + schedule.check_ready(MAX_DELAY, &winding), + Err(Refusal::WindingDown) + ); + assert_eq!( + schedule.check_ready(MAX_DELAY, &both), + Err(Refusal::WindingDown) + ); +} + +#[test] +fn a_change_shorter_than_the_current_exit_window_never_lands() { + let schedule = Schedule { + proposed_at: 0, + delay: MIN_DELAY, + paused_secs: 0, + }; + let mut c = clock(MIN_DELAY); + c.exit_window = MIN_DELAY + 1; + + assert_eq!(schedule.check_ready(0, &c), Err(Refusal::BelowExitWindow)); + assert_eq!( + schedule.check_ready(MAX_DELAY, &c), + Err(Refusal::BelowExitWindow) + ); +} + +proptest! { + #[test] + fn the_eta_never_comes_before_proposal_plus_delay( + proposed_at in 0u64..1_000_000_000, + delay in MIN_DELAY..=MAX_DELAY, + paused_before in 0u64..1_000_000, + paused_since in 0u64..1_000_000, + ) { + let schedule = Schedule { proposed_at, delay, paused_secs: paused_before }; + let mut c = clock(delay); + c.paused_secs = paused_before + paused_since; + let at = schedule.eta(&c); + prop_assert_eq!(at, proposed_at + delay + paused_since); + prop_assert!(schedule.check_ready(at - 1, &c).is_err()); + prop_assert!(schedule.check_ready(at, &c).is_ok()); + } +} diff --git a/crates/timelock/src/test/setup.rs b/crates/timelock/src/test/setup.rs new file mode 100644 index 00000000..9be8f5a0 --- /dev/null +++ b/crates/timelock/src/test/setup.rs @@ -0,0 +1,71 @@ +use soroban_sdk::testutils::Address as _; + +use super::*; +use crate::setup::{bind_source, finish, is_open, source}; + +#[test] +fn the_setup_window_closes_once() { + let (e, id) = owner(); + e.as_contract(&id, || { + assert!(is_open(&e)); + + assert_eq!(finish::(&e), Ok(())); + assert!(!is_open(&e)); + assert_eq!(finish::(&e), Err(Refusal::SetupClosed)); + }); +} + +#[test] +fn a_bound_contract_cannot_finish_setup_unbound() { + let e = Env::default(); + let follower = e.register(Follower, ()); + e.as_contract(&follower, || { + assert_eq!(finish::(&e), Err(Refusal::Unbound)); + assert!(is_open(&e)); + }); +} + +#[test] +fn a_contract_binds_one_source() { + let (e, first) = owner(); + let second = e.register(Owner, ()); + let follower = e.register(Follower, ()); + e.as_contract(&follower, || { + assert_eq!(source(&e), None); + assert_eq!(bind_source(&e, &first), Ok(())); + assert_eq!(bind_source(&e, &second), Err(Refusal::AlreadyBound)); + assert_eq!(source(&e), Some(first.clone())); + }); +} + +#[test] +fn nothing_binds_once_setup_is_finished() { + let (e, first) = owner(); + let second = e.register(Owner, ()); + let follower = e.register(Follower, ()); + e.as_contract(&follower, || { + bind_source(&e, &first).unwrap(); + finish::(&e).unwrap(); + + assert_eq!(bind_source(&e, &second), Err(Refusal::SetupClosed)); + assert_eq!(source(&e), Some(first.clone())); + }); +} + +#[test] +fn binding_refuses_an_address_that_is_not_a_clock_source() { + let (e, clock_source) = owner(); + let follower = FollowerClient::new(&e, &e.register(Follower, ())); + let no_clock = e.register(Follower, ()); + let no_contract = Address::generate(&e); + + assert!(follower.try_bind(&no_clock).is_err()); + assert!(follower.try_bind(&no_contract).is_err()); + assert_eq!(e.as_contract(&follower.address, || source(&e)), None); + + follower.bind(&clock_source); + assert_eq!( + e.as_contract(&follower.address, || source(&e)), + Some(clock_source) + ); +} From 0d84a5d12eb6565e26a17d54beaf7cbf3a29ac55 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:33:22 -0300 Subject: [PATCH 002/147] feat(vault): queue upgrades on the shared timelock --- Cargo.lock | 1 + contracts/async-vault/Cargo.toml | 1 + contracts/async-vault/src/error.rs | 16 +- contracts/async-vault/src/event.rs | 27 +- contracts/async-vault/src/governance.rs | 122 +++++++++ contracts/async-vault/src/keys.rs | 2 - contracts/async-vault/src/lib.rs | 62 +++-- contracts/async-vault/src/test/mod.rs | 5 +- contracts/async-vault/src/test/notice.rs | 8 +- contracts/async-vault/src/test/upgrade.rs | 316 +++++++++++++++------- contracts/async-vault/src/upgrade.rs | 119 +------- contracts/async-vault/src/wind_down.rs | 2 +- docs/AUTHORIZATION_MATRIX.md | 8 +- docs/kit-spec.md | 19 +- docs/strata-product-and-architecture.md | 2 +- 15 files changed, 431 insertions(+), 279 deletions(-) create mode 100644 contracts/async-vault/src/governance.rs diff --git a/Cargo.lock b/Cargo.lock index cde39f7f..67427429 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -195,6 +195,7 @@ dependencies = [ "stellar-contract-utils", "stellar-macros", "storage", + "timelock", ] [[package]] diff --git a/contracts/async-vault/Cargo.toml b/contracts/async-vault/Cargo.toml index 2da5c3d3..bfe945f9 100644 --- a/contracts/async-vault/Cargo.toml +++ b/contracts/async-vault/Cargo.toml @@ -16,6 +16,7 @@ stellar-access = { workspace = true } stellar-macros = { workspace = true } stellar-contract-utils = { workspace = true } bindings = { path = "../../crates/bindings" } +timelock = { path = "../../crates/timelock" } [dev-dependencies] diff --git a/contracts/async-vault/src/error.rs b/contracts/async-vault/src/error.rs index 9d487fff..8f3667de 100644 --- a/contracts/async-vault/src/error.rs +++ b/contracts/async-vault/src/error.rs @@ -92,17 +92,18 @@ pub enum VaultError { /// Nothing to surrender and nothing owed. Claiming is idempotent by /// rejection, as elsewhere in this contract. NoEntitlement = 6054, - /// A proposal already stands. Replacing one means cancelling it first, so + /// A code change already waits. Replacing one means cancelling it first, so /// the standing eta is never quietly moved. UpgradeProposalExists = 6055, - /// No proposal stands. - UpgradeProposalNotFound = 6056, - /// The delay has not elapsed. - UpgradeDelayNotElapsed = 6057, + /// No pending change has this id. + ChangeNotFound = 6056, + /// The change's delay has not elapsed. + ChangeNotReady = 6057, /// The delay is below the shortest the vault accepts. UpgradeDelayTooShort = 6058, /// The delay is shorter than the notice, so an investor could not complete - /// an exit before the change applies. + /// an exit before the change applies. Checked when a change is proposed and + /// again when it applies, since the notice may have risen in between. UpgradeDelayBelowNotice = 6059, /// The delay exceeds the longest the vault accepts. UpgradeDelayTooLong = 6060, @@ -111,4 +112,7 @@ pub enum VaultError { NoticeAboveUpgradeDelay = 6061, /// A deposit request would breach the vault's configured deposit cap. DepositCapExceeded = 6062, + /// As many changes as the timelock holds already wait. Apply or cancel one + /// first. + TooManyPendingChanges = 6063, } diff --git a/contracts/async-vault/src/event.rs b/contracts/async-vault/src/event.rs index 26f1cf5a..d7bae7b1 100644 --- a/contracts/async-vault/src/event.rs +++ b/contracts/async-vault/src/event.rs @@ -1,5 +1,7 @@ use soroban_sdk::{contractevent, Address}; +use crate::governance::VaultChange; + #[contractevent] pub struct DepositRequested { #[topic] @@ -94,27 +96,24 @@ pub struct RedeemCancelled { } #[contractevent] -pub struct UpgradeProposed { - pub eta: u64, -} - -#[contractevent] -pub struct UpgradeDelayProposed { - pub secs: u64, +pub struct ChangeProposed { + #[topic] + pub id: u32, + pub change: VaultChange, pub eta: u64, } #[contractevent] -pub struct UpgradeCancelled {} - -#[contractevent] -pub struct UpgradeDelaySet { - pub secs: u64, +pub struct ChangeCancelled { + #[topic] + pub id: u32, } #[contractevent] -pub struct Upgraded { - pub schema_version: u32, +pub struct ChangeApplied { + #[topic] + pub id: u32, + pub change: VaultChange, } #[contractevent] diff --git a/contracts/async-vault/src/governance.rs b/contracts/async-vault/src/governance.rs new file mode 100644 index 00000000..bf993c51 --- /dev/null +++ b/contracts/async-vault/src/governance.rs @@ -0,0 +1,122 @@ +use soroban_sdk::{contracttype, panic_with_error, BytesN, Env, Vec}; +use stellar_contract_utils::{ + pausable::{self, PausableError}, + upgradeable, +}; +use timelock::{queue, Clock, Refusal}; + +use crate::error::VaultError; +use crate::event::{ChangeApplied, ChangeCancelled, ChangeProposed}; +use crate::state; +use crate::upgrade; +use crate::wind_down; +use crate::AsyncVault; + +/// A privileged change that waits for the timelock before it lands. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum VaultChange { + Upgrade(BytesN<32>), + UpgradeDelay(u64), +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VaultPendingChange { + pub id: u32, + pub change: VaultChange, + pub eta: u64, +} + +/// The clock this vault's queue runs on, and that it reports to the contracts +/// bound to it. +pub(crate) fn clock(e: &Env) -> Clock { + Clock { + delay: upgrade::delay(e), + exit_window: state::notice(e), + paused_secs: timelock::pause::paused_secs(e), + paused: pausable::paused(e), + winding_down: wind_down::is_active(e), + } +} + +pub(crate) fn refuse(e: &Env, refusal: Refusal) -> ! { + match refusal { + Refusal::WindingDown => panic_with_error!(e, VaultError::WindDownActive), + Refusal::Paused => panic_with_error!(e, PausableError::EnforcedPause), + Refusal::NotElapsed => panic_with_error!(e, VaultError::ChangeNotReady), + Refusal::BelowExitWindow => panic_with_error!(e, VaultError::UpgradeDelayBelowNotice), + Refusal::NotFound => panic_with_error!(e, VaultError::ChangeNotFound), + Refusal::TooMany => panic_with_error!(e, VaultError::TooManyPendingChanges), + // The constructor closes setup and binds nothing, so these mean a broken instance. + Refusal::SetupOpen | Refusal::SetupClosed | Refusal::Unbound | Refusal::AlreadyBound => { + panic_with_error!(e, VaultError::NotInitialized) + } + } +} + +fn changes_code(change: &VaultChange) -> bool { + matches!( + change, + VaultChange::Upgrade(_) | VaultChange::UpgradeDelay(_) + ) +} + +/// One code change at a time, so a standing eta is never quietly replaced. +fn code_change_pending(e: &Env) -> bool { + queue::open_ids(e).iter().any(|id| { + queue::pending::(e, id).is_some_and(|(change, _)| changes_code(&change)) + }) +} + +pub(crate) fn propose(e: &Env, change: VaultChange) -> u32 { + wind_down::refuse_if_active(e); + if let VaultChange::UpgradeDelay(secs) = change { + upgrade::check_delay(e, secs); + } + if changes_code(&change) && code_change_pending(e) { + panic_with_error!(e, VaultError::UpgradeProposalExists); + } + let id = queue::propose::(e, &change).unwrap_or_else(|r| refuse(e, r)); + ChangeProposed { + id, + change, + eta: e.ledger().timestamp().saturating_add(upgrade::delay(e)), + } + .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: VaultChange = + queue::take_ready::(e, id).unwrap_or_else(|r| refuse(e, r)); + match &change { + VaultChange::UpgradeDelay(secs) => { + // The notice may have moved since the proposal; this check decides. + upgrade::check_delay(e, *secs); + upgrade::set_delay(e, *secs); + } + VaultChange::Upgrade(hash) => upgradeable::upgrade(e, hash), + } + ChangeApplied { id, change }.publish(e); +} + +pub(crate) fn pending(e: &Env) -> Vec { + let clock = clock(e); + let mut out = Vec::new(e); + for id in queue::open_ids(e).iter() { + if let Some((change, schedule)) = queue::pending::(e, id) { + out.push_back(VaultPendingChange { + id, + change, + eta: schedule.eta(&clock), + }); + } + } + out +} diff --git a/contracts/async-vault/src/keys.rs b/contracts/async-vault/src/keys.rs index 401cd278..e35fed58 100644 --- a/contracts/async-vault/src/keys.rs +++ b/contracts/async-vault/src/keys.rs @@ -17,8 +17,6 @@ pub(crate) enum DataKey { UserDeposit(u64, Address), UserRedeem(u64, Address), UpgradeDelay, - UpgradeProposal, - PausedAt, WindDownDelay, WindDown, WindDownSupply, diff --git a/contracts/async-vault/src/lib.rs b/contracts/async-vault/src/lib.rs index 69b552d7..68138734 100644 --- a/contracts/async-vault/src/lib.rs +++ b/contracts/async-vault/src/lib.rs @@ -4,6 +4,7 @@ mod deposit; mod epoch; mod error; mod event; +mod governance; mod keys; mod pricing; mod redeem; @@ -15,11 +16,12 @@ mod upgrade; mod wind_down; use bindings::ShareClient; -use soroban_sdk::{contract, contractimpl, panic_with_error, Address, BytesN, Env, Symbol, Vec}; +use soroban_sdk::{contract, contractimpl, panic_with_error, Address, Env, Symbol, Vec}; use stellar_access::access_control::{self, AccessControl}; use stellar_contract_utils::pausable::{self as pausable, Pausable}; use stellar_contract_utils::upgradeable; use stellar_macros::{only_admin, only_role, when_not_paused}; +use timelock::{Clock, ClockSource, Refusal, TimelockHost, DAY_DURATION}; use keys::DataKey; use roles::{GUARDIAN_ROLE, MANAGER_ROLE, TREASURY_ROLE}; @@ -27,21 +29,21 @@ use state::FIRST_EPOCH; pub use error::VaultError; pub use event::{ - CustodianSet, Deployed, DepositCapUpdated, DepositClaimed, DepositRequested, EpochClosed, - EpochFulfilled, Funded, NoticeSet, RedeemClaimed, RedeemRequested, UpgradeCancelled, - UpgradeDelayProposed, UpgradeDelaySet, UpgradeProposed, Upgraded, WindDownActivated, - WindDownClaimed, WindDownDelaySet, WindDownProposalCancelled, WindDownProposed, - WindDownRoundFinalized, + ChangeApplied, ChangeCancelled, ChangeProposed, CustodianSet, Deployed, DepositCapUpdated, + DepositClaimed, DepositRequested, EpochClosed, EpochFulfilled, Funded, NoticeSet, + RedeemClaimed, RedeemRequested, WindDownActivated, WindDownClaimed, WindDownDelaySet, + WindDownProposalCancelled, WindDownProposed, WindDownRoundFinalized, }; +pub use governance::{VaultChange, VaultPendingChange}; pub use pricing::{DirectUnitPricing, PricingScheme}; pub use roles::VaultRoles; pub use state::{DepositRequest, EpochInfo, EpochStatus, RedeemRequest}; -pub use upgrade::{UpgradeAction, UpgradeProposal, MAX_UPGRADE_DELAY, MIN_UPGRADE_DELAY}; +pub use upgrade::{MAX_UPGRADE_DELAY, MIN_UPGRADE_DELAY}; pub use wind_down::{WindDownInfo, WindDownPosition, WindDownStatus, MAX_WIND_DOWN_DELAY}; /// Thirty days. Longer than any notice a real structure uses, short enough that /// setting it by mistake is survivable. -pub const MAX_NOTICE_SECS: u64 = 30 * 24 * 60 * 60; +pub const MAX_NOTICE_SECS: u64 = 30 * DAY_DURATION; fn role_holder(e: &Env, role: &Symbol) -> Option
{ if access_control::get_role_member_count(e, role) == 0 { @@ -88,6 +90,7 @@ impl AsyncVault { state::set_epoch(e, FIRST_EPOCH, &epoch::open(0)); state::set_current_epoch(e, FIRST_EPOCH); upgradeable::set_schema_version(e, 1); + timelock::setup::finish::(e).unwrap_or_else(|r| governance::refuse(e, r)); } pub fn asset(e: &Env) -> Address { @@ -265,39 +268,35 @@ impl AsyncVault { NoticeSet { secs }.publish(e); } + /// Queues a change behind the timelock and returns its id. #[only_admin] - pub fn propose_upgrade(e: &Env, wasm_hash: BytesN<32>, caller: Address) { + pub fn propose_change(e: &Env, change: VaultChange, caller: Address) -> u32 { caller.require_auth(); - upgrade::propose_wasm(e, wasm_hash); + governance::propose(e, change) } #[only_admin] - pub fn propose_upgrade_delay(e: &Env, secs: u64, caller: Address) { + pub fn cancel_change(e: &Env, id: u32, caller: Address) { caller.require_auth(); - upgrade::propose_delay(e, secs); + governance::cancel(e, id); } + /// Lands a change whose delay has run. Refused while paused or winding + /// down. #[only_admin] - pub fn cancel_upgrade(e: &Env, caller: Address) { + pub fn apply_change(e: &Env, id: u32, caller: Address) { caller.require_auth(); - upgrade::cancel(e); + governance::apply(e, id); } - #[only_admin] - #[when_not_paused] - pub fn apply_upgrade(e: &Env, caller: Address) { - caller.require_auth(); - upgrade::apply(e); + pub fn pending_changes(e: &Env) -> Vec { + governance::pending(e) } pub fn upgrade_delay(e: &Env) -> u64 { upgrade::delay(e) } - pub fn upgrade_proposal(e: &Env) -> Option { - upgrade::proposal(e) - } - pub fn schema_version(e: &Env) -> u32 { upgradeable::get_schema_version(e) } @@ -393,14 +392,27 @@ impl Pausable for AsyncVault { #[only_role(caller, "guardian")] fn pause(e: &Env, caller: Address) { pausable::pause(e); - upgrade::on_pause(e); + timelock::pause::start(e); } #[only_admin] fn unpause(e: &Env, caller: Address) { caller.require_auth(); pausable::unpause(e); - upgrade::on_unpause(e); + timelock::pause::end(e); + } +} + +#[contractimpl(contracttrait)] +impl ClockSource for AsyncVault { + fn timelock_clock(e: &Env) -> Clock { + governance::clock(e) + } +} + +impl TimelockHost for AsyncVault { + fn clock(e: &Env) -> Result { + Ok(governance::clock(e)) } } diff --git a/contracts/async-vault/src/test/mod.rs b/contracts/async-vault/src/test/mod.rs index a22c194a..672bc01f 100644 --- a/contracts/async-vault/src/test/mod.rs +++ b/contracts/async-vault/src/test/mod.rs @@ -34,10 +34,11 @@ pub(crate) use nav_oracle::{NavOracleContract, NavOracleContractClient, NavRepor pub(crate) use share_token::{ShareToken, ShareTokenClient}; pub(crate) use stellar_contract_utils::math::wad::WAD_SCALE; +pub(crate) use timelock::DAY_DURATION; pub(crate) use crate::{ - AsyncVault, AsyncVaultClient, DirectUnitPricing, EpochStatus, PricingScheme, UpgradeAction, - VaultError, VaultRoles, WindDownStatus, MAX_NOTICE_SECS, MAX_UPGRADE_DELAY, + AsyncVault, AsyncVaultClient, DirectUnitPricing, EpochStatus, PricingScheme, VaultChange, + VaultError, VaultPendingChange, VaultRoles, WindDownStatus, MAX_NOTICE_SECS, MAX_UPGRADE_DELAY, MAX_WIND_DOWN_DELAY, MIN_UPGRADE_DELAY, }; diff --git a/contracts/async-vault/src/test/notice.rs b/contracts/async-vault/src/test/notice.rs index 3541e753..4f33a0aa 100644 --- a/contracts/async-vault/src/test/notice.rs +++ b/contracts/async-vault/src/test/notice.rs @@ -58,7 +58,7 @@ fn a_later_notice_does_not_move_an_epoch_that_closed() { let id = f.close_epoch(); let before = f.vault.get_epoch(&id).unwrap().priceable_at; - f.vault.set_notice(&86_400, &f.admin); + f.vault.set_notice(&DAY_DURATION, &f.admin); assert_eq!(f.vault.get_epoch(&id).unwrap().priceable_at, before); } @@ -171,9 +171,11 @@ fn a_notice_at_the_maximum_is_accepted() { let f = setup(); // The delay must be raised first, since notice cannot outgrow the upgrade delay. - f.vault.propose_upgrade_delay(&MAX_NOTICE_SECS, &f.admin); + let id = f + .vault + .propose_change(&VaultChange::UpgradeDelay(MAX_NOTICE_SECS), &f.admin); f.advance(MIN_UPGRADE_DELAY); - f.vault.apply_upgrade(&f.admin); + f.vault.apply_change(&id, &f.admin); f.vault.set_notice(&MAX_NOTICE_SECS, &f.admin); diff --git a/contracts/async-vault/src/test/upgrade.rs b/contracts/async-vault/src/test/upgrade.rs index 5d159cf2..08ee3b3b 100644 --- a/contracts/async-vault/src/test/upgrade.rs +++ b/contracts/async-vault/src/test/upgrade.rs @@ -1,6 +1,28 @@ +use soroban_sdk::{testutils::Events as _, Event as _}; + use super::*; +use crate::{ChangeApplied, ChangeCancelled, ChangeProposed}; -const DAY: u64 = 24 * 60 * 60; +fn wasm(f: &Fixture, byte: u8) -> BytesN<32> { + BytesN::from_array(&f.e, &[byte; 32]) +} + +fn propose(f: &Fixture, change: VaultChange) -> u32 { + f.vault.propose_change(&change, &f.admin) +} + +/// Raises the upgrade delay through the timelock, which costs one current delay. +fn raise_delay(f: &Fixture, secs: u64) { + let id = propose(f, VaultChange::UpgradeDelay(secs)); + f.advance(f.vault.upgrade_delay()); + f.vault.apply_change(&id, &f.admin); +} + +fn only_pending(f: &Fixture) -> VaultPendingChange { + let pending = f.vault.pending_changes(); + assert_eq!(pending.len(), 1); + pending.get(0).unwrap() +} #[test] fn the_delay_defaults_to_the_minimum() { @@ -12,28 +34,29 @@ fn the_delay_defaults_to_the_minimum() { #[test] fn a_proposal_records_an_eta_one_delay_out() { let f = setup(); - let hash = BytesN::from_array(&f.e, &[7u8; 32]); + let hash = wasm(&f, 7); - f.vault.propose_upgrade(&hash, &f.admin); + let id = propose(&f, VaultChange::Upgrade(hash.clone())); - let p = f.vault.upgrade_proposal().unwrap(); - assert_eq!(p.action, UpgradeAction::Wasm(hash)); + let p = only_pending(&f); + assert_eq!(p.id, id); + assert_eq!(p.change, VaultChange::Upgrade(hash)); assert_eq!(p.eta, f.e.ledger().timestamp() + MIN_UPGRADE_DELAY); } #[test] -fn a_second_proposal_while_one_stands_is_refused() { +fn a_second_code_change_while_one_stands_is_refused() { let f = setup(); - let hash = BytesN::from_array(&f.e, &[7u8; 32]); - let other = BytesN::from_array(&f.e, &[9u8; 32]); - f.vault.propose_upgrade(&hash, &f.admin); + propose(&f, VaultChange::Upgrade(wasm(&f, 7))); refused( - f.vault.try_propose_upgrade(&other, &f.admin), + f.vault + .try_propose_change(&VaultChange::Upgrade(wasm(&f, 9)), &f.admin), VaultError::UpgradeProposalExists, ); refused( - f.vault.try_propose_upgrade_delay(&(30 * DAY), &f.admin), + f.vault + .try_propose_change(&VaultChange::UpgradeDelay(30 * DAY_DURATION), &f.admin), VaultError::UpgradeProposalExists, ); } @@ -41,14 +64,14 @@ fn a_second_proposal_while_one_stands_is_refused() { #[test] fn a_cancelled_proposal_leaves_the_slot_free() { let f = setup(); - let hash = BytesN::from_array(&f.e, &[7u8; 32]); - f.vault.propose_upgrade(&hash, &f.admin); + let hash = wasm(&f, 7); + let id = propose(&f, VaultChange::Upgrade(hash.clone())); - f.vault.cancel_upgrade(&f.admin); - assert_eq!(f.vault.upgrade_proposal(), None); + f.vault.cancel_change(&id, &f.admin); + assert_eq!(f.vault.pending_changes().len(), 0); - f.vault.propose_upgrade(&hash, &f.admin); - assert!(f.vault.upgrade_proposal().is_some()); + propose(&f, VaultChange::Upgrade(hash)); + assert_eq!(f.vault.pending_changes().len(), 1); } #[test] @@ -56,8 +79,8 @@ fn cancelling_nothing_is_refused() { let f = setup(); refused( - f.vault.try_cancel_upgrade(&f.admin), - VaultError::UpgradeProposalNotFound, + f.vault.try_cancel_change(&1, &f.admin), + VaultError::ChangeNotFound, ); } @@ -67,10 +90,10 @@ fn a_delay_below_the_minimum_is_refused() { refused( f.vault - .try_propose_upgrade_delay(&(MIN_UPGRADE_DELAY - 1), &f.admin), + .try_propose_change(&VaultChange::UpgradeDelay(MIN_UPGRADE_DELAY - 1), &f.admin), VaultError::UpgradeDelayTooShort, ); - assert_eq!(f.vault.upgrade_proposal(), None); + assert_eq!(f.vault.pending_changes().len(), 0); } #[test] @@ -79,51 +102,50 @@ fn a_delay_above_the_maximum_is_refused() { refused( f.vault - .try_propose_upgrade_delay(&(MAX_UPGRADE_DELAY + 1), &f.admin), + .try_propose_change(&VaultChange::UpgradeDelay(MAX_UPGRADE_DELAY + 1), &f.admin), VaultError::UpgradeDelayTooLong, ); - assert_eq!(f.vault.upgrade_proposal(), None); + assert_eq!(f.vault.pending_changes().len(), 0); } #[test] fn a_delay_below_the_standing_notice_is_refused() { let f = setup(); - f.vault.propose_upgrade_delay(&(30 * DAY), &f.admin); - f.advance(MIN_UPGRADE_DELAY); - f.vault.apply_upgrade(&f.admin); + raise_delay(&f, 30 * DAY_DURATION); - f.vault.set_notice(&(20 * DAY), &f.admin); + f.vault.set_notice(&(20 * DAY_DURATION), &f.admin); // Above the minimum, below the notice: an exit could not complete. refused( - f.vault.try_propose_upgrade_delay(&(10 * DAY), &f.admin), + f.vault + .try_propose_change(&VaultChange::UpgradeDelay(10 * DAY_DURATION), &f.admin), VaultError::UpgradeDelayBelowNotice, ); - f.vault.propose_upgrade_delay(&(21 * DAY), &f.admin); - assert!(f.vault.upgrade_proposal().is_some()); + propose(&f, VaultChange::UpgradeDelay(21 * DAY_DURATION)); + assert_eq!(f.vault.pending_changes().len(), 1); } #[test] fn only_governance_proposes_and_cancels() { let f = setup(); - let hash = BytesN::from_array(&f.e, &[7u8; 32]); + let change = VaultChange::Upgrade(wasm(&f, 7)); let stranger = Address::generate(&f.e); f.e.set_auths(&[]); - assert!(f.vault.try_propose_upgrade(&hash, &stranger).is_err()); - assert!(f.vault.try_propose_upgrade(&hash, &f.manager).is_err()); - assert!(f.vault.try_propose_upgrade(&hash, &f.guardian).is_err()); + assert!(f.vault.try_propose_change(&change, &stranger).is_err()); + assert!(f.vault.try_propose_change(&change, &f.manager).is_err()); + assert!(f.vault.try_propose_change(&change, &f.guardian).is_err()); assert!(f .vault - .try_propose_upgrade_delay(&(30 * DAY), &f.treasury) + .try_propose_change(&VaultChange::UpgradeDelay(30 * DAY_DURATION), &f.treasury) .is_err()); f.e.mock_all_auths(); - f.vault.propose_upgrade(&hash, &f.admin); + let id = propose(&f, change); f.e.set_auths(&[]); - assert!(f.vault.try_cancel_upgrade(&stranger).is_err()); - assert!(f.vault.try_cancel_upgrade(&f.guardian).is_err()); + assert!(f.vault.try_cancel_change(&id, &stranger).is_err()); + assert!(f.vault.try_cancel_change(&id, &f.guardian).is_err()); } #[test] @@ -136,69 +158,63 @@ fn the_schema_version_is_one_after_construction() { #[test] fn applying_before_the_eta_is_refused() { let f = setup(); - f.vault.propose_upgrade_delay(&(30 * DAY), &f.admin); + let id = propose(&f, VaultChange::UpgradeDelay(30 * DAY_DURATION)); refused( - f.vault.try_apply_upgrade(&f.admin), - VaultError::UpgradeDelayNotElapsed, + f.vault.try_apply_change(&id, &f.admin), + VaultError::ChangeNotReady, ); f.advance(MIN_UPGRADE_DELAY - 1); refused( - f.vault.try_apply_upgrade(&f.admin), - VaultError::UpgradeDelayNotElapsed, + f.vault.try_apply_change(&id, &f.admin), + VaultError::ChangeNotReady, ); f.advance(1); - f.vault.apply_upgrade(&f.admin); - assert_eq!(f.vault.upgrade_delay(), 30 * DAY); - assert_eq!(f.vault.upgrade_proposal(), None); + f.vault.apply_change(&id, &f.admin); + assert_eq!(f.vault.upgrade_delay(), 30 * DAY_DURATION); + assert_eq!(f.vault.pending_changes().len(), 0); } #[test] fn changing_the_delay_serves_the_old_delay_first() { let f = setup(); - - f.vault.propose_upgrade_delay(&(30 * DAY), &f.admin); - f.advance(MIN_UPGRADE_DELAY); - f.vault.apply_upgrade(&f.admin); + raise_delay(&f, 30 * DAY_DURATION); // The next proposal uses the new, longer delay. - let hash = BytesN::from_array(&f.e, &[7u8; 32]); - f.vault.propose_upgrade(&hash, &f.admin); + propose(&f, VaultChange::Upgrade(wasm(&f, 7))); assert_eq!( - f.vault.upgrade_proposal().unwrap().eta, - f.e.ledger().timestamp() + 30 * DAY + only_pending(&f).eta, + f.e.ledger().timestamp() + 30 * DAY_DURATION ); } #[test] fn shortening_the_delay_cannot_be_rushed() { let f = setup(); - f.vault.propose_upgrade_delay(&(30 * DAY), &f.admin); - f.advance(MIN_UPGRADE_DELAY); - f.vault.apply_upgrade(&f.admin); + raise_delay(&f, 30 * DAY_DURATION); // Back down to the minimum: still costs the full thirty days. - f.vault.propose_upgrade_delay(&MIN_UPGRADE_DELAY, &f.admin); - f.advance(30 * DAY - 1); + let id = propose(&f, VaultChange::UpgradeDelay(MIN_UPGRADE_DELAY)); + f.advance(30 * DAY_DURATION - 1); refused( - f.vault.try_apply_upgrade(&f.admin), - VaultError::UpgradeDelayNotElapsed, + f.vault.try_apply_change(&id, &f.admin), + VaultError::ChangeNotReady, ); f.advance(1); - f.vault.apply_upgrade(&f.admin); + f.vault.apply_change(&id, &f.admin); assert_eq!(f.vault.upgrade_delay(), MIN_UPGRADE_DELAY); } #[test] fn the_notice_cannot_outgrow_the_delay() { let f = setup(); - f.vault.propose_upgrade_delay(&(10 * DAY), &f.admin); + propose(&f, VaultChange::UpgradeDelay(10 * DAY_DURATION)); refused( - f.vault.try_set_notice(&(20 * DAY), &f.admin), + f.vault.try_set_notice(&(20 * DAY_DURATION), &f.admin), VaultError::NoticeAboveUpgradeDelay, ); assert_eq!(f.vault.notice(), 0); @@ -209,65 +225,58 @@ fn applying_nothing_is_refused() { let f = setup(); refused( - f.vault.try_apply_upgrade(&f.admin), - VaultError::UpgradeProposalNotFound, + f.vault.try_apply_change(&1, &f.admin), + VaultError::ChangeNotFound, ); } #[test] fn a_pause_freezes_the_upgrade_timelock_clock() { let f = setup(); + let id = propose(&f, VaultChange::UpgradeDelay(14 * DAY_DURATION)); + let original_eta = only_pending(&f).eta; - f.vault.propose_upgrade_delay(&(14 * DAY), &f.admin); - let original_eta = f.vault.upgrade_proposal().unwrap().eta; - - // Advance 3 days - f.advance(3 * DAY); - - // Pause the vault for 10 days + f.advance(3 * DAY_DURATION); f.vault.pause(&f.guardian); - f.advance(10 * DAY); + f.advance(10 * DAY_DURATION); - // While paused, applying is refused - assert!(f.vault.try_apply_upgrade(&f.admin).is_err()); + assert!(f.vault.try_apply_change(&id, &f.admin).is_err()); - // Unpause: proposal ETA is pushed by the 10-day paused duration + // Unpause: the eta has moved by the ten days spent paused. f.vault.unpause(&f.admin); - let updated_p = f.vault.upgrade_proposal().unwrap(); - assert_eq!(updated_p.eta, original_eta + 10 * DAY); + assert_eq!(only_pending(&f).eta, original_eta + 10 * DAY_DURATION); - // 3 days later (total 6 unpaused days out of 7), applying still refused - f.advance(3 * DAY); + // Six unpaused days out of seven: still refused. + f.advance(3 * DAY_DURATION); refused( - f.vault.try_apply_upgrade(&f.admin), - VaultError::UpgradeDelayNotElapsed, + f.vault.try_apply_change(&id, &f.admin), + VaultError::ChangeNotReady, ); - // 1 more day (7 unpaused days total): applying succeeds - f.advance(DAY); - f.vault.apply_upgrade(&f.admin); - assert_eq!(f.vault.upgrade_proposal(), None); - assert_eq!(f.vault.upgrade_delay(), 14 * DAY); + f.advance(DAY_DURATION); + f.vault.apply_change(&id, &f.admin); + assert_eq!(f.vault.pending_changes().len(), 0); + assert_eq!(f.vault.upgrade_delay(), 14 * DAY_DURATION); } #[test] fn only_governance_applies() { let f = setup(); let stranger = Address::generate(&f.e); - f.vault.propose_upgrade_delay(&(30 * DAY), &f.admin); + let id = propose(&f, VaultChange::UpgradeDelay(30 * DAY_DURATION)); f.advance(MIN_UPGRADE_DELAY); f.e.set_auths(&[]); - assert!(f.vault.try_apply_upgrade(&stranger).is_err()); - assert!(f.vault.try_apply_upgrade(&f.manager).is_err()); - assert!(f.vault.try_apply_upgrade(&f.guardian).is_err()); - assert!(f.vault.upgrade_proposal().is_some()); + assert!(f.vault.try_apply_change(&id, &stranger).is_err()); + assert!(f.vault.try_apply_change(&id, &f.manager).is_err()); + assert!(f.vault.try_apply_change(&id, &f.guardian).is_err()); + assert_eq!(f.vault.pending_changes().len(), 1); } #[test] fn the_vault_exposes_no_immediate_upgrade() { let f = setup(); - let hash = BytesN::from_array(&f.e, &[7u8; 32]); + let hash = wasm(&f, 7); // If the Upgradeable trait were implemented, this client would work // against the vault and replace the code with no delay at all. @@ -276,6 +285,110 @@ fn the_vault_exposes_no_immediate_upgrade() { assert!(client.try_upgrade(&hash, &f.admin).is_err()); } +#[test] +fn nothing_is_proposed_during_a_wind_down() { + let f = setup(); + f.vault.propose_wind_down(&f.admin); + f.vault.activate_wind_down(); + + refused( + f.vault + .try_propose_change(&VaultChange::Upgrade(wasm(&f, 7)), &f.admin), + VaultError::WindDownActive, + ); +} + +#[test] +fn a_change_standing_when_the_wind_down_starts_never_applies() { + let f = setup(); + let id = propose(&f, VaultChange::Upgrade(wasm(&f, 7))); + f.vault.propose_wind_down(&f.admin); + f.vault.activate_wind_down(); + f.advance(MAX_UPGRADE_DELAY); + + refused( + f.vault.try_apply_change(&id, &f.admin), + VaultError::WindDownActive, + ); + f.vault.cancel_change(&id, &f.admin); + assert_eq!(f.vault.pending_changes().len(), 0); +} + +#[test] +fn applying_a_delay_change_checks_the_notice_again() { + let f = setup(); + raise_delay(&f, 30 * DAY_DURATION); + let id = propose(&f, VaultChange::UpgradeDelay(MIN_UPGRADE_DELAY)); + + // The notice rises while the shorter delay waits. + f.vault.set_notice(&(20 * DAY_DURATION), &f.admin); + f.advance(30 * DAY_DURATION); + + refused( + f.vault.try_apply_change(&id, &f.admin), + VaultError::UpgradeDelayBelowNotice, + ); + assert_eq!(f.vault.upgrade_delay(), 30 * DAY_DURATION); +} + +#[test] +fn proposing_and_applying_publish_the_change() { + let f = setup(); + let change = VaultChange::UpgradeDelay(30 * DAY_DURATION); + let eta = f.e.ledger().timestamp() + MIN_UPGRADE_DELAY; + + let id = propose(&f, change.clone()); + assert_eq!( + f.e.events().all().filter_by_contract(&f.vault.address), + [ChangeProposed { + id, + change: change.clone(), + eta, + } + .to_xdr(&f.e, &f.vault.address)] + ); + + f.advance(MIN_UPGRADE_DELAY); + f.vault.apply_change(&id, &f.admin); + assert_eq!( + f.e.events().all().filter_by_contract(&f.vault.address), + [ChangeApplied { id, change }.to_xdr(&f.e, &f.vault.address)] + ); +} + +#[test] +fn cancelling_publishes_the_change_id() { + let f = setup(); + let id = propose(&f, VaultChange::Upgrade(wasm(&f, 7))); + + f.vault.cancel_change(&id, &f.admin); + + assert_eq!( + f.e.events().all().filter_by_contract(&f.vault.address), + [ChangeCancelled { id }.to_xdr(&f.e, &f.vault.address)] + ); +} + +#[test] +fn the_vault_reports_its_clock_to_the_token_and_the_oracle() { + let f = setup(); + f.vault.pause(&f.guardian); + f.advance(DAY_DURATION); + + let clock = timelock::ClockSourceClient::new(&f.e, &f.vault.address).timelock_clock(); + + assert_eq!( + clock, + timelock::Clock { + delay: MIN_UPGRADE_DELAY, + exit_window: 0, + paused_secs: DAY_DURATION, + paused: true, + winding_down: false, + } + ); +} + #[cfg(feature = "upgrade_wasm")] mod with_wasm { use super::*; @@ -298,15 +411,16 @@ mod with_wasm { } #[test] - fn applying_a_wasm_proposal_replaces_the_code() { + fn applying_a_wasm_change_replaces_the_code() { let f = setup(); let hash = f.e.deployer().upload_contract_wasm(WASM); - assert_eq!(f.vault.schema_version(), 1); - f.vault.propose_upgrade(&hash, &f.admin); + let id = f + .vault + .propose_change(&VaultChange::Upgrade(hash), &f.admin); f.advance(MIN_UPGRADE_DELAY); - f.vault.apply_upgrade(&f.admin); + f.vault.apply_change(&id, &f.admin); let target = Client::new(&f.e, &f.vault.address); assert_eq!(target.schema_version(), 1); @@ -319,9 +433,11 @@ mod with_wasm { fn a_migration_refuses_a_second_run() { let f = setup(); let hash = f.e.deployer().upload_contract_wasm(WASM); - f.vault.propose_upgrade(&hash, &f.admin); + let id = f + .vault + .propose_change(&VaultChange::Upgrade(hash), &f.admin); f.advance(MIN_UPGRADE_DELAY); - f.vault.apply_upgrade(&f.admin); + f.vault.apply_change(&id, &f.admin); let target = Client::new(&f.e, &f.vault.address); target.migrate(&f.admin); diff --git a/contracts/async-vault/src/upgrade.rs b/contracts/async-vault/src/upgrade.rs index aa4ae603..ff532e71 100644 --- a/contracts/async-vault/src/upgrade.rs +++ b/contracts/async-vault/src/upgrade.rs @@ -1,43 +1,26 @@ -use soroban_sdk::{contracttype, panic_with_error, BytesN, Env}; +use soroban_sdk::{panic_with_error, Env}; use crate::error::VaultError; -use crate::event::{ - UpgradeCancelled, UpgradeDelayProposed, UpgradeDelaySet, UpgradeProposed, Upgraded, -}; use crate::keys::DataKey; use crate::state; /// Seven days. Short enough to fix a defect in a week, long enough that an /// investor who dislikes the change has time to act on it. -pub const MIN_UPGRADE_DELAY: u64 = 7 * 24 * 60 * 60; +pub const MIN_UPGRADE_DELAY: u64 = timelock::MIN_DELAY; /// Ninety days. Prevents setting a delay that effectively brick-walls the vault /// against future upgrades. -pub const MAX_UPGRADE_DELAY: u64 = 90 * 24 * 60 * 60; - -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum UpgradeAction { - Wasm(BytesN<32>), - Delay(u64), -} - -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct UpgradeProposal { - pub action: UpgradeAction, - pub eta: u64, -} +pub const MAX_UPGRADE_DELAY: u64 = timelock::MAX_DELAY; pub(crate) fn delay(e: &Env) -> u64 { storage::get_instance(e, &DataKey::UpgradeDelay).unwrap_or(MIN_UPGRADE_DELAY) } -pub(crate) fn proposal(e: &Env) -> Option { - storage::get_instance(e, &DataKey::UpgradeProposal) +pub(crate) fn set_delay(e: &Env, secs: u64) { + storage::set_instance(e, &DataKey::UpgradeDelay, &secs); } /// An exit takes a full notice, so a delay shorter than one is not a window. -fn check_delay(e: &Env, secs: u64) { +pub(crate) fn check_delay(e: &Env, secs: u64) { if secs < MIN_UPGRADE_DELAY { panic_with_error!(e, VaultError::UpgradeDelayTooShort); } @@ -48,93 +31,3 @@ fn check_delay(e: &Env, secs: u64) { panic_with_error!(e, VaultError::UpgradeDelayBelowNotice); } } - -fn queue(e: &Env, action: UpgradeAction) -> u64 { - if proposal(e).is_some() { - panic_with_error!(e, VaultError::UpgradeProposalExists); - } - let eta = e.ledger().timestamp().saturating_add(delay(e)); - storage::set_instance( - e, - &DataKey::UpgradeProposal, - &UpgradeProposal { action, eta }, - ); - if stellar_contract_utils::pausable::paused(e) { - storage::set_instance(e, &DataKey::PausedAt, &e.ledger().timestamp()); - } - eta -} - -pub(crate) fn on_pause(e: &Env) { - let paused_at: Option = storage::get_instance(e, &DataKey::PausedAt); - if paused_at.is_none() { - storage::set_instance(e, &DataKey::PausedAt, &e.ledger().timestamp()); - } -} - -pub(crate) fn on_unpause(e: &Env) { - let paused_at: Option = storage::get_instance(e, &DataKey::PausedAt); - if let Some(paused_at) = paused_at { - let duration = e.ledger().timestamp().saturating_sub(paused_at); - if duration > 0 { - if let Some(mut p) = proposal(e) { - p.eta = p.eta.saturating_add(duration); - storage::set_instance(e, &DataKey::UpgradeProposal, &p); - } - } - e.storage().instance().remove(&DataKey::PausedAt); - } -} - -pub(crate) fn propose_wasm(e: &Env, wasm_hash: BytesN<32>) { - let eta = queue(e, UpgradeAction::Wasm(wasm_hash)); - UpgradeProposed { eta }.publish(e); -} - -pub(crate) fn propose_delay(e: &Env, secs: u64) { - check_delay(e, secs); - let eta = queue(e, UpgradeAction::Delay(secs)); - UpgradeDelayProposed { secs, eta }.publish(e); -} - -pub(crate) fn cancel(e: &Env) { - if proposal(e).is_none() { - panic_with_error!(e, VaultError::UpgradeProposalNotFound); - } - e.storage().instance().remove(&DataKey::UpgradeProposal); - UpgradeCancelled {}.publish(e); -} - -pub(crate) fn apply(e: &Env) { - if stellar_contract_utils::pausable::paused(e) { - panic_with_error!( - e, - stellar_contract_utils::pausable::PausableError::EnforcedPause - ); - } - - let p = - proposal(e).unwrap_or_else(|| panic_with_error!(e, VaultError::UpgradeProposalNotFound)); - - if e.ledger().timestamp() < p.eta { - panic_with_error!(e, VaultError::UpgradeDelayNotElapsed); - } - - match &p.action { - UpgradeAction::Delay(secs) => { - // The notice may have moved since the proposal; this check decides. - check_delay(e, *secs); - storage::set_instance(e, &DataKey::UpgradeDelay, secs); - e.storage().instance().remove(&DataKey::UpgradeProposal); - UpgradeDelaySet { secs: *secs }.publish(e); - } - UpgradeAction::Wasm(hash) => { - e.storage().instance().remove(&DataKey::UpgradeProposal); - stellar_contract_utils::upgradeable::upgrade(e, hash); - Upgraded { - schema_version: stellar_contract_utils::upgradeable::get_schema_version(e), - } - .publish(e); - } - } -} diff --git a/contracts/async-vault/src/wind_down.rs b/contracts/async-vault/src/wind_down.rs index 7a22e250..0b7d272f 100644 --- a/contracts/async-vault/src/wind_down.rs +++ b/contracts/async-vault/src/wind_down.rs @@ -13,7 +13,7 @@ use crate::treasury; /// Ninety days. Long enough for a real announcement period, short enough that /// governance cannot set a delay the vault would never outlive. -pub const MAX_WIND_DOWN_DELAY: u64 = 90 * 24 * 60 * 60; +pub const MAX_WIND_DOWN_DELAY: u64 = 90 * timelock::DAY_DURATION; #[contracttype] #[derive(Clone, Copy, Debug, Eq, PartialEq)] diff --git a/docs/AUTHORIZATION_MATRIX.md b/docs/AUTHORIZATION_MATRIX.md index 69b13459..7c11caee 100644 --- a/docs/AUTHORIZATION_MATRIX.md +++ b/docs/AUTHORIZATION_MATRIX.md @@ -86,10 +86,10 @@ Tests: [`controls.rs`](../contracts/async-vault/src/test/controls.rs), [`epochs. | `set_wind_down_delay` | `#[only_admin]`, `caller.require_auth()` | `secs <= MAX_WIND_DOWN_DELAY`, not active | `test::wind_down::only_governance_sets_the_delay` | | `propose_wind_down` | `#[only_admin]`, `caller.require_auth()` | No proposal standing, not active | `test::wind_down::only_governance_proposes` | | `cancel_wind_down_proposal` | `#[only_admin]`, `caller.require_auth()` | Must be proposed, not active | `test::wind_down::governance_cancels_a_proposal_before_activation` | -| `propose_upgrade` | `#[only_admin]`, `caller.require_auth()` | No proposal standing, delay >= notice | `test::upgrade::only_governance_proposes_and_cancels` | -| `propose_upgrade_delay` | `#[only_admin]`, `caller.require_auth()` | `secs in [MIN, MAX]`, `secs >= notice` | `test::upgrade::only_governance_proposes_and_cancels` | -| `cancel_upgrade` | `#[only_admin]`, `caller.require_auth()` | Proposal must stand | `test::upgrade::only_governance_proposes_and_cancels` | -| `apply_upgrade` | `#[only_admin]`, `caller.require_auth()` | Timelock expired, `when_not_paused` | `test::upgrade::only_governance_applies` | +| `propose_change` | `#[only_admin]`, `caller.require_auth()` | Not winding down, one code change at a time, an `UpgradeDelay` in `[MIN, MAX]` and `>= notice` | `test::upgrade::only_governance_proposes_and_cancels`, `test::upgrade::nothing_is_proposed_during_a_wind_down` | +| `cancel_change` | `#[only_admin]`, `caller.require_auth()` | The change must be pending | `test::upgrade::only_governance_proposes_and_cancels` | +| `apply_change` | `#[only_admin]`, `caller.require_auth()` | Eta passed, not paused, not winding down, delay `>= notice` | `test::upgrade::only_governance_applies` | +| `pending_changes` | Open view | None | None (view) | | `renounce_admin` | Refused always (`VaultError::AdminRequired`) | None | `test::controls::governance_cannot_renounce_itself_out_of_the_vault` | ### 5.2 ShareToken (`contracts/share-token`) diff --git a/docs/kit-spec.md b/docs/kit-spec.md index 345b408a..79f22b87 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -71,10 +71,13 @@ The contracts enforce a set of mathematical and state-machine invariants across ### Invariant 8: Bounded Governance Timelocks - **Definition**: Upgrades and wind-downs cannot execute without advance notice, and delays cannot be set to invalid boundaries. - **Testable Property**: - - Upgrade delay $\in [7 \text{ days}, 90 \text{ days}]$ and $\text{delay} \ge \text{notice}$. + - Upgrade delay $\in [7 \text{ days}, 90 \text{ days}]$ and $\text{delay} \ge \text{notice}$, checked when a delay change is proposed and again when it applies. - Wind-down delay $\le 90 \text{ days}$. - - Applying an upgrade before $\text{proposed\_time} + \text{delay}$ reverts with `UpgradeDelayNotElapsed`. - - Pausing freezes the upgrade timelock clock and pushes the ETA by the duration paused. + - Every change waits in the queue (`propose_change` → `apply_change`). Applying before its eta reverts with `ChangeNotReady`. + - A change whose delay is shorter than the notice standing at apply time reverts with `UpgradeDelayBelowNotice`. + - Time spent paused does not count. Applying while paused is refused. + - Nothing is proposed or applied during an active wind-down (`WindDownActive`). + - One code change (upgrade or upgrade delay) waits at a time (`UpgradeProposalExists`). - Renouncing admin is permanently refused (`AdminRequired`). ### Invariant 9: Wind-Down Pro-Rata Solvency @@ -137,7 +140,7 @@ The `async_vault` constructor strictly verifies pairwise separation across autho | **Custodian Address** | `set_custodian(custodian: Address)` | Governance | Recipient address for off-chain capital deployment. Required before `deploy_to_custodian`. | | **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_upgrade_delay(secs: u64)` | Governance | Timelock delay for wasm upgrades. Must be $\ge 7 \text{ days}$, $\le 90 \text{ days}$, and $\ge \text{notice}$. | +| **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. | --- @@ -185,10 +188,10 @@ Strata distributes control across five native Stellar multisig authorities and p | **`AsyncVault`** | `claim_wind_down` | Shareholder (Caller) | `caller.require_auth()` | No | | **`AsyncVault`** | `deploy_to_custodian` | Treasury | `#[only_role(caller, "treasury")]` | No (open during pause) | | **`AsyncVault`** | `fund` | Treasury | None (caller transfers funds) | No | -| **`AsyncVault`** | `propose_upgrade` | Governance | `#[only_admin]` | No | -| **`AsyncVault`** | `propose_upgrade_delay` | Governance | `#[only_admin]` | No | -| **`AsyncVault`** | `cancel_upgrade` | Governance | `#[only_admin]` | No | -| **`AsyncVault`** | `apply_upgrade` | Governance | `#[only_admin]` | Refused if paused | +| **`AsyncVault`** | `propose_change` | Governance | `#[only_admin]` | No | +| **`AsyncVault`** | `cancel_change` | Governance | `#[only_admin]` | No | +| **`AsyncVault`** | `apply_change` | Governance | `#[only_admin]` | Refused if paused or winding down | +| **`AsyncVault`** | `pending_changes` | Permissionless | None (view) | No | | **`AsyncVault`** | `transfer_admin_role` | Governance | `#[only_admin]` | No | | **`AsyncVault`** | `accept_admin_transfer` | Proposed Admin | `caller.require_auth()` | No | | **`NavOracle`** | `attest` | Attester | `#[only_role(caller, "attester")]` | Refused if ripcord active | diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index a1dcb650..c4291c69 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -582,7 +582,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 upward, and `min_answer` is what bounds it downward, so that floor is a risk parameter and not a sanity check; 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. | +| 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. | | 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 2649de5e5500a69a79dc0d1dea837f272df4ac2d Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:04:08 -0300 Subject: [PATCH 003/147] test(vault): pin the pause refusal on apply --- contracts/async-vault/src/test/upgrade.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/contracts/async-vault/src/test/upgrade.rs b/contracts/async-vault/src/test/upgrade.rs index 08ee3b3b..1ab20f74 100644 --- a/contracts/async-vault/src/test/upgrade.rs +++ b/contracts/async-vault/src/test/upgrade.rs @@ -1,4 +1,5 @@ use soroban_sdk::{testutils::Events as _, Event as _}; +use stellar_contract_utils::pausable::PausableError; use super::*; use crate::{ChangeApplied, ChangeCancelled, ChangeProposed}; @@ -259,6 +260,25 @@ fn a_pause_freezes_the_upgrade_timelock_clock() { assert_eq!(f.vault.upgrade_delay(), 14 * DAY_DURATION); } +#[test] +fn applying_while_paused_is_refused() { + let f = setup(); + let id = propose(&f, VaultChange::UpgradeDelay(30 * DAY_DURATION)); + f.advance(MIN_UPGRADE_DELAY); + + // At the eta, with no paused time yet, only the pause stands in the way. + f.vault.pause(&f.guardian); + assert_eq!( + f.vault.try_apply_change(&id, &f.admin), + Err(Ok(PausableError::EnforcedPause.into())) + ); + assert_eq!(f.vault.upgrade_delay(), MIN_UPGRADE_DELAY); + + f.vault.unpause(&f.admin); + f.vault.apply_change(&id, &f.admin); + assert_eq!(f.vault.upgrade_delay(), 30 * DAY_DURATION); +} + #[test] fn only_governance_applies() { let f = setup(); From b8f30ed109cf5b6dcfac151ed6572560bd151a82 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:19:02 -0300 Subject: [PATCH 004/147] fix(token): extend the instance on every call --- Cargo.lock | 1 + contracts/share-token/Cargo.toml | 1 + contracts/share-token/src/contract.rs | 37 +++++++++- contracts/share-token/src/test.rs | 99 ++++++++++++++++++++++++++- 4 files changed, 136 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 67427429..073575e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1712,6 +1712,7 @@ dependencies = [ "stellar-contract-utils", "stellar-macros", "stellar-tokens", + "storage", ] [[package]] diff --git a/contracts/share-token/Cargo.toml b/contracts/share-token/Cargo.toml index 3aecab2c..d06350fc 100644 --- a/contracts/share-token/Cargo.toml +++ b/contracts/share-token/Cargo.toml @@ -15,6 +15,7 @@ stellar-access = { workspace = true } stellar-macros = { workspace = true } stellar-tokens = { workspace = true } stellar-contract-utils = { workspace = true } +storage = { path = "../../crates/storage" } [dev-dependencies] soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/share-token/src/contract.rs b/contracts/share-token/src/contract.rs index e69d419a..7cd1c853 100644 --- a/contracts/share-token/src/contract.rs +++ b/contracts/share-token/src/contract.rs @@ -8,7 +8,7 @@ use stellar_access::access_control::{self as access_control, AccessControl}; use stellar_contract_utils::pausable::{self as pausable, Pausable}; use stellar_macros::{only_admin, only_any_role, only_role}; use stellar_tokens::{ - fungible::{Base, FungibleToken}, + fungible::{Base, ContractOverrides, FungibleToken}, rwa::{RWAToken, RWA}, }; @@ -36,6 +36,7 @@ impl ShareToken { ); RWA::set_compliance(e, &compliance); RWA::set_identity_verifier(e, &identity_verifier); + storage::bump_instance(e); } } @@ -44,18 +45,43 @@ impl Pausable for ShareToken { #[only_admin] fn pause(e: &Env, caller: Address) { caller.require_auth(); + storage::bump_instance(e); pausable::pause(e); } #[only_admin] fn unpause(e: &Env, caller: Address) { caller.require_auth(); + storage::bump_instance(e); pausable::unpause(e); } } +/// OpenZeppelin leaves the instance's lifetime to the token, so every call +/// that moves shares extends it before handing over to the library. #[contractimpl(contracttrait)] impl FungibleToken for ShareToken { type ContractType = RWA; + + fn transfer(e: &Env, from: Address, to: MuxedAddress, amount: i128) { + storage::bump_instance(e); + ::transfer(e, &from, &to, amount); + } + + fn transfer_from(e: &Env, spender: Address, from: Address, to: Address, amount: i128) { + storage::bump_instance(e); + ::transfer_from(e, &spender, &from, &to, amount); + } + + fn approve(e: &Env, owner: Address, spender: Address, amount: i128, live_until_ledger: u32) { + storage::bump_instance(e); + ::approve( + e, + &owner, + &spender, + amount, + live_until_ledger, + ); + } } #[contractimpl(contracttrait)] @@ -72,15 +98,18 @@ impl AccessControl for ShareToken { impl RWAToken for ShareToken { #[only_role(operator, "manager")] fn mint(e: &Env, to: Address, amount: i128, operator: Address) { + storage::bump_instance(e); RWA::mint(e, &to, amount); } #[only_role(operator, "manager")] fn burn(e: &Env, user_address: Address, amount: i128, operator: Address) { + storage::bump_instance(e); RWA::burn(e, &user_address, amount); } /// The vault escrows redemptions through it; compliance intervenes with it. #[only_any_role(operator, ["manager", "compliance"])] fn forced_transfer(e: &Env, from: Address, to: Address, amount: i128, operator: Address) { + storage::bump_instance(e); RWA::forced_transfer(e, &from, &to, amount); } #[only_role(operator, "compliance")] @@ -90,28 +119,34 @@ impl RWAToken for ShareToken { new_account: Address, operator: Address, ) -> bool { + storage::bump_instance(e); RWA::recover_balance(e, &old_account, &new_account) } #[only_role(operator, "compliance")] fn set_address_frozen(e: &Env, user_address: Address, freeze: bool, operator: Address) { + storage::bump_instance(e); RWA::set_address_frozen(e, &user_address, freeze); } #[only_role(operator, "compliance")] fn freeze_partial_tokens(e: &Env, user_address: Address, amount: i128, operator: Address) { + storage::bump_instance(e); RWA::freeze_partial_tokens(e, &user_address, amount); } #[only_role(operator, "compliance")] fn unfreeze_partial_tokens(e: &Env, user_address: Address, amount: i128, operator: Address) { + storage::bump_instance(e); RWA::unfreeze_partial_tokens(e, &user_address, amount); } #[only_admin] fn set_compliance(e: &Env, compliance: Address, operator: Address) { operator.require_auth(); + storage::bump_instance(e); RWA::set_compliance(e, &compliance); } #[only_admin] fn set_identity_verifier(e: &Env, identity_verifier: Address, operator: Address) { operator.require_auth(); + storage::bump_instance(e); RWA::set_identity_verifier(e, &identity_verifier); } } diff --git a/contracts/share-token/src/test.rs b/contracts/share-token/src/test.rs index 8c45c7a6..947798ef 100644 --- a/contracts/share-token/src/test.rs +++ b/contracts/share-token/src/test.rs @@ -1,10 +1,11 @@ use soroban_sdk::{ symbol_short, - testutils::{Address as _, MockAuth, MockAuthInvoke}, + testutils::{storage::Instance as _, Address as _, Ledger as _, MockAuth, MockAuthInvoke}, Address, Env, IntoVal, String, Symbol, }; use stellar_access::access_control::{self as access_control, AccessControlError}; use stellar_tokens::rwa::RWAError; +use storage::INSTANCE_EXTEND_AMOUNT; use crate::{ShareToken, ShareTokenClient}; use compliance::{Compliance, ComplianceClient}; @@ -413,3 +414,99 @@ fn the_constructor_grants_the_compliance_role_and_no_manager() { .has_role(&f.admin, &symbol_short!("manager")) .is_none()); } + +const TWO_DAYS_IN_LEDGERS: u32 = 2 * 17_280; + +fn instance_ttl(e: &Env, id: &Address) -> u32 { + e.as_contract(id, || e.storage().instance().get_ttl()) +} + +fn age(e: &Env, ledgers: u32) { + e.ledger().with_mut(|l| l.sequence_number += ledgers); +} + +/// A live token whose instance has decayed past the extend threshold, with +/// two allowlisted holders, the first holding 100 shares. +fn decayed_live_token(e: &Env) -> (Fixture<'_>, Address, Address) { + let (f, identity) = setup_live(e); + let holder = Address::generate(e); + let other = Address::generate(e); + identity.allow(&holder, &true, &f.admin); + identity.allow(&other, &true, &f.admin); + f.token.mint(&holder, &100, &f.manager); + + age(e, TWO_DAYS_IN_LEDGERS); + assert!(instance_ttl(e, &f.token.address) < INSTANCE_EXTEND_AMOUNT); + (f, holder, other) +} + +#[test] +fn construction_writes_the_instance_with_the_full_ttl() { + let e = Env::default(); + let f = setup_fixture(&e); + + assert_eq!(instance_ttl(&e, &f.token.address), INSTANCE_EXTEND_AMOUNT); +} + +#[test] +fn a_transfer_restores_the_instance_ttl() { + let e = Env::default(); + let (f, holder, other) = decayed_live_token(&e); + + f.token.transfer(&holder, &other, &10); + + assert_eq!(instance_ttl(&e, &f.token.address), INSTANCE_EXTEND_AMOUNT); +} + +#[test] +fn a_transfer_from_restores_the_instance_ttl() { + let e = Env::default(); + let (f, holder, other) = decayed_live_token(&e); + let live_until = e.ledger().sequence() + 3 * 17_280; + f.token.approve(&holder, &other, &10, &live_until); + age(&e, TWO_DAYS_IN_LEDGERS); + assert!(instance_ttl(&e, &f.token.address) < INSTANCE_EXTEND_AMOUNT); + + f.token.transfer_from(&other, &holder, &other, &10); + + assert_eq!(instance_ttl(&e, &f.token.address), INSTANCE_EXTEND_AMOUNT); +} + +#[test] +fn an_approval_restores_the_instance_ttl() { + let e = Env::default(); + let (f, holder, other) = decayed_live_token(&e); + + let live_until = e.ledger().sequence() + 100; + f.token.approve(&holder, &other, &10, &live_until); + + assert_eq!(instance_ttl(&e, &f.token.address), INSTANCE_EXTEND_AMOUNT); +} + +#[test] +fn the_vaults_mint_burn_and_escrow_restore_the_instance_ttl() { + let e = Env::default(); + let (f, holder, other) = decayed_live_token(&e); + + f.token.mint(&other, &5, &f.manager); + assert_eq!(instance_ttl(&e, &f.token.address), INSTANCE_EXTEND_AMOUNT); + + age(&e, TWO_DAYS_IN_LEDGERS); + f.token.forced_transfer(&holder, &other, &5, &f.manager); + assert_eq!(instance_ttl(&e, &f.token.address), INSTANCE_EXTEND_AMOUNT); + + age(&e, TWO_DAYS_IN_LEDGERS); + f.token.burn(&other, &5, &f.manager); + assert_eq!(instance_ttl(&e, &f.token.address), INSTANCE_EXTEND_AMOUNT); +} + +#[test] +fn compliance_interventions_restore_the_instance_ttl() { + let e = Env::default(); + let (f, holder, _) = decayed_live_token(&e); + + f.token + .set_address_frozen(&holder, &true, &f.compliance_authority); + + assert_eq!(instance_ttl(&e, &f.token.address), INSTANCE_EXTEND_AMOUNT); +} From 5c63d446e1314ba77dcc114eaf99fd1332b4700f Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:20:12 -0300 Subject: [PATCH 005/147] fix(verifier): extend the instance on every call --- contracts/identity-verifier/src/lib.rs | 6 ++++ contracts/identity-verifier/src/test.rs | 39 +++++++++++++++++++++++-- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/contracts/identity-verifier/src/lib.rs b/contracts/identity-verifier/src/lib.rs index 5c3ba014..4206c639 100644 --- a/contracts/identity-verifier/src/lib.rs +++ b/contracts/identity-verifier/src/lib.rs @@ -26,23 +26,29 @@ pub struct IdentityVerifier; impl IdentityVerifier { pub fn __constructor(e: &Env, admin: Address) { access_control::set_admin(e, &admin); + storage::bump_instance(e); } /// Adds or removes an account from the allowlist. #[only_admin] pub fn allow(e: &Env, account: Address, allowed: bool, caller: Address) { caller.require_auth(); + storage::bump_instance(e); storage::set_persistent(e, &DataKey::Allowed(account), &allowed); } pub fn is_allowed(e: &Env, account: Address) -> bool { + storage::bump_instance(e); storage::get_persistent(e, &DataKey::Allowed(account)).unwrap_or(false) } } #[contractimpl(contracttrait)] impl identity_verification::IdentityVerifier for IdentityVerifier { + /// The share token calls this on every mint and transfer, so it is what + /// keeps the instance alive. fn verify_identity(e: &Env, account: &Address) { + storage::bump_instance(e); let allowed: bool = storage::get_persistent(e, &DataKey::Allowed(account.clone())).unwrap_or(false); if !allowed { diff --git a/contracts/identity-verifier/src/test.rs b/contracts/identity-verifier/src/test.rs index de865bfe..221bf836 100644 --- a/contracts/identity-verifier/src/test.rs +++ b/contracts/identity-verifier/src/test.rs @@ -1,9 +1,9 @@ use super::*; use soroban_sdk::{ - testutils::{storage::Persistent as _, Address as _, Ledger as _}, + testutils::{storage::Instance as _, storage::Persistent as _, Address as _, Ledger as _}, Address, Env, }; -use storage::PERSISTENT_EXTEND_AMOUNT; +use storage::{INSTANCE_EXTEND_AMOUNT, PERSISTENT_EXTEND_AMOUNT}; const DAY_IN_LEDGERS: u32 = 17_280; use stellar_tokens::rwa::identity_verification::IdentityVerifierClient as IdClient; @@ -103,3 +103,38 @@ fn checking_an_allowed_account_restores_the_full_ttl() { IdClient::new(&e, &id).verify_identity(&investor); assert_eq!(allowed_ttl(&e, &id, &investor), PERSISTENT_EXTEND_AMOUNT); } + +fn instance_ttl(e: &Env, id: &Address) -> u32 { + e.as_contract(id, || e.storage().instance().get_ttl()) +} + +#[test] +fn construction_writes_the_instance_with_the_full_ttl() { + let e = Env::default(); + let id = e.register(IdentityVerifier, (Address::generate(&e),)); + + assert_eq!(instance_ttl(&e, &id), INSTANCE_EXTEND_AMOUNT); +} + +#[test] +fn allowing_and_checking_restore_the_instance_ttl() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let investor = Address::generate(&e); + let id = e.register(IdentityVerifier, (admin.clone(),)); + let client = IdentityVerifierClient::new(&e, &id); + + age(&e, 2 * DAY_IN_LEDGERS); + assert!(instance_ttl(&e, &id) < INSTANCE_EXTEND_AMOUNT); + client.allow(&investor, &true, &admin); + assert_eq!(instance_ttl(&e, &id), INSTANCE_EXTEND_AMOUNT); + + age(&e, 2 * DAY_IN_LEDGERS); + IdClient::new(&e, &id).verify_identity(&investor); + assert_eq!(instance_ttl(&e, &id), INSTANCE_EXTEND_AMOUNT); + + age(&e, 2 * DAY_IN_LEDGERS); + client.is_allowed(&investor); + assert_eq!(instance_ttl(&e, &id), INSTANCE_EXTEND_AMOUNT); +} From 170dd3f445f0445044ccbccb760579b0225dea70 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:22:20 -0300 Subject: [PATCH 006/147] fix(compliance): extend the instance on hooks --- Cargo.lock | 1 + contracts/compliance/Cargo.toml | 4 ++ contracts/compliance/src/lib.rs | 35 +++++++++++++++ contracts/compliance/src/test.rs | 73 ++++++++++++++++++++++++++++++++ 4 files changed, 113 insertions(+) create mode 100644 contracts/compliance/src/test.rs diff --git a/Cargo.lock b/Cargo.lock index 073575e3..21750d9a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -354,6 +354,7 @@ dependencies = [ "stellar-access", "stellar-macros", "stellar-tokens", + "storage", ] [[package]] diff --git a/contracts/compliance/Cargo.toml b/contracts/compliance/Cargo.toml index 203d3b86..dbc70e3f 100644 --- a/contracts/compliance/Cargo.toml +++ b/contracts/compliance/Cargo.toml @@ -14,3 +14,7 @@ soroban-sdk = { workspace = true } stellar-access = { workspace = true } stellar-macros = { workspace = true } stellar-tokens = { workspace = true } +storage = { path = "../../crates/storage" } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/compliance/src/lib.rs b/contracts/compliance/src/lib.rs index 5946ddf3..7f59fdad 100644 --- a/contracts/compliance/src/lib.rs +++ b/contracts/compliance/src/lib.rs @@ -19,6 +19,7 @@ pub struct Compliance; impl Compliance { pub fn __constructor(e: &Env, admin: Address) { access_control::set_admin(e, &admin); + storage::bump_instance(e); } } @@ -26,24 +27,58 @@ impl Compliance { impl token_binder::TokenBinder for Compliance { #[only_admin] fn bind_token(e: &Env, token: Address, _operator: Address) { + storage::bump_instance(e); token_binder::bind_token(e, &token); } #[only_admin] fn unbind_token(e: &Env, token: Address, _operator: Address) { + storage::bump_instance(e); token_binder::unbind_token(e, &token); } } +/// The token calls the check before and the hook after every mint, burn and +/// transfer, which is what keeps this instance alive. #[contractimpl(contracttrait)] impl compliance::Compliance for Compliance { #[only_admin] fn add_module_to(e: &Env, hook: ComplianceHook, module: Address, _operator: Address) { + storage::bump_instance(e); compliance::storage::add_module_to(e, hook, module); } #[only_admin] fn remove_module_from(e: &Env, hook: ComplianceHook, module: Address, _operator: Address) { + storage::bump_instance(e); compliance::storage::remove_module_from(e, hook, module); } + + fn transferred(e: &Env, from: Address, to: Address, amount: i128, token: Address) { + storage::bump_instance(e); + compliance::storage::transferred(e, from, to, amount, token); + } + + fn created(e: &Env, to: Address, amount: i128, token: Address) { + storage::bump_instance(e); + compliance::storage::created(e, to, amount, token); + } + + fn destroyed(e: &Env, from: Address, amount: i128, token: Address) { + storage::bump_instance(e); + compliance::storage::destroyed(e, from, amount, token); + } + + fn can_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) -> bool { + storage::bump_instance(e); + compliance::storage::can_transfer(e, from, to, amount, token) + } + + fn can_create(e: &Env, to: Address, amount: i128, token: Address) -> bool { + storage::bump_instance(e); + compliance::storage::can_create(e, to, amount, token) + } } + +#[cfg(test)] +mod test; diff --git a/contracts/compliance/src/test.rs b/contracts/compliance/src/test.rs new file mode 100644 index 00000000..8e479e6c --- /dev/null +++ b/contracts/compliance/src/test.rs @@ -0,0 +1,73 @@ +use soroban_sdk::{ + testutils::{storage::Instance as _, Address as _, Ledger as _}, + Address, Env, +}; +use storage::INSTANCE_EXTEND_AMOUNT; + +use crate::{Compliance, ComplianceClient}; + +const TWO_DAYS_IN_LEDGERS: u32 = 2 * 17_280; + +fn instance_ttl(e: &Env, id: &Address) -> u32 { + e.as_contract(id, || e.storage().instance().get_ttl()) +} + +fn age(e: &Env) { + e.ledger() + .with_mut(|l| l.sequence_number += TWO_DAYS_IN_LEDGERS); +} + +/// A compliance contract with one bound token, its instance decayed past the +/// extend threshold. +fn decayed(e: &Env) -> (ComplianceClient<'_>, Address) { + e.mock_all_auths(); + let admin = Address::generate(e); + let token = Address::generate(e); + let client = ComplianceClient::new(e, &e.register(Compliance, (admin.clone(),))); + client.bind_token(&token, &admin); + age(e); + assert!(instance_ttl(e, &client.address) < INSTANCE_EXTEND_AMOUNT); + (client, token) +} + +#[test] +fn construction_writes_the_instance_with_the_full_ttl() { + let e = Env::default(); + let id = e.register(Compliance, (Address::generate(&e),)); + + assert_eq!(instance_ttl(&e, &id), INSTANCE_EXTEND_AMOUNT); +} + +#[test] +fn the_checks_a_token_runs_before_moving_shares_restore_the_instance_ttl() { + let e = Env::default(); + let (client, token) = decayed(&e); + let holder = Address::generate(&e); + let other = Address::generate(&e); + + assert!(client.can_create(&holder, &1, &token)); + assert_eq!(instance_ttl(&e, &client.address), INSTANCE_EXTEND_AMOUNT); + + age(&e); + assert!(client.can_transfer(&holder, &other, &1, &token)); + assert_eq!(instance_ttl(&e, &client.address), INSTANCE_EXTEND_AMOUNT); +} + +#[test] +fn the_hooks_a_token_calls_after_moving_shares_restore_the_instance_ttl() { + let e = Env::default(); + let (client, token) = decayed(&e); + let holder = Address::generate(&e); + let other = Address::generate(&e); + + client.created(&holder, &1, &token); + assert_eq!(instance_ttl(&e, &client.address), INSTANCE_EXTEND_AMOUNT); + + age(&e); + client.transferred(&holder, &other, &1, &token); + assert_eq!(instance_ttl(&e, &client.address), INSTANCE_EXTEND_AMOUNT); + + age(&e); + client.destroyed(&other, &1, &token); + assert_eq!(instance_ttl(&e, &client.address), INSTANCE_EXTEND_AMOUNT); +} From c88ace472dd44b21c1131bc1c53c91313666e888 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:22:37 -0300 Subject: [PATCH 007/147] docs: list the instance ttl tests --- docs/TESTING.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/TESTING.md b/docs/TESTING.md index ca832e7b..c55d81e1 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -27,6 +27,7 @@ than a day short. The instance follows the same rule. | Persistent entries round-trip and get the full TTL on write | [`crates/storage/src/test.rs`](../crates/storage/src/test.rs) | `persistent_round_trips`, `writing_sets_the_full_ttl` | | A read restores a decayed entry; a missing entry is not created | [`crates/storage/src/test.rs`](../crates/storage/src/test.rs) | `reading_a_decayed_entry_restores_the_full_ttl`, `reading_a_missing_entry_creates_nothing` | | Instance writes and reads extend the contract instance | [`crates/storage/src/test.rs`](../crates/storage/src/test.rs) | `instance_writes_bump_the_instance`, `reading_the_instance_restores_its_ttl` | +| The share token, identity verifier and compliance contract extend their own instance on every call that does work | [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.rs), [`contracts/identity-verifier/src/test.rs`](../contracts/identity-verifier/src/test.rs), [`contracts/compliance/src/test.rs`](../contracts/compliance/src/test.rs) | `a_transfer_restores_the_instance_ttl`, `the_vaults_mint_burn_and_escrow_restore_the_instance_ttl`, `allowing_and_checking_restore_the_instance_ttl`, `the_hooks_a_token_calls_after_moving_shares_restore_the_instance_ttl` | | Vault requests and epochs are written with the full TTL | [`contracts/async-vault/src/test/ttl.rs`](../contracts/async-vault/src/test/ttl.rs) | `a_request_and_its_epoch_are_written_with_the_full_ttl`, `a_redemption_request_is_written_with_the_full_ttl` | | 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` | @@ -38,7 +39,9 @@ So these tests check the TTL numbers the contracts set, not what the network does once an entry is archived. The share token's balances and roles are stored by the OpenZeppelin Stellar -library, which manages their TTL itself. +library, which extends them when they are read or written. It never extends the +instance, so the token, the identity verifier and the compliance contract +extend their own on every call that does work. ## Authorization From 1f79ce3804208ba5ac660f49a8be1d1356847864 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:01:49 -0300 Subject: [PATCH 008/147] feat(harness): restore and extend a deployment --- scripts/harness/extend.ts | 95 ++++++++++++++++++++++++++++++++++ scripts/harness/lib/cli.ts | 7 ++- scripts/harness/lib/ttl.ts | 85 ++++++++++++++++++++++++++++++ scripts/harness/package.json | 1 + scripts/harness/tsconfig.json | 1 + scripts/lib/environments.d.mts | 13 +++++ 6 files changed, 200 insertions(+), 2 deletions(-) create mode 100644 scripts/harness/extend.ts create mode 100644 scripts/harness/lib/ttl.ts create mode 100644 scripts/lib/environments.d.mts diff --git a/scripts/harness/extend.ts b/scripts/harness/extend.ts new file mode 100644 index 00000000..67e03193 --- /dev/null +++ b/scripts/harness/extend.ts @@ -0,0 +1,95 @@ +import { readFileSync } from "node:fs" +import { join, resolve } from "node:path" + +import { + contractIds, + networkOf, + selectedEnvironment, +} from "../lib/environments.mjs" + +import { type Rpc } from "./lib/cli.js" +import { REPO_ROOT } from "./lib/deployment.js" +import { + EXTEND_LEDGERS, + extend, + inspect, + latestLedger, + restore, + type Entry, +} from "./lib/ttl.js" + +// Restores whatever has been archived, then extends every contract instance +// and the Wasm it runs, so an idle deployment stays callable. Extending needs +// no authority: any funded account pays. + +interface DeploymentRecord { + contracts: Record + asset?: { contract: string } +} + +function option(flag: string): string | undefined { + const at = process.argv.indexOf(flag) + return at === -1 ? undefined : process.argv[at + 1] +} + +function target(): { rpc: Rpc; contracts: Array<[string, string]> } { + const toml = readFileSync(join(REPO_ROOT, "environments.toml"), "utf8") + const environment = option("--env") ?? selectedEnvironment(process.env) + const { rpcUrl, networkPassphrase } = networkOf(toml, environment) + const rpc = { rpcUrl, passphrase: networkPassphrase } + + const config = option("--config") + if (config === undefined) { + const ids = contractIds(toml, environment) + return { rpc, contracts: ids.map(({ name, id }) => [name, id]) } + } + // npm runs workspace scripts from the workspace; resolve from where it was called. + const path = resolve(process.env["INIT_CWD"] ?? process.cwd(), config) + const record = JSON.parse(readFileSync(path, "utf8")) as DeploymentRecord + const contracts = Object.entries(record.contracts) + if (record.asset !== undefined) + contracts.push(["asset", record.asset.contract]) + return { rpc, contracts } +} + +async function keepAlive( + rpc: Rpc, + entry: Entry, + label: string, + latest: number, +): Promise { + const before = await inspect(rpc, entry) + if (before.liveUntil < latest) { + await restore(rpc, entry) + console.log(`${label} restored`) + } + const until = await extend(rpc, entry, EXTEND_LEDGERS) + console.log(`${label} live until ledger ${until}`) + return before.wasmHash +} + +async function main(): Promise { + if (!process.env["STELLAR_ACCOUNT"]) + throw new Error( + "set STELLAR_ACCOUNT to a funded account: a stellar CLI identity name or a secret key", + ) + const { rpc, contracts } = target() + const latest = await latestLedger(rpc) + console.log( + `extending ${contracts.length} contracts, latest ledger ${latest}`, + ) + + const code = new Set() + for (const [name, id] of contracts) { + const wasmHash = await keepAlive(rpc, { id }, `${name} instance`, latest) + if (wasmHash !== undefined) code.add(wasmHash) + } + for (const wasmHash of code) { + await keepAlive(rpc, { wasmHash }, `code ${wasmHash.slice(0, 8)}`, latest) + } +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 +}) diff --git a/scripts/harness/lib/cli.ts b/scripts/harness/lib/cli.ts index a9c3b019..95488632 100644 --- a/scripts/harness/lib/cli.ts +++ b/scripts/harness/lib/cli.ts @@ -36,14 +36,17 @@ async function exec( } } -async function stellar( +export async function stellar( args: readonly string[], options: Record = {}, ): Promise { return exec("stellar", args, options) } -function rpcArgs(net: Network): string[] { +/** The two settings every network command needs. */ +export type Rpc = Pick + +export function rpcArgs(net: Rpc): string[] { return ["--rpc-url", net.rpcUrl, "--network-passphrase", net.passphrase] } diff --git a/scripts/harness/lib/ttl.ts b/scripts/harness/lib/ttl.ts new file mode 100644 index 00000000..1986d735 --- /dev/null +++ b/scripts/harness/lib/ttl.ts @@ -0,0 +1,85 @@ +import { rpcArgs, stellar, type Rpc } from "./cli.js" + +/** Thirty days of five-second ledgers: the same length the contracts extend to on use. */ +export const EXTEND_LEDGERS = 30 * 17_280 + +export type Entry = { id: string } | { wasmHash: string } + +const entryArgs = (entry: Entry): string[] => + "id" in entry ? ["--id", entry.id] : ["--wasm-hash", entry.wasmHash] + +interface FetchedEntries { + entries: Array<{ + liveUntilLedgerSeq: number + val: { + contract_data?: { + val: { contract_instance: { executable: { wasm: string } | string } } + } + } + }> +} + +export async function latestLedger(rpc: Rpc): Promise { + const out = await stellar([ + "ledger", + "latest", + "--output", + "json", + ...rpcArgs(rpc), + ]) + return (JSON.parse(out) as { sequence: number }).sequence +} + +/** + * The ledger the entry lives until, zero once archived, and for an instance + * the hash of the Wasm it runs. A Stellar Asset Contract runs no Wasm. + */ +export async function inspect( + rpc: Rpc, + entry: Entry, +): Promise<{ liveUntil: number; wasmHash: string | undefined }> { + const args = + "id" in entry + ? ["contract-data", "--contract", entry.id, "--instance"] + : ["contract-code", "--wasm-hash", entry.wasmHash] + const out = await stellar([ + "ledger", + "entry", + "fetch", + ...args, + "--output", + "json", + ...rpcArgs(rpc), + ]) + const [found] = (JSON.parse(out) as FetchedEntries).entries + if (found === undefined) + throw new Error(`no ledger entry for ${JSON.stringify(entry)}`) + const executable = found.val.contract_data?.val.contract_instance.executable + return { + liveUntil: found.liveUntilLedgerSeq, + wasmHash: typeof executable === "object" ? executable.wasm : undefined, + } +} + +// The CLI pays with the account in STELLAR_ACCOUNT, inherited from the caller. +export async function restore(rpc: Rpc, entry: Entry): Promise { + await stellar(["contract", "restore", ...entryArgs(entry), ...rpcArgs(rpc)]) +} + +/** Returns the ledger the entry now lives until. */ +export async function extend( + rpc: Rpc, + entry: Entry, + ledgers: number, +): Promise { + const out = await stellar([ + "contract", + "extend", + ...entryArgs(entry), + "--ledgers-to-extend", + String(ledgers), + "--ttl-ledger-only", + ...rpcArgs(rpc), + ]) + return Number(out.split("\n").at(-1)) +} diff --git a/scripts/harness/package.json b/scripts/harness/package.json index be8dcf42..84ed1aa9 100644 --- a/scripts/harness/package.json +++ b/scripts/harness/package.json @@ -6,6 +6,7 @@ "description": "Deploys the vault set to a network and runs integration tests.", "scripts": { "deploy": "tsx deploy.ts", + "extend": "tsx extend.ts", "build:clients": "tsx build-clients.ts", "lint": "eslint .", "typecheck": "tsc --noEmit", diff --git a/scripts/harness/tsconfig.json b/scripts/harness/tsconfig.json index df950756..19d27e91 100644 --- a/scripts/harness/tsconfig.json +++ b/scripts/harness/tsconfig.json @@ -15,6 +15,7 @@ }, "include": [ "deploy.ts", + "extend.ts", "build-clients.ts", "seed.ts", "lib/**/*.ts", diff --git a/scripts/lib/environments.d.mts b/scripts/lib/environments.d.mts new file mode 100644 index 00000000..1cb0b418 --- /dev/null +++ b/scripts/lib/environments.d.mts @@ -0,0 +1,13 @@ +export function selectedEnvironment( + env: Record, +): string + +export function contractIds( + toml: string, + environment: string, +): Array<{ name: string; id: string }> + +export function networkOf( + toml: string, + environment: string, +): { rpcUrl: string; networkPassphrase: string; allowHttp: boolean } From 15554ed00d3bb80c10ecc35642f370b6ceea5e60 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:02:12 -0300 Subject: [PATCH 009/147] docs: write the ttl policy --- README.md | 15 ++++++++++++ docs/TESTING.md | 4 +++- docs/kit-spec.md | 62 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ee24fbdf..18bc4054 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,21 @@ npm run test:happy-path -w scripts/harness npm run test:guards -w scripts/harness ``` +### Keeping a deployment alive + +Every contract extends its own storage when it is called, but a deployment +nobody calls archives after 30 days. Restore and extend all of it with: + +```sh +STELLAR_ACCOUNT= npm run extend -w scripts/harness +``` + +It reads the testnet contracts from `environments.toml`, restores any +archived instance or Wasm, and extends each by 30 days. Any funded account +pays, since extending needs no authority. Use `--env ` for another +environment, or `--config ` for a deployment record. The +[TTL policy](./docs/kit-spec.md#5-storage-lifetime-ttl-policy) says what each +contract extends on its own. ## Reference base diff --git a/docs/TESTING.md b/docs/TESTING.md index c55d81e1..920807e5 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -20,7 +20,9 @@ build run only with `--features upgrade_wasm`, after the fixture in The contracts read and write through the shared helpers in [`crates/storage`](../crates/storage/src/lib.rs). A write extends the entry to 30 days; a read that finds the entry extends it again once it has fallen more -than a day short. The instance follows the same rule. +than a day short. The instance follows the same rule. The +[TTL policy](./kit-spec.md#5-storage-lifetime-ttl-policy) says what each +contract extends, who pays, and how to restore an archived one. | Behaviour | Tests | Key test names | | --- | --- | --- | diff --git a/docs/kit-spec.md b/docs/kit-spec.md index 79f22b87..e69b0bc6 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -225,3 +225,65 @@ A clean deployment executes in three sequenced phases: - Governance calls `set_notice(secs)` and `set_custodian(custodian_address)`. - Governance sets `deposit_cap` if initial capital intake is bounded. - Oracle reporter submits the genesis NAV attestation (`attest`). + +--- + +## 5. Storage Lifetime (TTL) Policy + +Soroban archives a ledger entry when its time to live (TTL) runs out. An +archived entry cannot be read until it is restored, so a contract whose +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 | Every `attest` and every read of the report | Same | +| `share_token` | Its instance | 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` | 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 | + +The lengths are the constants in `crates/storage` +(`PERSISTENT_EXTEND_AMOUNT`, `INSTANCE_EXTEND_AMOUNT`, and thresholds one day +shorter). They are fixed in code: a governance setting would add an admin power +for little gain. + +**Pending governance changes.** A change waiting in `async_vault`'s timelock +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. To keep a long wait from archiving, extend its entry +directly: `stellar contract extend --id --key-xdr --ledgers-to-extend `. `npm run extend` does not reach it, +since it only knows each contract's instance and code, not an individual +change's key. + +**What OpenZeppelin extends.** `stellar-tokens` 0.7.2 stores the share token's +balances, frozen amounts and roles, and the compliance contract's modules and +bound tokens, and extends each to 30 days when it is read or written. +Allowances are temporary entries that live until their `live_until_ledger`. +OpenZeppelin never extends an instance; the kit does, as above. The token's +total supply and metadata live in its instance. + +**Who pays.** The account that sends a transaction pays the rent for every +extension that transaction triggers. One call extends each entry it touches to +at most 30 days, so no caller pays for more than 30 days of any entry. + +**Idle deployments.** A deployment nobody calls for 30 days archives. Run +`STELLAR_ACCOUNT= npm run extend -w scripts/harness` after +every deploy and before a long quiet period. It restores anything archived +and extends every instance and its Wasm by 30 days. It reads the contracts +in `environments.toml [staging.contracts]`; `--env ` picks another +environment, and `--config ` reads a deployment record instead. +Extending needs no authority, so any funded account can pay. + +**Restoring.** Since Protocol 23 an invocation can restore archived entries +itself, so a call to an archived contract usually goes through once simulation +adds the restore. Restoring many entries in one transaction can still hit +per-transaction limits; `stellar contract restore --id ` restores +one instance at a time. + +**Production modules.** The compliance and identity contracts in this +repository are testnet stubs. A production compliance module or identity +registry must extend its own instance and entries the same way, or the share +token stops working when they archive. From c7d03c6487b755c86ea08ba19432765dde495be0 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:37:08 -0300 Subject: [PATCH 010/147] fix(harness): extend open pending changes --- scripts/harness/extend.ts | 47 +++++++++++++++++++++++++++++++++++++- scripts/harness/lib/ttl.ts | 28 +++++++++++++++++------ 2 files changed, 67 insertions(+), 8 deletions(-) diff --git a/scripts/harness/extend.ts b/scripts/harness/extend.ts index 67e03193..b8e27b73 100644 --- a/scripts/harness/extend.ts +++ b/scripts/harness/extend.ts @@ -1,13 +1,15 @@ import { readFileSync } from "node:fs" import { join, resolve } from "node:path" +import { xdr } from "@stellar/stellar-sdk" + import { contractIds, networkOf, selectedEnvironment, } from "../lib/environments.mjs" -import { type Rpc } from "./lib/cli.js" +import { rpcArgs, stellar, type Rpc } from "./lib/cli.js" import { REPO_ROOT } from "./lib/deployment.js" import { EXTEND_LEDGERS, @@ -27,6 +29,38 @@ interface DeploymentRecord { asset?: { contract: string } } +// Contracts whose storage holds a timelock change queue, by the name they +// deploy under. Only the vault keeps one today. +const CONTRACTS_WITH_QUEUES = new Set(["async_vault"]) + +/** + * The timelock's own storage key for a change still waiting: + * `Key::TimelockPending(id)`, a contracttype variant whose ScVal is a vec of + * its symbol and the id (`crates/timelock/src/keys.rs`, `queue.rs`). + */ +function pendingChangeKey(id: number): string { + return xdr.ScVal.scvVec([ + xdr.ScVal.scvSymbol("TimelockPending"), + xdr.ScVal.scvU32(id), + ]).toXDR("base64") +} + +/** The ids still waiting in a contract's queue, read through its `pending_changes` view. */ +async function openChangeIds(rpc: Rpc, contractId: string): Promise { + const out = await stellar([ + "contract", + "invoke", + "--id", + contractId, + "--send", + "no", + ...rpcArgs(rpc), + "--", + "pending_changes", + ]) + return (JSON.parse(out) as Array<{ id: number }>).map(({ id }) => id) +} + function option(flag: string): string | undefined { const at = process.argv.indexOf(flag) return at === -1 ? undefined : process.argv[at + 1] @@ -84,6 +118,17 @@ async function main(): Promise { const wasmHash = await keepAlive(rpc, { id }, `${name} instance`, latest) if (wasmHash !== undefined) code.add(wasmHash) } + for (const [name, id] of contracts) { + if (!CONTRACTS_WITH_QUEUES.has(name)) continue + for (const changeId of await openChangeIds(rpc, id)) { + await keepAlive( + rpc, + { id, keyXdr: pendingChangeKey(changeId) }, + `${name} pending change ${changeId}`, + latest, + ) + } + } for (const wasmHash of code) { await keepAlive(rpc, { wasmHash }, `code ${wasmHash.slice(0, 8)}`, latest) } diff --git a/scripts/harness/lib/ttl.ts b/scripts/harness/lib/ttl.ts index 1986d735..d60101b6 100644 --- a/scripts/harness/lib/ttl.ts +++ b/scripts/harness/lib/ttl.ts @@ -3,17 +3,29 @@ import { rpcArgs, stellar, type Rpc } from "./cli.js" /** Thirty days of five-second ledgers: the same length the contracts extend to on use. */ export const EXTEND_LEDGERS = 30 * 17_280 -export type Entry = { id: string } | { wasmHash: string } +export type Entry = { id: string } | { wasmHash: string } | PendingEntry + +/** A specific persistent entry in a contract's own storage, by its key. */ +export interface PendingEntry { + id: string + keyXdr: string +} const entryArgs = (entry: Entry): string[] => - "id" in entry ? ["--id", entry.id] : ["--wasm-hash", entry.wasmHash] + "keyXdr" in entry + ? ["--id", entry.id, "--key-xdr", entry.keyXdr] + : "id" in entry + ? ["--id", entry.id] + : ["--wasm-hash", entry.wasmHash] interface FetchedEntries { entries: Array<{ liveUntilLedgerSeq: number val: { contract_data?: { - val: { contract_instance: { executable: { wasm: string } | string } } + val: { + contract_instance?: { executable: { wasm: string } | string } + } } } }> @@ -39,9 +51,11 @@ export async function inspect( entry: Entry, ): Promise<{ liveUntil: number; wasmHash: string | undefined }> { const args = - "id" in entry - ? ["contract-data", "--contract", entry.id, "--instance"] - : ["contract-code", "--wasm-hash", entry.wasmHash] + "keyXdr" in entry + ? ["contract-data", "--contract", entry.id, "--key-xdr", entry.keyXdr] + : "id" in entry + ? ["contract-data", "--contract", entry.id, "--instance"] + : ["contract-code", "--wasm-hash", entry.wasmHash] const out = await stellar([ "ledger", "entry", @@ -54,7 +68,7 @@ export async function inspect( const [found] = (JSON.parse(out) as FetchedEntries).entries if (found === undefined) throw new Error(`no ledger entry for ${JSON.stringify(entry)}`) - const executable = found.val.contract_data?.val.contract_instance.executable + const executable = found.val.contract_data?.val.contract_instance?.executable return { liveUntil: found.liveUntilLedgerSeq, wasmHash: typeof executable === "object" ? executable.wasm : undefined, From 310399ed3a164dec291ccbd0f780b0e8be457ecf Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:37:24 -0300 Subject: [PATCH 011/147] docs: fix ttl policy numbers and routing --- docs/kit-spec.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/kit-spec.md b/docs/kit-spec.md index e69b0bc6..1180e993 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -237,7 +237,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 | Every `attest` and every read of the report | Same | +| `nav_oracle` | Its instance and the latest report | `attest` extends both; every other call extends whichever of the two it reads or writes | Same | | `share_token` | Its instance | 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` | 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 | @@ -252,15 +252,15 @@ 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. To keep a long wait from archiving, extend its entry -directly: `stellar contract extend --id --key-xdr --ledgers-to-extend `. `npm run extend` does not reach it, -since it only knows each contract's instance and code, not an individual -change's key. +any other archived entry. `npm run extend` also keeps it alive: it reads the +vault's open ids from its `pending_changes()` view, builds each one's storage +key the way the timelock crate does, and extends it the same 30 days as every +instance and Wasm. **What OpenZeppelin extends.** `stellar-tokens` 0.7.2 stores the share token's -balances, frozen amounts and roles, and the compliance contract's modules and -bound tokens, and extends each to 30 days when it is read or written. +balances and frozen amounts, and the compliance contract's modules and bound +tokens, and extends each to 30 days when it is read or written. `stellar-access` +0.7.2 stores every contract's roles and extends them to 90 days the same way. Allowances are temporary entries that live until their `live_until_ledger`. OpenZeppelin never extends an instance; the kit does, as above. The token's total supply and metadata live in its instance. From 65c37d589afcdc4a20bc6758743e040bbf0d5e25 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:13:06 -0300 Subject: [PATCH 012/147] refactor(oracle): rename ripcord to nav pause --- contracts/async-vault/src/test/cancel.rs | 4 +-- .../async-vault/src/test/oracle_pricing.rs | 8 ++--- contracts/nav-oracle/src/lib.rs | 32 ++++++++--------- contracts/nav-oracle/src/state.rs | 20 +++++------ contracts/nav-oracle/src/test.rs | 36 +++++++++---------- docs/AUTHORIZATION_MATRIX.md | 10 +++--- docs/TESTING.md | 2 +- docs/kit-spec.md | 14 ++++---- docs/strata-product-and-architecture.md | 10 +++--- scripts/harness/guards.test.ts | 22 +++++------- 10 files changed, 77 insertions(+), 81 deletions(-) diff --git a/contracts/async-vault/src/test/cancel.rs b/contracts/async-vault/src/test/cancel.rs index 8cf93ca2..8bd64a91 100644 --- a/contracts/async-vault/src/test/cancel.rs +++ b/contracts/async-vault/src/test/cancel.rs @@ -282,13 +282,13 @@ fn a_sealed_epoch_cancels_once_the_feed_goes_stale() { } #[test] -fn a_sealed_epoch_cancels_while_the_ripcord_is_raised() { +fn a_sealed_epoch_cancels_while_the_nav_is_paused() { let f = setup(); let holder = f.holder(200); let epoch = f.vault.request_redeem(&holder, &200); f.close_epoch(); - f.oracle.raise_ripcord(&f.guardian); + f.oracle.pause_nav(&f.guardian); assert_eq!(f.vault.cancel_redeem(&holder, &epoch), 200); assert_eq!(f.shares(&holder), 200); diff --git a/contracts/async-vault/src/test/oracle_pricing.rs b/contracts/async-vault/src/test/oracle_pricing.rs index 2e4a9aae..493f278c 100644 --- a/contracts/async-vault/src/test/oracle_pricing.rs +++ b/contracts/async-vault/src/test/oracle_pricing.rs @@ -10,15 +10,15 @@ fn fulfill_is_rejected_before_the_first_attestation() { } #[test] -fn fulfill_is_rejected_while_the_ripcord_is_raised() { +fn fulfill_is_rejected_while_the_nav_is_paused() { let f = setup(); f.close_epoch(); f.attest(wad(2)); - f.oracle.set_ripcord(&true, &f.admin); + f.oracle.set_nav_paused(&true, &f.admin); refused(f.vault.try_fulfill_epoch(&1), VaultError::FeedNotValid); - f.oracle.set_ripcord(&false, &f.admin); + f.oracle.set_nav_paused(&false, &f.admin); assert_eq!(f.vault.fulfill_epoch(&1), wad(2)); } @@ -56,7 +56,7 @@ fn a_paused_feed_is_refused_by_the_vault_not_the_oracle() { f.vault.request_redeem(&holder, &100); let epoch = f.close_epoch(); - f.oracle.raise_ripcord(&f.guardian); + f.oracle.pause_nav(&f.guardian); refused(f.vault.try_fulfill_epoch(&epoch), VaultError::FeedNotValid); } diff --git a/contracts/nav-oracle/src/lib.rs b/contracts/nav-oracle/src/lib.rs index 60b34847..d2016232 100644 --- a/contracts/nav-oracle/src/lib.rs +++ b/contracts/nav-oracle/src/lib.rs @@ -9,7 +9,7 @@ use soroban_sdk::{ use stellar_access::access_control::{self, AccessControl}; use stellar_macros::{only_admin, only_role}; -use state::{get_config, get_latest, ripcord_raised, ATTESTER_ROLE, GUARDIAN_ROLE}; +use state::{get_config, get_latest, nav_paused, ATTESTER_ROLE, GUARDIAN_ROLE}; pub use state::{NavReport, OracleConfig, OracleState}; @@ -32,8 +32,8 @@ pub enum OracleError { NoRecord = 3006, /// `ensure_consumable` found the feed in a state other than `Valid`. NotConsumable = 3007, - /// `clear_latest` was called while the ripcord was down. - RipcordNotRaised = 3008, + /// `clear_latest` was called while the NAV was not paused. + NavNotPaused = 3008, /// The oracle must always have an admin. AdminRequired = 3009, } @@ -50,7 +50,7 @@ pub struct NavAttested { pub struct LatestCleared {} #[contractevent] -pub struct RipcordSet { +pub struct NavPausedSet { pub paused: bool, } @@ -86,7 +86,7 @@ fn trustworthy(e: &Env, report: &NavReport) -> bool { } fn compute_state(e: &Env) -> OracleState { - if ripcord_raised(e) { + if nav_paused(e) { return OracleState::Paused; } match get_latest(e) { @@ -102,7 +102,7 @@ pub struct NavOracleContract; #[contractimpl(contracttrait)] impl AccessControl for NavOracleContract { /// Refused. An oracle with no admin can never be reconfigured, never clear a - /// stuck record and never lower the ripcord again. + /// stuck record and never unpause the NAV again. fn renounce_admin(e: &Env) { panic_with_error!(e, OracleError::AdminRequired); } @@ -123,7 +123,7 @@ impl NavOracleContract { access_control::grant_role_no_auth(e, &guardian, &GUARDIAN_ROLE, &admin); state::set_config(e, &config); - state::set_ripcord(e, false); + state::set_nav_paused(e, false); } pub fn nav_per_share(e: &Env) -> i128 { @@ -208,26 +208,26 @@ impl NavOracleContract { } #[only_role(caller, "guardian")] - pub fn raise_ripcord(e: &Env, caller: Address) { - state::set_ripcord(e, true); - RipcordSet { paused: true }.publish(e); + pub fn pause_nav(e: &Env, caller: Address) { + state::set_nav_paused(e, true); + NavPausedSet { paused: true }.publish(e); } #[only_admin] - pub fn set_ripcord(e: &Env, paused: bool, caller: Address) { + pub fn set_nav_paused(e: &Env, paused: bool, caller: Address) { caller.require_auth(); - state::set_ripcord(e, paused); - RipcordSet { paused }.publish(e); + state::set_nav_paused(e, paused); + NavPausedSet { paused }.publish(e); } /// Removes the stored record, so one in-band value can land again whatever - /// its distance from the last. Only while the ripcord is raised, so + /// its distance from the last. Only while the NAV is paused, so /// resuming is always a deliberate second act. #[only_admin] pub fn clear_latest(e: &Env, caller: Address) { caller.require_auth(); - if !ripcord_raised(e) { - panic_with_error!(e, OracleError::RipcordNotRaised); + if !nav_paused(e) { + panic_with_error!(e, OracleError::NavNotPaused); } state::clear_latest(e); LatestCleared {}.publish(e); diff --git a/contracts/nav-oracle/src/state.rs b/contracts/nav-oracle/src/state.rs index 40e3a719..051b55f5 100644 --- a/contracts/nav-oracle/src/state.rs +++ b/contracts/nav-oracle/src/state.rs @@ -2,17 +2,17 @@ use soroban_sdk::{contracttype, symbol_short, Env, Symbol}; /// The three-state feed health // -// - `Valid`: ripcord=0 AND now ≤ expires_at +// - `Valid`: the NAV is not paused AND now ≤ expires_at // AND min_answer ≤ nav_per_share ≤ max_answer. Mint AND redeem. -// - `Stale`: ripcord=0 but past heartbeat/expires_at (or no record yet). +// - `Stale`: the NAV is not paused but past heartbeat/expires_at (or no record yet). // Value untrusted; BOTH mint and redeem halt. -// - `Paused`: ripcord=1 (issuer freeze). Value ignored entirely, both -// directions, until the issuer clears ripcord to 0. +// - `Paused`: the NAV is paused (issuer freeze). Value ignored entirely, both +// directions, until the issuer clears the NAV pause. pub use bindings::OracleState; pub const ATTESTER_ROLE: Symbol = symbol_short!("attester"); -/// Raises the ripcord in an emergency. Cannot lower it again. +/// Pauses the NAV in an emergency. Cannot unpause it. pub const GUARDIAN_ROLE: Symbol = symbol_short!("guardian"); #[contracttype] @@ -43,7 +43,7 @@ pub struct OracleConfig { pub(crate) enum DataKey { Config, Latest, - Ripcord, + NavPaused, } pub(crate) fn set_config(e: &Env, config: &OracleConfig) { @@ -54,12 +54,12 @@ pub(crate) fn get_config(e: &Env) -> OracleConfig { storage::get_instance(e, &DataKey::Config).unwrap() } -pub(crate) fn set_ripcord(e: &Env, paused: bool) { - storage::set_instance(e, &DataKey::Ripcord, &paused); +pub(crate) fn set_nav_paused(e: &Env, paused: bool) { + storage::set_instance(e, &DataKey::NavPaused, &paused); } -pub(crate) fn ripcord_raised(e: &Env) -> bool { - storage::get_instance(e, &DataKey::Ripcord).unwrap_or(false) +pub(crate) fn nav_paused(e: &Env) -> bool { + storage::get_instance(e, &DataKey::NavPaused).unwrap_or(false) } pub(crate) fn set_latest(e: &Env, report: &NavReport) { diff --git a/contracts/nav-oracle/src/test.rs b/contracts/nav-oracle/src/test.rs index d6244642..f82fafec 100644 --- a/contracts/nav-oracle/src/test.rs +++ b/contracts/nav-oracle/src/test.rs @@ -92,17 +92,17 @@ fn heartbeat_expiry_makes_it_stale() { } #[test] -fn ripcord_pauses_regardless_of_freshness() { +fn the_nav_pause_holds_regardless_of_freshness() { let f = setup(); let r = report(&f.e, SCALE, 1, 1_000_000); f.oracle.attest(&r, &f.attester); assert_eq!(f.oracle.state(), OracleState::Valid); - f.oracle.set_ripcord(&true, &f.admin); + f.oracle.set_nav_paused(&true, &f.admin); assert_eq!(f.oracle.state(), OracleState::Paused); assert!(f.oracle.try_ensure_consumable().is_err()); - f.oracle.set_ripcord(&false, &f.admin); + f.oracle.set_nav_paused(&false, &f.admin); assert_eq!(f.oracle.state(), OracleState::Valid); } @@ -306,33 +306,33 @@ fn only_admin_can_set_config() { } #[test] -fn the_guardian_raises_the_ripcord_but_lowering_needs_governance() { +fn the_guardian_pauses_the_nav_but_unpausing_needs_governance() { let f = setup(); let r = report(&f.e, SCALE, 1, 1_000_000); f.oracle.attest(&r, &f.attester); assert_eq!(f.oracle.state(), OracleState::Valid); - f.oracle.raise_ripcord(&f.guardian); + f.oracle.pause_nav(&f.guardian); assert_eq!(f.oracle.state(), OracleState::Paused); f.e.set_auths(&[]); - assert!(f.oracle.try_set_ripcord(&false, &f.guardian).is_err()); - assert!(f.oracle.try_set_ripcord(&false, &f.admin).is_err()); + assert!(f.oracle.try_set_nav_paused(&false, &f.guardian).is_err()); + assert!(f.oracle.try_set_nav_paused(&false, &f.admin).is_err()); assert_eq!(f.oracle.state(), OracleState::Paused); f.e.mock_all_auths(); - f.oracle.set_ripcord(&false, &f.admin); + f.oracle.set_nav_paused(&false, &f.admin); assert_eq!(f.oracle.state(), OracleState::Valid); } #[test] -fn raising_the_ripcord_is_limited_to_the_guardian() { +fn pausing_the_nav_is_limited_to_the_guardian() { let f = setup(); let stranger = Address::generate(&f.e); - assert!(f.oracle.try_raise_ripcord(&stranger).is_err()); - assert!(f.oracle.try_raise_ripcord(&f.attester).is_err()); - assert!(f.oracle.try_raise_ripcord(&f.admin).is_err()); + assert!(f.oracle.try_pause_nav(&stranger).is_err()); + assert!(f.oracle.try_pause_nav(&f.attester).is_err()); + assert!(f.oracle.try_pause_nav(&f.admin).is_err()); assert_eq!(f.oracle.state(), OracleState::Stale); } @@ -417,18 +417,18 @@ fn constructor_rejects_a_zero_upside_cap() { } #[test] -fn the_record_clears_only_while_the_ripcord_is_raised() { +fn the_record_clears_only_while_the_nav_is_paused() { let f = setup(); f.oracle .attest(&report(&f.e, SCALE, 1, 1_000_000), &f.attester); assert!(f.oracle.try_clear_latest(&f.admin).is_err()); - f.oracle.raise_ripcord(&f.guardian); + f.oracle.pause_nav(&f.guardian); f.oracle.clear_latest(&f.admin); // With no stored record, one in-band value lands however far it sits. - f.oracle.set_ripcord(&false, &f.admin); + f.oracle.set_nav_paused(&false, &f.admin); f.e.ledger().set_timestamp(10_100); let far = report(&f.e, SCALE * 50, 2, 1_000_000); f.oracle.attest(&far, &f.attester); @@ -441,7 +441,7 @@ fn only_admin_can_clear_latest() { let stranger = Address::generate(&f.e); let r = report(&f.e, SCALE, 1, 1_000_000); f.oracle.attest(&r, &f.attester); - f.oracle.raise_ripcord(&f.guardian); + f.oracle.pause_nav(&f.guardian); f.e.set_auths(&[]); assert!(f.oracle.try_clear_latest(&stranger).is_err()); @@ -465,8 +465,8 @@ fn every_oracle_authority_is_readable_and_rotatable() { f.oracle .revoke_role(&f.guardian, &symbol_short!("guardian"), &f.admin); - assert!(f.oracle.try_raise_ripcord(&f.guardian).is_err()); - f.oracle.raise_ripcord(&next); + assert!(f.oracle.try_pause_nav(&f.guardian).is_err()); + f.oracle.pause_nav(&next); } #[test] diff --git a/docs/AUTHORIZATION_MATRIX.md b/docs/AUTHORIZATION_MATRIX.md index 7c11caee..f50ee015 100644 --- a/docs/AUTHORIZATION_MATRIX.md +++ b/docs/AUTHORIZATION_MATRIX.md @@ -10,10 +10,10 @@ This document provides the complete authorization and role model specification f 2. **Strict Caller Authentication**: No entrypoint takes a caller argument that it does not authenticate. Every function accepting a `caller: Address` executes `caller.require_auth()`. 3. **Exit-Only Guarantee**: A covered redemption claim always pays out cash, regardless of pause, staleness, or allowlist status. Delisted or frozen investors exit through the cash path and are prevented from re-entering share circulation. 4. **Five Separate Authorities**: - - **Governance (Admin)**: Root authority; upgrades, timelock parameters, custodian settings, unpause, oracle parameters, emergency ripcord reset. + - **Governance (Admin)**: Root authority; upgrades, timelock parameters, custodian settings, unpause, oracle parameters, emergency NAV pause reset. - **Manager (Vault)**: Closes epochs. - **Treasury**: Deploys free reserve assets to custodian. - - **Guardian**: Emergency responder; pauses vault entries, raises oracle ripcord. + - **Guardian**: Emergency responder; pauses vault entries, pauses the oracle's NAV. - **Attester (Oracle)**: Submits off-chain valuation reports within strict deviation and rate bounds. - **Compliance**: Writes the allowlist on the identity verifier and holds the share token's `compliance` role for freezes, forced transfers and recovery. Cannot mint, burn or swap the token's compliance or identity-verifier contract. @@ -120,9 +120,9 @@ Tests: [`contracts/nav-oracle/src/test.rs`](../contracts/nav-oracle/src/test.rs) | Entrypoint | Access Control / Caller Auth | State & Precondition Guards | Tested Refusal (Test Name) | |---|---|---|---| | `attest` | `#[only_role(caller, "attester")]` | Cooldown, deviation cap, bounds `[min, max]` | `test::attest_is_role_gated` | -| `raise_ripcord` | `#[only_role(caller, "guardian")]` | None | `test::raising_the_ripcord_is_limited_to_the_guardian` | -| `set_ripcord` | `#[only_admin]`, `caller.require_auth()` | None | `test::the_guardian_raises_the_ripcord_but_lowering_needs_governance` | -| `clear_latest` | `#[only_admin]`, `caller.require_auth()` | Ripcord must be raised | `test::only_admin_can_clear_latest`, `test::the_record_clears_only_while_the_ripcord_is_raised` | +| `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` | diff --git a/docs/TESTING.md b/docs/TESTING.md index 920807e5..68be26f0 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -59,7 +59,7 @@ nothing. | Guardian, governance and upgrade powers stay with their role | [`contracts/async-vault/src/test/controls.rs`](../contracts/async-vault/src/test/controls.rs), [`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) | `the_guardian_pauses_but_only_governance_unpauses`, `only_governance_proposes_and_cancels`, `only_governance_applies`, `only_governance_sets_the_delay` | | Roles that must stay separate are refused at deployment | [`contracts/async-vault/src/test/constructor.rs`](../contracts/async-vault/src/test/constructor.rs) | `the_treasury_may_not_also_be_the_guardian`, `compliance_may_not_also_be_governance` | | 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` | -| Oracle: only the attester posts, only the guardian raises the ripcord | [`contracts/nav-oracle/src/test.rs`](../contracts/nav-oracle/src/test.rs) | `attest_is_role_gated`, `raising_the_ripcord_is_limited_to_the_guardian`, `only_admin_can_set_config` | +| 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` | | 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 diff --git a/docs/kit-spec.md b/docs/kit-spec.md index 1180e993..512ea3bb 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -39,7 +39,7 @@ The contracts enforce a set of mathematical and state-machine invariants across - **Definition**: Once an exit is priced and covered by the liquid reserve, its cash payout cannot be blocked by an administrative pause, oracle staleness, or an investor's compliance delisting. - **Testable Property**: If `liquid_reserve >= assets_owed`, `claim_redeem` succeeds when: 1. The vault is paused (`paused == true`). - 2. The oracle feed has expired (`is_stale == true`) or ripcord is raised. + 2. The oracle feed has expired (`is_stale == true`) or the NAV is paused. 3. The caller has been de-listed in the `IdentityVerifier`. Priced claims are never re-priced and never identity-gated. A frozen investor is outside this guarantee (design document §8.1). @@ -106,7 +106,7 @@ Deploying the Strata Vault Kit involves five contracts configured via `environme | **`share_token`** | `identity_verifier` | `Address` | Address of deployed `identity_verifier` contract | `share_token.constructor_args` | | **`nav_oracle`** | `admin` | `Address` | Oracle admin multisig (Governance) | `nav_oracle.constructor_args` | | **`nav_oracle`** | `attester` | `Address` | Authorized reporter multisig for NAV attestations | `nav_oracle.constructor_args` | -| **`nav_oracle`** | `guardian` | `Address` | Guardian multisig authorized to raise ripcord | `nav_oracle.constructor_args` | +| **`nav_oracle`** | `guardian` | `Address` | Guardian multisig authorized to pause the NAV | `nav_oracle.constructor_args` | | **`nav_oracle`** | `config.freshness_duration` | `u64` (seconds) | Validity window of an attestation before it is stale ($> 0$) | `nav_oracle.constructor_args` | | **`nav_oracle`** | `config.cooldown_secs` | `u64` (seconds) | Minimum time between consecutive price attestations | `nav_oracle.constructor_args` | | **`nav_oracle`** | `config.max_up_bps` | `u32` (basis points) | Maximum allowable upward price jump (1 bps = 0.01%, $> 0$) | `nav_oracle.constructor_args` | @@ -154,7 +154,7 @@ Strata distributes control across five native Stellar multisig authorities and p | Authority | Quorum / Profile | Authorized Capabilities | Anti-Capabilities (What It Cannot Do) | |---|---|---|---| | **Governance** | High-quorum deliberative multisig | Set parameters (`notice`, `custodian`, `deposit_cap`, `wind_down_delay`), propose/apply upgrades, propose/cancel wind-down, unpause the vault, pause/unpause the share token, transfer admin. | **Cannot pause the vault directly**; cannot move funds; cannot initiate custodian transfers; cannot attest prices; cannot bypass timelocks. | -| **Guardian** | Low-threshold fast-reaction multisig | `pause` (vault); `raise_ripcord` (oracle emergency price freeze). | **Cannot unpause**; cannot move funds; cannot block redemption requests; cannot block payable claims or cancellations. | +| **Guardian** | Low-threshold fast-reaction multisig | `pause` (vault); `pause_nav` (oracle emergency price freeze). | **Cannot unpause**; cannot move funds; cannot block redemption requests; cannot block payable claims or cancellations. | | **Treasury** | Operational capital multisig | `deploy_to_custodian` (moves free reserve); `fund` (returns assets from custodian). | **Cannot deploy while anything is uncovered**; cannot touch escrowed subscription assets; cannot alter parameters or pause. | | **Attester** | High-security attestation multisig | `attest` (submits NAV report with proof reference). | **Cannot price outside deviation band**; cannot violate cooldown; cannot operate on the vault directly. | | **Compliance** | Legal / KYC operator multisig | Manage allowlist (`allow`), topic claims; token interventions (`freeze`, `forced_transfer`, `recovery`). | **Cannot touch vault reserve**; cannot pause vault operations; cannot bypass exit-only cash redemptions. | @@ -194,11 +194,11 @@ Strata distributes control across five native Stellar multisig authorities and p | **`AsyncVault`** | `pending_changes` | Permissionless | None (view) | No | | **`AsyncVault`** | `transfer_admin_role` | Governance | `#[only_admin]` | No | | **`AsyncVault`** | `accept_admin_transfer` | Proposed Admin | `caller.require_auth()` | No | -| **`NavOracle`** | `attest` | Attester | `#[only_role(caller, "attester")]` | Refused if ripcord active | -| **`NavOracle`** | `raise_ripcord` | Guardian | `#[only_role(caller, "guardian")]` | N/A | -| **`NavOracle`** | `set_ripcord` | Governance | `#[only_admin]` | N/A | +| **`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`** | `clear_latest` | Governance | `#[only_admin]` (requires ripcord) | No | +| **`NavOracle`** | `clear_latest` | Governance | `#[only_admin]` (requires NAV paused) | No | | **`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 | diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index c4291c69..3d77412f 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -478,8 +478,8 @@ investor sees the exit-only path. Operates an existing vault; deploys nothing. Each privileged entrypoint is gated on one role of one contract, except the share token's `forced_transfer`, which -accepts its `manager` (the vault) or `compliance` role. The oracle's ripcord has -two entrypoints: `raise_ripcord` for its guardian and `set_ripcord` for its +accepts its `manager` (the vault) or `compliance` role. The oracle's NAV pause has +two entrypoints: `pause_nav` for its guardian and `set_nav_paused` for its admin. The panel groups the entrypoints into five domain surfaces. A surface is visible to the authorities that act in it, and each action only to its own (permissionless actions to any connected address): @@ -488,7 +488,7 @@ visible to the authorities that act in it, and each action only to its own | ------------- | ----------------------------------------------------------------------------------------------------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Cycle | manager, attestation, treasury; anyone prices, funds, activates and finalizes | Continuous | Epoch sealing (manager); attestations; transfers to the custodian (treasury); pricing, funding, wind-down activation and round finalization (permissionless) | | Compliance | compliance; verifier admin (compliance by deploy) | Continuous | Allowlist (verifier admin); freeze/unfreeze of an address or of part of its shares, forced transfer (compliance); recovery is not offered, the kit's verifier names no recovery target | -| Emergency | guardian, governance; oracle admin and share-token admin (both governance by deploy) | Rare and urgent | Vault pause (guardian) and unpause (governance); oracle ripcord raise (oracle guardian, or oracle admin through `set_ripcord`), reset and `clear_latest` (oracle admin); share-token pause/unpause (share-token admin) | +| Emergency | guardian, governance; oracle admin and share-token admin (both governance by deploy) | Rare and urgent | Vault pause (guardian) and unpause (governance); oracle NAV pause (oracle guardian, or oracle admin through `set_nav_paused`), reset and `clear_latest` (oracle admin); share-token pause/unpause (share-token admin) | | Configuration | governance; oracle admin and share-token admin (both governance by deploy) | Rare and deliberate | Custodian slot, deposit cap, notice and wind-down delay (governance); oracle guardrails (oracle admin); compliance and identity-verifier contracts (share-token admin) | | Governance | governance; oracle admin and share-token admin for their own roles and handover (both governance by deploy) | Very rare | Roles and admin handover on the vault, oracle and share token; upgrade and its delay, wind-down proposal and cancellation (governance) | @@ -520,8 +520,8 @@ flowchart LR subgraph ORACLE["Oracle"] OATT[attest] - ORAISE[raise_ripcord] - OSET[set_ripcord · clear_latest] + ORAISE[pause_nav] + OSET[set_nav_paused · clear_latest] OCFG[set_config] OGOV[roles · admin handover] end diff --git a/scripts/harness/guards.test.ts b/scripts/harness/guards.test.ts index 586a7cdb..21038482 100644 --- a/scripts/harness/guards.test.ts +++ b/scripts/harness/guards.test.ts @@ -113,9 +113,9 @@ describe("an account with no role", () => { ) }) - it("cannot raise the ripcord", async () => { + it("cannot pause the NAV", async () => { const o = oracle(c, outsider) - expectRefused(await o.raise_ripcord({ caller: who }), o, "Unauthorized") + expectRefused(await o.pause_nav({ caller: who }), o, "Unauthorized") }) it("cannot pause the vault", async () => { @@ -131,21 +131,19 @@ describe("an account with no role", () => { await send(vault(c, governance).unpause({ caller: governance.publicKey() })) }) - it("cannot set the ripcord", async () => { + it("cannot set the NAV pause", async () => { await expect( - send(oracle(c, outsider).set_ripcord({ paused: true, caller: who })), + send(oracle(c, outsider).set_nav_paused({ paused: true, caller: who })), ).rejects.toThrow(/signatures/) }) it("cannot clear the latest report", async () => { - await send( - oracle(c, guardian).raise_ripcord({ caller: guardian.publicKey() }), - ) + await send(oracle(c, guardian).pause_nav({ caller: guardian.publicKey() })) await expect( send(oracle(c, outsider).clear_latest({ caller: who })), ).rejects.toThrow(/signatures/) await send( - oracle(c, governance).set_ripcord({ + oracle(c, governance).set_nav_paused({ paused: false, caller: governance.publicKey(), }), @@ -522,10 +520,8 @@ describe("a sealed epoch while the feed is down", () => { ) }) - it("the guardian raises the ripcord and pricing is refused", async () => { - await send( - oracle(c, guardian).raise_ripcord({ caller: guardian.publicKey() }), - ) + it("the guardian pauses the NAV and pricing is refused", async () => { + await send(oracle(c, guardian).pause_nav({ caller: guardian.publicKey() })) expect((await feed.state()).result.tag).toBe("Paused") expectRefused( await vault(c, outsider).fulfill_epoch({ epoch_id: epoch }), @@ -578,7 +574,7 @@ describe("the notice period and valuation age rules", () => { beforeAll(async () => { await send( - oracle(c, governance).set_ripcord({ + oracle(c, governance).set_nav_paused({ paused: false, caller: governance.publicKey(), }), From 578c968f57062760c6b540392f23e53e8c5b1b6b Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:39:02 -0300 Subject: [PATCH 013/147] feat(oracle): bound the config and report expiry --- Cargo.lock | 1 + contracts/async-vault/src/test/mod.rs | 22 +++- contracts/nav-oracle/Cargo.toml | 1 + contracts/nav-oracle/src/lib.rs | 50 ++++++--- contracts/nav-oracle/src/state.rs | 12 +- contracts/nav-oracle/src/test.rs | 152 ++++++++++++++++++++++++-- environments.toml | 2 +- scripts/harness/lib/network.ts | 5 +- 8 files changed, 206 insertions(+), 39 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 21750d9a..f160d6f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1215,6 +1215,7 @@ dependencies = [ "stellar-access", "stellar-macros", "storage", + "timelock", ] [[package]] diff --git a/contracts/async-vault/src/test/mod.rs b/contracts/async-vault/src/test/mod.rs index 672bc01f..9a9b63bb 100644 --- a/contracts/async-vault/src/test/mod.rs +++ b/contracts/async-vault/src/test/mod.rs @@ -93,17 +93,31 @@ impl Fixture<'_> { self.share.balance(who) } + /// Posts a report that the feed's own freshness window bounds, waiting out + /// the cooldown first when the last report is too recent. fn attest(&self, nav_per_share: i128) { + self.wait_out_the_cooldown(); self.oracle.attest( &NavReport { nav_per_share, timestamp: 0, - expires_at: 1_000_000, + expires_at: u64::MAX, }, &self.attester, ); } + fn wait_out_the_cooldown(&self) { + let Ok(Ok(latest)) = self.oracle.try_latest() else { + return; + }; + let ready = latest.timestamp + self.oracle.config().cooldown_secs; + let now = self.e.ledger().timestamp(); + if now < ready { + self.advance(ready - now); + } + } + fn holder(&self, shares: i128) -> Address { let who = self.investor(shares * 2); let epoch = self.vault.request_deposit(&who, &(shares * 2)); @@ -163,13 +177,13 @@ pub(crate) fn register_with(e: &Env, roles: VaultRoles) -> Address { e.register(AsyncVault, (&asset, &asset, &asset, roles)) } -/// The permissive feed most tests want: no cooldown, no effective cap. +/// The permissive feed most tests want: the shortest cooldown, no effective cap. fn open_feed() -> OracleConfig { OracleConfig { freshness_duration: 3_600, - cooldown_secs: 0, + cooldown_secs: 1, max_up_bps: 10_000, - max_down_bps: Some(10_000), + max_down_bps: 10_000, min_answer: 1, max_answer: 1_000 * WAD_SCALE, } diff --git a/contracts/nav-oracle/Cargo.toml b/contracts/nav-oracle/Cargo.toml index 6a092b14..23e0f66b 100644 --- a/contracts/nav-oracle/Cargo.toml +++ b/contracts/nav-oracle/Cargo.toml @@ -15,6 +15,7 @@ storage = { path = "../../crates/storage" } stellar-access = { workspace = true } stellar-macros = { workspace = true } bindings = { path = "../../crates/bindings" } +timelock = { path = "../../crates/timelock" } [dev-dependencies] soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/nav-oracle/src/lib.rs b/contracts/nav-oracle/src/lib.rs index d2016232..b5da6008 100644 --- a/contracts/nav-oracle/src/lib.rs +++ b/contracts/nav-oracle/src/lib.rs @@ -14,7 +14,15 @@ use state::{get_config, get_latest, nav_paused, ATTESTER_ROLE, GUARDIAN_ROLE}; pub use state::{NavReport, OracleConfig, OracleState}; const BPS_DENOM: i128 = 10_000; -const MAX_ANSWER: i128 = i128::MAX / BPS_DENOM; +const WAD: i128 = 1_000_000_000_000_000_000; + +/// One report per ledger at most. A deployment sets a longer cooldown. +pub const MIN_COOLDOWN_SECS: u64 = 1; +/// Thirty-five days: a monthly valuation with slack, and no longer. +pub const MAX_FRESHNESS_SECS: u64 = 35 * timelock::DAY_DURATION; +/// A million units of the asset per share. Far above any real price, and far +/// below where the deviation arithmetic could overflow. +pub const MAX_ANSWER: i128 = 1_000_000 * WAD; #[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq)] @@ -36,6 +44,8 @@ pub enum OracleError { NavNotPaused = 3008, /// The oracle must always have an admin. AdminRequired = 3009, + /// The NAV is paused. Unpause it first; a report never lifts it. + NavPaused = 3010, } #[contractevent] @@ -59,19 +69,25 @@ pub struct ConfigSet { pub freshness_duration: u64, pub cooldown_secs: u64, pub max_up_bps: u32, - pub max_down_bps: Option, + pub max_down_bps: u32, pub min_answer: i128, pub max_answer: i128, } +fn bps_in_range(bps: u32) -> bool { + bps > 0 && bps as i128 <= BPS_DENOM +} + fn validate_config(e: &Env, cfg: &OracleConfig) { if cfg.freshness_duration == 0 + || cfg.freshness_duration > MAX_FRESHNESS_SECS + || cfg.cooldown_secs < MIN_COOLDOWN_SECS + || cfg.cooldown_secs > cfg.freshness_duration || cfg.min_answer <= 0 || cfg.max_answer < cfg.min_answer || cfg.max_answer > MAX_ANSWER - || cfg.max_up_bps == 0 - || cfg.max_up_bps as i128 > BPS_DENOM - || cfg.max_down_bps.is_some_and(|d| d as i128 > BPS_DENOM) + || !bps_in_range(cfg.max_up_bps) + || !bps_in_range(cfg.max_down_bps) { panic_with_error!(e, OracleError::InvalidConfig); } @@ -160,6 +176,9 @@ impl NavOracleContract { #[only_role(caller, "attester")] pub fn attest(e: &Env, report: NavReport, caller: Address) { storage::bump_instance(e); + if nav_paused(e) { + panic_with_error!(e, OracleError::NavPaused); + } let cfg = get_config(e); let now = e.ledger().timestamp(); @@ -175,27 +194,24 @@ impl NavOracleContract { if now < prev.timestamp.saturating_add(cfg.cooldown_secs) { panic_with_error!(e, OracleError::CooldownActive); } - // Directional cap. A rise is always bounded. A fall is bounded only - // when the deployment asks for it, so a real loss can land at once. - let rising = report.nav_per_share >= prev.nav_per_share; - let bps = if rising { - Some(cfg.max_up_bps) + let bps = if report.nav_per_share >= prev.nav_per_share { + cfg.max_up_bps } else { cfg.max_down_bps }; - if let Some(bps) = bps { - let diff = (report.nav_per_share - prev.nav_per_share).abs(); - let bound = prev.nav_per_share * (bps as i128) / BPS_DENOM; - if diff > bound { - panic_with_error!(e, OracleError::DeviationExceeded); - } + let diff = (report.nav_per_share - prev.nav_per_share).abs(); + let bound = prev.nav_per_share * (bps as i128) / BPS_DENOM; + if diff > bound { + panic_with_error!(e, OracleError::DeviationExceeded); } } let stored = NavReport { nav_per_share: report.nav_per_share, timestamp: now, - expires_at: now.saturating_add(cfg.freshness_duration), + expires_at: report + .expires_at + .min(now.saturating_add(cfg.freshness_duration)), }; state::set_latest(e, &stored); diff --git a/contracts/nav-oracle/src/state.rs b/contracts/nav-oracle/src/state.rs index 051b55f5..917f6d88 100644 --- a/contracts/nav-oracle/src/state.rs +++ b/contracts/nav-oracle/src/state.rs @@ -15,6 +15,10 @@ pub const ATTESTER_ROLE: Symbol = symbol_short!("attester"); /// Pauses the NAV in an emergency. Cannot unpause it. pub const GUARDIAN_ROLE: Symbol = symbol_short!("guardian"); +/// A valuation. On input, `expires_at` is the latest moment the reporter +/// vouches for it, and `timestamp` is ignored. As stored, `timestamp` is the +/// ledger time the oracle accepted the report, and `expires_at` is the +/// reporter's expiry capped at that time plus `freshness_duration`. #[contracttype] #[derive(Clone)] pub struct NavReport { @@ -30,11 +34,9 @@ pub struct OracleConfig { pub cooldown_secs: u64, // before new record is admitted /// Upward move allowed per attestation. Always set, never zero. pub max_up_bps: u32, - /// Downward move allowed per attestation. `None` leaves falls uncapped, so a - /// loss of any size lands in one attestation. With it unset, `min_answer` is - /// the only bound on a single report, which makes that floor a risk - /// parameter rather than a sanity check. - pub max_down_bps: Option, + /// Downward move allowed per attestation, 1 to 10,000. At 10,000 a loss of + /// any size lands in one attestation and `min_answer` is the only floor. + pub max_down_bps: u32, pub min_answer: i128, pub max_answer: i128, } diff --git a/contracts/nav-oracle/src/test.rs b/contracts/nav-oracle/src/test.rs index f82fafec..1da7e95e 100644 --- a/contracts/nav-oracle/src/test.rs +++ b/contracts/nav-oracle/src/test.rs @@ -26,7 +26,7 @@ fn config() -> OracleConfig { freshness_duration: 3600, cooldown_secs: 60, max_up_bps: 1_000, - max_down_bps: Some(1_000), // 10% + max_down_bps: 1_000, // 10% min_answer: SCALE / 2, max_answer: SCALE * 100, } @@ -201,7 +201,7 @@ fn set_config_takes_effect() { // that the original 10% band would admit is now rejected. let tight = OracleConfig { max_up_bps: 100, - max_down_bps: Some(100), + max_down_bps: 100, ..config() }; f.oracle.set_config(&tight); @@ -264,7 +264,7 @@ fn constructor_rejects_a_deviation_above_one_hundred_percent() { let attester = Address::generate(&e); let cfg = OracleConfig { max_up_bps: 10_001, - max_down_bps: Some(10_001), + max_down_bps: 10_001, ..config() }; e.register( @@ -360,9 +360,9 @@ fn setup_with(cfg: OracleConfig) -> Fixture<'static> { } #[test] -fn a_large_loss_lands_in_one_attestation() { +fn a_full_downside_cap_lets_a_large_loss_land_at_once() { let mut cfg = config(); - cfg.max_down_bps = None; + cfg.max_down_bps = 10_000; let f = setup_with(cfg); f.oracle @@ -376,9 +376,9 @@ fn a_large_loss_lands_in_one_attestation() { } #[test] -fn an_unbounded_rise_is_still_refused() { +fn a_full_downside_cap_still_bounds_a_rise() { let mut cfg = config(); - cfg.max_down_bps = None; + cfg.max_down_bps = 10_000; let f = setup_with(cfg); f.oracle @@ -476,11 +476,11 @@ fn the_oracle_admin_cannot_renounce_itself_away() { assert_eq!(f.oracle.get_admin(), Some(f.admin.clone())); } -/// Probe: with the downward cap unset, what actually bounds one attestation? +/// With the downward cap at 100%, `min_answer` is all that bounds one fall. #[test] -fn a_fall_is_bounded_only_by_min_answer() { +fn a_full_downside_cap_leaves_min_answer_as_the_only_floor() { let mut cfg = config(); - cfg.max_down_bps = None; + cfg.max_down_bps = 10_000; let f = setup_with(cfg); f.oracle @@ -518,3 +518,135 @@ fn attested_at_without_a_report_is_refused() { assert!(f.oracle.try_attested_at().is_err()); } + +#[test] +fn a_reporter_expiry_inside_the_window_is_kept() { + let f = setup(); + f.oracle + .attest(&report(&f.e, SCALE, 0, 10_000 + 600), &f.attester); + + assert_eq!(f.oracle.latest().expires_at, 10_000 + 600); + f.e.ledger().set_timestamp(10_000 + 601); + assert_eq!(f.oracle.state(), OracleState::Stale); +} + +#[test] +fn a_reporter_expiry_past_the_window_is_cut_to_the_window() { + let f = setup(); + f.oracle + .attest(&report(&f.e, SCALE, 0, 10_000 + 10_000), &f.attester); + + assert_eq!(f.oracle.latest().expires_at, 10_000 + 3600); +} + +#[test] +fn the_reporters_timestamp_is_ignored() { + let f = setup(); + f.oracle + .attest(&report(&f.e, SCALE, 99_999, 1_000_000), &f.attester); + + assert_eq!(f.oracle.latest().timestamp, 10_000); + assert_eq!(f.oracle.attested_at(), 10_000); +} + +#[test] +fn attesting_is_refused_while_the_nav_is_paused() { + let f = setup(); + f.oracle.pause_nav(&f.guardian); + + assert_eq!( + f.oracle + .try_attest(&report(&f.e, SCALE, 0, 1_000_000), &f.attester) + .err(), + Some(Ok(crate::OracleError::NavPaused.into())) + ); + + f.oracle.set_nav_paused(&false, &f.admin); + f.oracle + .attest(&report(&f.e, SCALE, 0, 1_000_000), &f.attester); + assert_eq!(f.oracle.state(), OracleState::Valid); +} + +#[test] +fn two_reports_in_one_ledger_are_refused_at_the_shortest_cooldown() { + let mut cfg = config(); + cfg.cooldown_secs = crate::MIN_COOLDOWN_SECS; + let f = setup_with(cfg); + f.oracle + .attest(&report(&f.e, SCALE, 0, 1_000_000), &f.attester); + + assert_eq!( + f.oracle + .try_attest(&report(&f.e, SCALE, 0, 1_000_000), &f.attester) + .err(), + Some(Ok(crate::OracleError::CooldownActive.into())) + ); + + f.e.ledger() + .set_timestamp(10_000 + crate::MIN_COOLDOWN_SECS); + f.oracle + .attest(&report(&f.e, SCALE, 0, 1_000_000), &f.attester); +} + +fn refuses_config(cfg: OracleConfig) { + let f = setup(); + assert_eq!( + f.oracle.try_set_config(&cfg).err(), + Some(Ok(crate::OracleError::InvalidConfig.into())) + ); +} + +#[test] +fn a_zero_cooldown_is_refused() { + refuses_config(OracleConfig { + cooldown_secs: 0, + ..config() + }); +} + +#[test] +fn a_freshness_window_past_the_maximum_is_refused() { + refuses_config(OracleConfig { + freshness_duration: crate::MAX_FRESHNESS_SECS + 1, + ..config() + }); +} + +#[test] +fn a_max_answer_past_the_ceiling_is_refused() { + refuses_config(OracleConfig { + max_answer: crate::MAX_ANSWER + 1, + ..config() + }); +} + +/// A report must always be replaceable before it expires, or the feed would +/// sit stale through every cooldown. +#[test] +fn a_cooldown_longer_than_the_freshness_window_is_refused() { + refuses_config(OracleConfig { + cooldown_secs: 3_601, + ..config() + }); +} + +#[test] +fn a_zero_downside_cap_is_refused() { + refuses_config(OracleConfig { + max_down_bps: 0, + ..config() + }); +} + +#[test] +fn the_bounds_themselves_are_accepted() { + let f = setup(); + f.oracle.set_config(&OracleConfig { + freshness_duration: crate::MAX_FRESHNESS_SECS, + cooldown_secs: crate::MIN_COOLDOWN_SECS, + max_up_bps: 10_000, + max_down_bps: 10_000, + min_answer: 1, + max_answer: crate::MAX_ANSWER, + }); +} diff --git a/environments.toml b/environments.toml index 0a689953..514b39a6 100644 --- a/environments.toml +++ b/environments.toml @@ -40,7 +40,7 @@ constructor_args = '--name "Strata Vault USDC" --symbol bvUSDC --admin me --comp # fields are strings because they do not survive a JSON number round trip. [development.contracts.nav_oracle] client = true -constructor_args = "--admin me --attester me --guardian me --config '{\"freshness_duration\":3600,\"cooldown_secs\":60,\"max_up_bps\":1000,\"max_down_bps\":null,\"min_answer\":\"500000000000000000\",\"max_answer\":\"100000000000000000000\"}'" +constructor_args = "--admin me --attester me --guardian me --config '{\"freshness_duration\":3600,\"cooldown_secs\":60,\"max_up_bps\":1000,\"max_down_bps\":10000,\"min_answer\":\"500000000000000000\",\"max_answer\":\"100000000000000000000\"}'" [development.contracts.async_vault] client = true diff --git a/scripts/harness/lib/network.ts b/scripts/harness/lib/network.ts index dae1a93b..707f7372 100644 --- a/scripts/harness/lib/network.ts +++ b/scripts/harness/lib/network.ts @@ -8,7 +8,7 @@ export interface OracleConfig { freshness_duration: number cooldown_secs: number max_up_bps: number - max_down_bps: number | null + max_down_bps: number min_answer: string max_answer: string } @@ -25,9 +25,10 @@ export interface Network { } // The band is the protocol's; only the two waits are the fixture's choice. +// A fall of any size lands at once, as a real loss must. const band = { max_up_bps: 1000, - max_down_bps: null, + max_down_bps: 10_000, min_answer: (WAD / 2n).toString(), max_answer: (WAD * 100n).toString(), } From 28bcb14a5e2c72a636cce998cccc72f36ea491cf Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:47:05 -0300 Subject: [PATCH 014/147] fix(harness): let the oracle cap report expiry --- scripts/harness/lib/oracle.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/scripts/harness/lib/oracle.ts b/scripts/harness/lib/oracle.ts index 399ac4f8..5b2a1981 100644 --- a/scripts/harness/lib/oracle.ts +++ b/scripts/harness/lib/oracle.ts @@ -8,12 +8,14 @@ export interface NavReport { timestamp: bigint } -export function navReport(nav: bigint, freshnessSecs: number): NavReport { - const now = BigInt(Math.floor(Date.now() / 1000)) +/** The latest expiry a u64 holds: the oracle cuts it to its own window. */ +const NO_EXPIRY = (1n << 64n) - 1n + +export function navReport(nav: bigint): NavReport { return { nav_per_share: nav, - expires_at: now + BigInt(freshnessSecs), - timestamp: now, + expires_at: NO_EXPIRY, + timestamp: 0n, } } @@ -24,7 +26,7 @@ export async function attest(c: Connected, nav: bigint): Promise { const deadline = Date.now() + (c.net.oracle.cooldown_secs + 60) * 1_000 for (;;) { const tx = await client.attest({ - report: navReport(nav, c.net.oracle.freshness_duration), + report: navReport(nav), caller: attester.publicKey(), }) const result = outcome(tx, client) From da94830d8f22cafca4a20d6ba9c5885c924f23dd Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:49:00 -0300 Subject: [PATCH 015/147] docs: state the oracle bounds --- docs/AUTHORIZATION_MATRIX.md | 2 +- docs/TESTING.md | 2 +- docs/kit-spec.md | 11 ++++++----- docs/strata-product-and-architecture.md | 11 +++++++---- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/docs/AUTHORIZATION_MATRIX.md b/docs/AUTHORIZATION_MATRIX.md index f50ee015..1355a318 100644 --- a/docs/AUTHORIZATION_MATRIX.md +++ b/docs/AUTHORIZATION_MATRIX.md @@ -119,7 +119,7 @@ Tests: [`contracts/nav-oracle/src/test.rs`](../contracts/nav-oracle/src/test.rs) | Entrypoint | Access Control / Caller Auth | State & Precondition Guards | Tested Refusal (Test Name) | |---|---|---|---| -| `attest` | `#[only_role(caller, "attester")]` | Cooldown, deviation cap, bounds `[min, max]` | `test::attest_is_role_gated` | +| `attest` | `#[only_role(caller, "attester")]` | Refused while the NAV is paused; cooldown, deviation cap, bounds `[min, max]` | `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` | diff --git a/docs/TESTING.md b/docs/TESTING.md index 68be26f0..082da65c 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -72,7 +72,7 @@ so time rules and TTL can be reached in one test. | The test clock moves time and sequence | [`contracts/async-vault/src/test/controls.rs`](../contracts/async-vault/src/test/controls.rs) | `the_clock_helper_moves_time_and_sequence` | | Closing records the ledger time; pricing waits for the notice period | [`contracts/async-vault/src/test/notice.rs`](../contracts/async-vault/src/test/notice.rs) | `closing_records_the_moment_and_the_wait`, `an_epoch_cannot_be_priced_inside_its_notice`, `it_is_priced_once_the_notice_elapses` | | A valuation must be newer than the epoch close | [`contracts/async-vault/src/test/oracle_pricing.rs`](../contracts/async-vault/src/test/oracle_pricing.rs) | `an_epoch_is_not_priced_against_a_valuation_older_than_its_close`, `a_valuation_taken_in_the_closing_ledger_prices_the_epoch` | -| 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` | +| 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` | | 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` | diff --git a/docs/kit-spec.md b/docs/kit-spec.md index 512ea3bb..78de0f47 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -34,6 +34,7 @@ The contracts enforce a set of mathematical and state-machine invariants across - **Mathematical Statement**: $$\text{attested\_at} \ge \text{closed\_at}$$ - **Testable Property**: If the latest oracle attestation timestamp precedes `closed_at`, `fulfill_epoch` is rejected with `AttestationBeforeClose`. A valuation accepted in the exact closing ledger timestamp or later is accepted. + `attested_at` is the ledger time the oracle accepted the report, not a valuation date: the reporter's own `timestamp` is ignored. So "accepted before the close" compares acceptance time. The reporter's `expires_at` can only shorten a report's life: the oracle stores `min(expires_at, attested_at + freshness_duration)`. ### Invariant 4: Covered Claims Always Pay (Exit Guarantee) - **Definition**: Once an exit is priced and covered by the liquid reserve, its cash payout cannot be blocked by an administrative pause, oracle staleness, or an investor's compliance delisting. @@ -107,12 +108,12 @@ Deploying the Strata Vault Kit involves five contracts configured via `environme | **`nav_oracle`** | `admin` | `Address` | Oracle admin multisig (Governance) | `nav_oracle.constructor_args` | | **`nav_oracle`** | `attester` | `Address` | Authorized reporter multisig for NAV attestations | `nav_oracle.constructor_args` | | **`nav_oracle`** | `guardian` | `Address` | Guardian multisig authorized to pause the NAV | `nav_oracle.constructor_args` | -| **`nav_oracle`** | `config.freshness_duration` | `u64` (seconds) | Validity window of an attestation before it is stale ($> 0$) | `nav_oracle.constructor_args` | -| **`nav_oracle`** | `config.cooldown_secs` | `u64` (seconds) | Minimum time between consecutive price attestations | `nav_oracle.constructor_args` | -| **`nav_oracle`** | `config.max_up_bps` | `u32` (basis points) | Maximum allowable upward price jump (1 bps = 0.01%, $> 0$) | `nav_oracle.constructor_args` | -| **`nav_oracle`** | `config.max_down_bps` | `Option` (bps) | Maximum allowable downward jump (`null` = loss of any size allowed) | `nav_oracle.constructor_args` | +| **`nav_oracle`** | `config.freshness_duration` | `u64` (seconds) | Validity window of an attestation before it is stale; from 1 s to 35 days (`MAX_FRESHNESS_SECS`) | `nav_oracle.constructor_args` | +| **`nav_oracle`** | `config.cooldown_secs` | `u64` (seconds) | Minimum time between consecutive attestations. At least 1 s (`MIN_COOLDOWN_SECS`), so at most one report per ledger. At most `freshness_duration`, so a new report can always replace the last one before it expires | `nav_oracle.constructor_args` | +| **`nav_oracle`** | `config.max_up_bps` | `u32` (basis points) | Largest upward move per attestation, 1 to 10,000 | `nav_oracle.constructor_args` | +| **`nav_oracle`** | `config.max_down_bps` | `u32` (basis points) | Largest downward move per attestation, 1 to 10,000; 10,000 lets a loss of any size land at once | `nav_oracle.constructor_args` | | **`nav_oracle`** | `config.min_answer` | `i128` (18 decimals) | Absolute floor price per share (cannot be zero or negative) | `nav_oracle.constructor_args` | -| **`nav_oracle`** | `config.max_answer` | `i128` (18 decimals) | Absolute ceiling price per share ($\ge \text{min\_answer}$) | `nav_oracle.constructor_args` | +| **`nav_oracle`** | `config.max_answer` | `i128` (18 decimals) | Absolute ceiling price per share ($\ge \text{min\_answer}$, $\le 10^{6} \times 10^{18}$, `MAX_ANSWER`) | `nav_oracle.constructor_args` | | **`async_vault`** | `asset` | `Address` | Settlement asset SAC contract address (e.g. USDC) | `async_vault.constructor_args` | | **`async_vault`** | `share_token` | `Address` | Deployed share token contract address | `async_vault.constructor_args` | | **`async_vault`** | `oracle` | `Address` | Deployed NAV oracle contract address | `async_vault.constructor_args` | diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index 3d77412f..f5ad2473 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -374,9 +374,12 @@ attestation carries the share price and a proof reference; attestations are ordered by their acceptance time on the ledger. The price must stay within configured bounds, a minimum cooldown bounds frequency, and the deviation cap is directional: the upward bound is mandatory and non-zero, the downward bound is -optional, and leaving it unset lets a loss of any size land in one attestation. -An epoch is never priced against a valuation accepted before it closed, and the -oracle's cooldown therefore bounds how soon a closed epoch can be priced. +required too, and setting it to 100% lets a loss of any size land in one +attestation. A report's own expiry can shorten its life but never extend it past +the freshness window, and no report is accepted while the NAV is paused: +unpausing it is a deliberate act before reporting resumes. An epoch is never +priced against a valuation accepted before it closed, and the oracle's cooldown +therefore bounds how soon a closed epoch can be priced. **Freshness and pause:** each attestation opens a validity window; when it lapses the feed is stale and new requests stop being priced. The guardian can @@ -581,7 +584,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 upward, and `min_answer` is what bounds it downward, so that floor is a risk parameter and not a sanity check; 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 | +| 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. | | 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 | From 027f322cac79aeaeec89622bdeb397552ef4632e Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:16:01 -0300 Subject: [PATCH 016/147] fix(oracle): correct test and doc gaps the cap exposed --- contracts/async-vault/src/test/oracle_pricing.rs | 5 +++++ contracts/nav-oracle/src/test.rs | 4 ++-- docs/AUTHORIZATION_MATRIX.md | 2 +- docs/kit-spec.md | 2 +- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/contracts/async-vault/src/test/oracle_pricing.rs b/contracts/async-vault/src/test/oracle_pricing.rs index 493f278c..32a7773e 100644 --- a/contracts/async-vault/src/test/oracle_pricing.rs +++ b/contracts/async-vault/src/test/oracle_pricing.rs @@ -99,12 +99,17 @@ fn a_valuation_taken_in_the_closing_ledger_prices_the_epoch() { let holder = f.holder(100); f.vault.request_redeem(&holder, &100); + f.advance(60); let epoch = f.close_epoch(); f.attest(wad(3)); f.vault.fulfill_epoch(&epoch); assert_eq!(f.vault.get_epoch(&epoch).unwrap().share_price, wad(3)); + assert_eq!( + f.oracle.attested_at(), + f.vault.get_epoch(&epoch).unwrap().closed_at + ); } #[test] diff --git a/contracts/nav-oracle/src/test.rs b/contracts/nav-oracle/src/test.rs index 1da7e95e..a4e4d3d6 100644 --- a/contracts/nav-oracle/src/test.rs +++ b/contracts/nav-oracle/src/test.rs @@ -620,8 +620,8 @@ fn a_max_answer_past_the_ceiling_is_refused() { }); } -/// A report must always be replaceable before it expires, or the feed would -/// sit stale through every cooldown. +/// A new report must always be able to replace the last one before the +/// freshness window ends, or the feed would sit stale through every cooldown. #[test] fn a_cooldown_longer_than_the_freshness_window_is_refused() { refuses_config(OracleConfig { diff --git a/docs/AUTHORIZATION_MATRIX.md b/docs/AUTHORIZATION_MATRIX.md index 1355a318..27a565f9 100644 --- a/docs/AUTHORIZATION_MATRIX.md +++ b/docs/AUTHORIZATION_MATRIX.md @@ -119,7 +119,7 @@ Tests: [`contracts/nav-oracle/src/test.rs`](../contracts/nav-oracle/src/test.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]` | `test::attest_is_role_gated`, `test::attesting_is_refused_while_the_nav_is_paused` | +| `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` | diff --git a/docs/kit-spec.md b/docs/kit-spec.md index 78de0f47..17a830ba 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -109,7 +109,7 @@ Deploying the Strata Vault Kit involves five contracts configured via `environme | **`nav_oracle`** | `attester` | `Address` | Authorized reporter multisig for NAV attestations | `nav_oracle.constructor_args` | | **`nav_oracle`** | `guardian` | `Address` | Guardian multisig authorized to pause the NAV | `nav_oracle.constructor_args` | | **`nav_oracle`** | `config.freshness_duration` | `u64` (seconds) | Validity window of an attestation before it is stale; from 1 s to 35 days (`MAX_FRESHNESS_SECS`) | `nav_oracle.constructor_args` | -| **`nav_oracle`** | `config.cooldown_secs` | `u64` (seconds) | Minimum time between consecutive attestations. At least 1 s (`MIN_COOLDOWN_SECS`), so at most one report per ledger. At most `freshness_duration`, so a new report can always replace the last one before it expires | `nav_oracle.constructor_args` | +| **`nav_oracle`** | `config.cooldown_secs` | `u64` (seconds) | Minimum time between consecutive attestations. At least 1 s (`MIN_COOLDOWN_SECS`), so at most one report per ledger. At most `freshness_duration`, so a new report can always replace the last one before the freshness window ends | `nav_oracle.constructor_args` | | **`nav_oracle`** | `config.max_up_bps` | `u32` (basis points) | Largest upward move per attestation, 1 to 10,000 | `nav_oracle.constructor_args` | | **`nav_oracle`** | `config.max_down_bps` | `u32` (basis points) | Largest downward move per attestation, 1 to 10,000; 10,000 lets a loss of any size land at once | `nav_oracle.constructor_args` | | **`nav_oracle`** | `config.min_answer` | `i128` (18 decimals) | Absolute floor price per share (cannot be zero or negative) | `nav_oracle.constructor_args` | From 763930048d5a136e42837c905785c33ad291a6ef Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:43:04 -0300 Subject: [PATCH 017/147] refactor: share the token and feed interfaces --- Cargo.lock | 1 + contracts/async-vault/Cargo.toml | 1 + contracts/async-vault/src/deposit.rs | 4 +-- contracts/async-vault/src/lib.rs | 4 +-- contracts/async-vault/src/redeem.rs | 8 +++--- contracts/async-vault/src/wind_down.rs | 11 ++++---- contracts/nav-oracle/src/lib.rs | 36 ++++++++++++++++---------- contracts/nav-oracle/src/test.rs | 17 ++++++++++++ crates/bindings/src/lib.rs | 19 +++++--------- 9 files changed, 61 insertions(+), 40 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f160d6f0..de5da08b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -194,6 +194,7 @@ dependencies = [ "stellar-access", "stellar-contract-utils", "stellar-macros", + "stellar-tokens", "storage", "timelock", ] diff --git a/contracts/async-vault/Cargo.toml b/contracts/async-vault/Cargo.toml index bfe945f9..da34cabb 100644 --- a/contracts/async-vault/Cargo.toml +++ b/contracts/async-vault/Cargo.toml @@ -15,6 +15,7 @@ storage = { path = "../../crates/storage" } stellar-access = { workspace = true } stellar-macros = { workspace = true } stellar-contract-utils = { workspace = true } +stellar-tokens = { workspace = true } bindings = { path = "../../crates/bindings" } timelock = { path = "../../crates/timelock" } diff --git a/contracts/async-vault/src/deposit.rs b/contracts/async-vault/src/deposit.rs index b4023db6..1720252e 100644 --- a/contracts/async-vault/src/deposit.rs +++ b/contracts/async-vault/src/deposit.rs @@ -1,5 +1,5 @@ -use bindings::ShareClient; use soroban_sdk::{panic_with_error, token::TokenClient, Address, Env}; +use stellar_tokens::rwa::RWATokenClient; use crate::epoch; use crate::error::VaultError; @@ -106,7 +106,7 @@ pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 { state::set_pending_mint_shares(e, state::pending_mint_shares(e).saturating_sub(shares)); let share_token = state::get_addr(e, &DataKey::ShareToken); - ShareClient::new(e, &share_token).mint(caller, &shares, &e.current_contract_address()); + RWATokenClient::new(e, &share_token).mint(caller, &shares, &e.current_contract_address()); DepositClaimed { controller: caller.clone(), diff --git a/contracts/async-vault/src/lib.rs b/contracts/async-vault/src/lib.rs index 68138734..fb7ed11b 100644 --- a/contracts/async-vault/src/lib.rs +++ b/contracts/async-vault/src/lib.rs @@ -15,12 +15,12 @@ mod treasury; mod upgrade; mod wind_down; -use bindings::ShareClient; use soroban_sdk::{contract, contractimpl, panic_with_error, Address, Env, Symbol, Vec}; use stellar_access::access_control::{self, AccessControl}; use stellar_contract_utils::pausable::{self as pausable, Pausable}; use stellar_contract_utils::upgradeable; use stellar_macros::{only_admin, only_role, when_not_paused}; +use stellar_tokens::fungible::FungibleTokenClient; use timelock::{Clock, ClockSource, Refusal, TimelockHost, DAY_DURATION}; use keys::DataKey; @@ -159,7 +159,7 @@ impl AsyncVault { pub fn total_economic_supply(e: &Env) -> i128 { let share_token = state::get_addr(e, &DataKey::ShareToken); - let client = ShareClient::new(e, &share_token); + let client = FungibleTokenClient::new(e, &share_token); let total_supply = client.total_supply(); let vault_escrow = client.balance(&e.current_contract_address()); let circulating = total_supply.saturating_sub(vault_escrow); diff --git a/contracts/async-vault/src/redeem.rs b/contracts/async-vault/src/redeem.rs index c85e5e72..c8b51d14 100644 --- a/contracts/async-vault/src/redeem.rs +++ b/contracts/async-vault/src/redeem.rs @@ -1,5 +1,5 @@ -use bindings::ShareClient; use soroban_sdk::{panic_with_error, token::TokenClient, Address, Env}; +use stellar_tokens::{fungible::FungibleTokenClient, rwa::RWATokenClient}; use crate::epoch; use crate::error::VaultError; @@ -45,7 +45,7 @@ pub(crate) fn request(e: &Env, from: &Address, shares: i128) -> u64 { let vault = e.current_contract_address(); let share_token = state::get_addr(e, &DataKey::ShareToken); - ShareClient::new(e, &share_token).forced_transfer(from, &vault, &shares, &vault); + RWATokenClient::new(e, &share_token).forced_transfer(from, &vault, &shares, &vault); RedeemRequested { controller: from.clone(), @@ -101,7 +101,7 @@ pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 { state::set_committed(e, state::committed(e).saturating_sub(assets)); TokenClient::new(e, &asset).transfer(&vault, caller, &assets); - ShareClient::new(e, &state::get_addr(e, &DataKey::ShareToken)).burn( + RWATokenClient::new(e, &state::get_addr(e, &DataKey::ShareToken)).burn( &vault, &request.shares, &vault, @@ -130,7 +130,7 @@ fn return_shares(e: &Env, controller: &Address, epoch_id: u64, shares: i128) { state::remove_redeem_request(e, epoch_id, controller); let vault = e.current_contract_address(); - ShareClient::new(e, &state::get_addr(e, &DataKey::ShareToken)) + FungibleTokenClient::new(e, &state::get_addr(e, &DataKey::ShareToken)) .transfer(&vault, controller, &shares); RedeemCancelled { diff --git a/contracts/async-vault/src/wind_down.rs b/contracts/async-vault/src/wind_down.rs index 0b7d272f..028faf79 100644 --- a/contracts/async-vault/src/wind_down.rs +++ b/contracts/async-vault/src/wind_down.rs @@ -1,6 +1,6 @@ -use bindings::ShareClient; use soroban_sdk::{contracttype, panic_with_error, token::TokenClient, Address, Env}; use stellar_contract_utils::math::{i128_fixed_point::checked_mul_div_floor, wad::WAD_SCALE}; +use stellar_tokens::{fungible::FungibleTokenClient, rwa::RWATokenClient}; use crate::error::VaultError; use crate::event::{ @@ -153,7 +153,7 @@ pub(crate) fn supply_snapshot(e: &Env) -> i128 { /// redemption, and owed by a deposit that has been priced but not claimed. fn snapshot_supply(e: &Env) -> i128 { let share_token = state::get_addr(e, &DataKey::ShareToken); - ShareClient::new(e, &share_token) + FungibleTokenClient::new(e, &share_token) .total_supply() .saturating_sub(state::pending_burn_shares(e)) .checked_add(state::pending_mint_shares(e)) @@ -236,7 +236,7 @@ pub(crate) fn claimable(e: &Env, holder: &Address) -> i128 { } let share_token = state::get_addr(e, &DataKey::ShareToken); - let held = ShareClient::new(e, &share_token).balance(holder); + let held = FungibleTokenClient::new(e, &share_token).balance(holder); let p = position(e, holder); let entitlement = p.entitlement.saturating_add(held); @@ -255,9 +255,8 @@ pub(crate) fn claim(e: &Env, holder: &Address) -> i128 { let vault = e.current_contract_address(); let share_token = state::get_addr(e, &DataKey::ShareToken); - let share = ShareClient::new(e, &share_token); - let surrendered = share.balance(holder); + let surrendered = FungibleTokenClient::new(e, &share_token).balance(holder); let mut p = position(e, holder); p.entitlement = p .entitlement @@ -276,7 +275,7 @@ pub(crate) fn claim(e: &Env, holder: &Address) -> i128 { storage::set_persistent(e, &DataKey::WindDownPosition(holder.clone()), &p); if surrendered > 0 { - share.burn(holder, &surrendered, &vault); + RWATokenClient::new(e, &share_token).burn(holder, &surrendered, &vault); } if assets > 0 { diff --git a/contracts/nav-oracle/src/lib.rs b/contracts/nav-oracle/src/lib.rs index b5da6008..8c6f6f0a 100644 --- a/contracts/nav-oracle/src/lib.rs +++ b/contracts/nav-oracle/src/lib.rs @@ -2,6 +2,7 @@ mod state; +use bindings::OracleFeed; use soroban_sdk::{ contract, contracterror, contractevent, contractimpl, panic_with_error, Address, Env, Symbol, Vec, @@ -142,18 +143,10 @@ impl NavOracleContract { state::set_nav_paused(e, false); } - pub fn nav_per_share(e: &Env) -> i128 { - Self::latest(e).nav_per_share - } - pub fn latest(e: &Env) -> NavReport { get_latest(e).unwrap_or_else(|| panic_with_error!(e, OracleError::NoRecord)) } - pub fn state(e: &Env) -> OracleState { - compute_state(e) - } - pub fn is_stale(e: &Env) -> bool { match get_latest(e) { None => true, @@ -161,12 +154,6 @@ impl NavOracleContract { } } - /// Ledger time the standing report was accepted. Not the valuation date - /// the reporter had in mind: the moment the contract took it. - pub fn attested_at(e: &Env) -> u64 { - Self::latest(e).timestamp - } - pub fn ensure_consumable(e: &Env) { if compute_state(e) != OracleState::Valid { panic_with_error!(e, OracleError::NotConsumable); @@ -269,5 +256,26 @@ impl NavOracleContract { } } +#[contractimpl(contracttrait)] +impl OracleFeed for NavOracleContract { + fn state(e: &Env) -> OracleState { + compute_state(e) + } + + fn nav_per_share(e: &Env) -> i128 { + Self::latest(e).nav_per_share + } + + /// Not the valuation date the reporter had in mind: the moment the + /// contract took the report. + fn attested_at(e: &Env) -> u64 { + Self::latest(e).timestamp + } + + fn freshness_duration(e: &Env) -> u64 { + get_config(e).freshness_duration + } +} + #[cfg(test)] mod test; diff --git a/contracts/nav-oracle/src/test.rs b/contracts/nav-oracle/src/test.rs index a4e4d3d6..3e9c0b99 100644 --- a/contracts/nav-oracle/src/test.rs +++ b/contracts/nav-oracle/src/test.rs @@ -650,3 +650,20 @@ fn the_bounds_themselves_are_accepted() { max_answer: crate::MAX_ANSWER, }); } + +fn implements_the_feed() {} + +#[test] +fn the_oracle_answers_the_feed_interface_the_vault_reads() { + implements_the_feed::(); + let f = setup(); + f.oracle + .attest(&report(&f.e, SCALE, 0, 1_000_000), &f.attester); + + let feed = bindings::OracleFeedClient::new(&f.e, &f.oracle.address); + + assert_eq!(feed.state(), OracleState::Valid); + assert_eq!(feed.nav_per_share(), SCALE); + assert_eq!(feed.attested_at(), 10_000); + assert_eq!(feed.freshness_duration(), 3600); +} diff --git a/crates/bindings/src/lib.rs b/crates/bindings/src/lib.rs index 80e77826..7452dc1f 100644 --- a/crates/bindings/src/lib.rs +++ b/crates/bindings/src/lib.rs @@ -1,6 +1,6 @@ #![no_std] -use soroban_sdk::{contractclient, contracttype, Address, Env}; +use soroban_sdk::{contracttrait, contracttype, Env}; #[contracttype] #[derive(Clone, Copy, PartialEq, Eq, Debug)] @@ -10,19 +10,14 @@ pub enum OracleState { Paused, } -#[contractclient(name = "OracleFeedClient")] +/// The feed a vault prices epochs from. The kit's oracle implements it, and +/// so does any feed an adopter brings: the compiler checks both sides. +#[contracttrait] pub trait OracleFeed { fn state(e: &Env) -> OracleState; fn nav_per_share(e: &Env) -> i128; + /// Ledger time the standing report was accepted. fn attested_at(e: &Env) -> u64; -} - -#[contractclient(name = "ShareClient")] -pub trait Share { - fn mint(e: &Env, to: Address, amount: i128, operator: Address); - fn burn(e: &Env, user_address: Address, amount: i128, operator: Address); - fn forced_transfer(e: &Env, from: Address, to: Address, amount: i128, operator: Address); - fn transfer(e: &Env, from: Address, to: Address, amount: i128); - fn total_supply(e: &Env) -> i128; - fn balance(e: &Env, id: Address) -> i128; + /// How long a report stays valid after it is accepted. + fn freshness_duration(e: &Env) -> u64; } From 9b6b5e125fd8e365615726fda0f875017ef7ca21 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:43:20 -0300 Subject: [PATCH 018/147] docs: list the interfaces an adopter implements --- docs/COMPLIANCE_MATRIX.md | 8 ++++---- docs/kit-spec.md | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/docs/COMPLIANCE_MATRIX.md b/docs/COMPLIANCE_MATRIX.md index 101e9cbb..ebf20b04 100644 --- a/docs/COMPLIANCE_MATRIX.md +++ b/docs/COMPLIANCE_MATRIX.md @@ -31,9 +31,9 @@ Every investor account in the vault ecosystem exists in one of three primary com |---|---|---|---|---| | **`request_deposit`** | ✅ Allowed | ⚠️ Allowed by SAC | ⚠️ Allowed by SAC | SAC USDC transfer moves funds into cancellable escrow. The vault does not check `IdentityVerifier` at subscription time to avoid coupling to external registry gas or third-party downtime. | | **`cancel_deposit`** | ✅ Allowed | ✅ **Allowed** | ✅ **Allowed** | **Escape Hatch**: Calls `refund_deposit`. Escrowed settlement asset (USDC) is refunded directly to `controller`. Cash refunds bypass share token compliance checks. | -| **`claim_deposit`** | ✅ Allowed | ❌ **Refused** | ⚠️ Minted if allowlisted | `ShareClient::mint` calls `verify_identity` and `can_create`, and does not check freezes. A de-listed receiver is refused with `IdentityVerificationFailed`. A frozen receiver still on the allowlist is minted, and cannot transfer those shares while frozen. | -| **`request_redeem`** | ✅ Allowed | ✅ **Allowed** | ⚠️ Allowed, not guaranteed | **Exit Right**: the vault escrows the shares with `ShareClient::forced_transfer`, which checks neither the allowlist nor freezes, so a de-listed holder can start an exit. It also releases as much of a partial freeze as the escrow needs. | -| **`cancel_redeem`** | ✅ Allowed | ❌ **Refused** | ❌ **Refused** | `return_shares` uses standard `ShareClient::transfer(vault, controller, shares)`, which executes `can_transfer`. Reverts if `controller` is de-listed or frozen. Holder cannot re-acquire shares. Refused with `EnforcedPause` while the share-token transfer pause is active; the request stays pending until the pause is lifted or the epoch prices. | +| **`claim_deposit`** | ✅ Allowed | ❌ **Refused** | ⚠️ Minted if allowlisted | `RWATokenClient::mint` calls `verify_identity` and `can_create`, and does not check freezes. A de-listed receiver is refused with `IdentityVerificationFailed`. A frozen receiver still on the allowlist is minted, and cannot transfer those shares while frozen. | +| **`request_redeem`** | ✅ Allowed | ✅ **Allowed** | ⚠️ Allowed, not guaranteed | **Exit Right**: the vault escrows the shares with `RWATokenClient::forced_transfer`, which checks neither the allowlist nor freezes, so a de-listed holder can start an exit. It also releases as much of a partial freeze as the escrow needs. | +| **`cancel_redeem`** | ✅ Allowed | ❌ **Refused** | ❌ **Refused** | `return_shares` uses standard `FungibleTokenClient::transfer(vault, controller, shares)`, which executes `can_transfer`. Reverts if `controller` is de-listed or frozen. Holder cannot re-acquire shares. Refused with `EnforcedPause` while the share-token transfer pause is active; the request stays pending until the pause is lifted or the epoch prices. | | **`claim_redeem`** | ✅ Allowed | ✅ **Allowed** | ⚠️ Pays, not guaranteed | **Exit-Only Cash Path**: the settlement asset pays out from the liquid reserve and the escrowed shares are burned from the vault. The cash claim checks neither the allowlist nor freezes. | | **`transfer`** (Secondary) | ✅ Allowed | ❌ **Refused** | ❌ **Refused** | Standard peer-to-peer share transfer via SEP-57. Requires both sender and receiver to be allowlisted and neither to be frozen. | @@ -59,7 +59,7 @@ If an investor deposits into an open epoch but is de-listed (or KYC expires) bef ### 3.3 De-Listing Between Deposit Request and Claim If an investor deposits while allowlisted, but is de-listed after the epoch is closed and priced: -- **`claim_deposit` is refused**: The vault calls `ShareClient::mint`, which evaluates `can_create`. Because the receiver is no longer allowlisted, `verify_identity` fails with `IdentityVerificationFailed`. +- **`claim_deposit` is refused**: The vault calls `RWATokenClient::mint`, which evaluates `can_create`. Because the receiver is no longer allowlisted, `verify_identity` fails with `IdentityVerificationFailed`. - **`cancel_deposit` is blocked**: The epoch is already `Fulfilled`. As an anti-arbitrage safeguard, once an epoch has taken a valuation, cancellation is permanently closed (`VaultError::AlreadyPriced`). - **Resolution**: The investor's settlement capital remains safely in vault escrow. The investor can either: 1. Complete KYC refresh, upon which compliance re-allowlists the account and `claim_deposit` succeeds; or diff --git a/docs/kit-spec.md b/docs/kit-spec.md index 17a830ba..d1bb1271 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -288,3 +288,22 @@ one instance at a time. repository are testnet stubs. A production compliance module or identity registry must extend its own instance and entries the same way, or the share token stops working when they archive. + +--- + +## 6. Contract Interfaces for Adopters + +The vault reaches the other contracts only through these traits. An adopter +who replaces one implements the trait, and the compiler checks the +implementation against what the vault calls. + +| Interface | Defined in | Implemented here by | What the vault calls | +|---|---|---|---| +| `OracleFeed` | `crates/bindings` | `nav-oracle` | `state`, `nav_per_share`, `attested_at`, `freshness_duration` | +| `FungibleToken` | OpenZeppelin `stellar-tokens` 0.7.2 | `share-token` | `transfer`, `balance`, `total_supply` | +| `RWAToken` | OpenZeppelin `stellar-tokens` 0.7.2 | `share-token` | `mint`, `burn`, `forced_transfer` | +| `IdentityVerifier` | OpenZeppelin `stellar-tokens` 0.7.2 | `identity-verifier` (testnet stub) | `verify_identity`, called by the share token | + +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. From 58244b1b247c0cbfdb66c78b6c78143c43b98a61 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:27:34 -0300 Subject: [PATCH 019/147] feat(vault): check allowlist on deposit request --- contracts/async-vault/src/deposit.rs | 9 ++ contracts/async-vault/src/error.rs | 3 + contracts/async-vault/src/lib.rs | 1 + contracts/async-vault/src/lock_policy.rs | 43 +++++++ contracts/async-vault/src/test/compliance.rs | 121 ++++++++++++++++-- contracts/async-vault/src/test/lock_policy.rs | 52 ++++++++ contracts/async-vault/src/test/mod.rs | 1 + 7 files changed, 221 insertions(+), 9 deletions(-) create mode 100644 contracts/async-vault/src/lock_policy.rs create mode 100644 contracts/async-vault/src/test/lock_policy.rs diff --git a/contracts/async-vault/src/deposit.rs b/contracts/async-vault/src/deposit.rs index 1720252e..109ac15b 100644 --- a/contracts/async-vault/src/deposit.rs +++ b/contracts/async-vault/src/deposit.rs @@ -5,11 +5,19 @@ use crate::epoch; use crate::error::VaultError; use crate::event::{DepositCancelled, DepositClaimed, DepositRequested}; use crate::keys::DataKey; +use crate::lock_policy::{self, Decision, Exit}; use crate::pricing::{Pricing, PricingScheme}; use crate::state::{self, DepositRequest, EpochStatus}; use crate::treasury; use crate::wind_down; +fn refuse_unless_allowlisted(e: &Env, from: &Address) { + let standing = lock_policy::standing(e, from); + if lock_policy::decide(Exit::RequestDeposit, standing) == Decision::Refuse { + panic_with_error!(e, VaultError::NotAllowlisted); + } +} + fn refuse_if_exceeds_cap(e: &Env, amount: i128) { let Some(cap) = state::deposit_cap(e) else { return; @@ -30,6 +38,7 @@ pub(crate) fn request(e: &Env, from: &Address, amount: i128) -> u64 { if amount <= 0 { panic_with_error!(e, VaultError::InvalidAmount); } + refuse_unless_allowlisted(e, from); refuse_if_exceeds_cap(e, amount); let epoch_id = state::current_epoch(e); diff --git a/contracts/async-vault/src/error.rs b/contracts/async-vault/src/error.rs index 8f3667de..ca0624f9 100644 --- a/contracts/async-vault/src/error.rs +++ b/contracts/async-vault/src/error.rs @@ -115,4 +115,7 @@ pub enum VaultError { /// As many changes as the timelock holds already wait. Apply or cancel one /// first. TooManyPendingChanges = 6063, + /// The depositor fails the share token's identity check, so the vault + /// could never deliver the shares. Refused before any cash moves. + NotAllowlisted = 6068, } diff --git a/contracts/async-vault/src/lib.rs b/contracts/async-vault/src/lib.rs index fb7ed11b..6290ae00 100644 --- a/contracts/async-vault/src/lib.rs +++ b/contracts/async-vault/src/lib.rs @@ -6,6 +6,7 @@ mod error; mod event; mod governance; mod keys; +mod lock_policy; mod pricing; mod redeem; mod roles; diff --git a/contracts/async-vault/src/lock_policy.rs b/contracts/async-vault/src/lock_policy.rs new file mode 100644 index 00000000..e0704e08 --- /dev/null +++ b/contracts/async-vault/src/lock_policy.rs @@ -0,0 +1,43 @@ +use soroban_sdk::{Address, Env}; +use stellar_tokens::rwa::{identity_verification::IdentityVerifierClient, RWATokenClient}; + +use crate::keys::DataKey; +use crate::state; + +/// Where a holder stands with the share token. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum Standing { + Listed, + Delisted, +} + +/// The holder's standing, asked of the identity verifier the share token +/// itself uses, so the vault and the token never disagree. +pub(crate) fn standing(e: &Env, holder: &Address) -> Standing { + let token = state::get_addr(e, &DataKey::ShareToken); + let verifier = RWATokenClient::new(e, &token).identity_verifier(); + match IdentityVerifierClient::new(e, &verifier).try_verify_identity(holder) { + Ok(Ok(())) => Standing::Listed, + _ => Standing::Delisted, + } +} + +/// The vault's entrypoints that move a holder's value. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum Exit { + RequestDeposit, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum Decision { + Proceed, + Refuse, +} + +/// What `exit` does for a holder in `standing`. No storage, no calls. +pub(crate) fn decide(exit: Exit, standing: Standing) -> Decision { + match (exit, standing) { + (Exit::RequestDeposit, Standing::Listed) => Decision::Proceed, + (Exit::RequestDeposit, Standing::Delisted) => Decision::Refuse, + } +} diff --git a/contracts/async-vault/src/test/compliance.rs b/contracts/async-vault/src/test/compliance.rs index 3ed1148d..16dd192a 100644 --- a/contracts/async-vault/src/test/compliance.rs +++ b/contracts/async-vault/src/test/compliance.rs @@ -1,26 +1,54 @@ +use soroban_sdk::{contract, contractimpl}; +use stellar_tokens::rwa::compliance::ComplianceHook; + use super::*; #[test] -fn a_non_allowlisted_investor_cannot_claim_shares() { +fn an_investor_who_was_never_allowlisted_is_refused_before_any_cash_moves() { let f = setup(); let stranger = f.unverified_investor(1_000); - f.vault.request_deposit(&stranger, &400); - f.fulfill_epoch(wad(2)); + refused( + f.vault.try_request_deposit(&stranger, &400), + VaultError::NotAllowlisted, + ); - assert!(f.vault.try_claim_deposit(&stranger, &1).is_err()); - assert_eq!(f.shares(&stranger), 0); - assert!(!f.vault.get_deposit_request(&1, &stranger).unwrap().claimed); - assert_eq!(f.balance(&f.vault.address), 400); + assert_eq!(f.balance(&stranger), 1_000); + assert_eq!(f.balance(&f.vault.address), 0); + assert_eq!(f.vault.cancellable_escrow(), 0); + assert_eq!(f.vault.get_epoch(&1).unwrap().total_deposited, 0); + assert_eq!(f.vault.get_deposit_request(&1, &stranger), None); } #[test] -fn allowlisting_after_the_fact_lets_the_claim_through() { +fn a_delisted_investor_cannot_request_a_deposit() { let f = setup(); - let investor = f.unverified_investor(1_000); + let investor = f.investor(1_000); + f.identity.allow(&investor, &false, &f.compliance); + refused( + f.vault.try_request_deposit(&investor, &400), + VaultError::NotAllowlisted, + ); + assert_eq!(f.balance(&investor), 1_000); +} + +#[test] +fn an_allowlisted_investor_still_requests_a_deposit() { + let f = setup(); + let investor = f.investor(1_000); + + assert_eq!(f.vault.request_deposit(&investor, &400), 1); + assert_eq!(f.balance(&f.vault.address), 400); +} + +#[test] +fn relisting_after_pricing_lets_the_claim_through() { + let f = setup(); + let investor = f.investor(1_000); f.vault.request_deposit(&investor, &400); f.fulfill_epoch(wad(2)); + f.identity.allow(&investor, &false, &f.compliance); assert!(f.vault.try_claim_deposit(&investor, &1).is_err()); f.identity.allow(&investor, &true, &f.compliance); @@ -29,6 +57,81 @@ fn allowlisting_after_the_fact_lets_the_claim_through() { assert_eq!(f.shares(&investor), 200); } +/// Today's behaviour for a holder delisted after their deposit was priced: +/// relisting is the only way out. The cash joined the reserve at pricing, so +/// her claim is to shares, and the wind-down holds her part in `owed` where +/// nobody can collect it. +#[test] +fn a_holder_delisted_after_pricing_can_only_leave_by_being_relisted() { + let f = setup(); + let investor = f.investor(1_000); + let epoch = f.vault.request_deposit(&investor, &400); + f.fulfill_epoch(wad(2)); + assert_eq!(f.vault.cancellable_escrow(), 0); + assert_eq!(f.vault.pending_mint_shares(), 200); + + f.identity.allow(&investor, &false, &f.compliance); + + assert_eq!( + f.vault.try_claim_deposit(&investor, &epoch).err(), + Some(Ok(soroban_sdk::Error::from_contract_error(304))) + ); + refused( + f.vault.try_cancel_deposit(&investor, &epoch), + VaultError::AlreadyPriced, + ); + + f.vault.propose_wind_down(&f.admin); + f.vault.activate_wind_down(); + assert_eq!(f.vault.finalize_wind_down_round(), 400); + refused( + f.vault.try_claim_wind_down(&investor), + VaultError::NoEntitlement, + ); + assert_eq!(f.vault.wind_down_owed(), 400); + + f.identity.allow(&investor, &true, &f.compliance); + assert_eq!(f.vault.claim_deposit(&investor, &epoch), 200); + assert_eq!(f.vault.claim_wind_down(&investor), 400); + assert_eq!(f.vault.wind_down_owed(), 0); +} + +#[contract] +struct RefusesEveryMint; + +#[contractimpl] +impl RefusesEveryMint { + pub fn can_create(_e: Env, _to: Address, _amount: i128, _token: Address) -> bool { + false + } +} + +/// A compliance module can refuse the mint of a priced deposit even for an +/// allowlisted holder. The kit cannot stop it; it is a trust assumption. +#[test] +fn a_compliance_module_that_refuses_the_mint_blocks_a_priced_claim() { + let f = setup(); + let investor = f.investor(1_000); + let epoch = f.vault.request_deposit(&investor, &400); + f.fulfill_epoch(wad(2)); + + let module = f.e.register(RefusesEveryMint, ()); + ComplianceClient::new(&f.e, &f.share.compliance()).add_module_to( + &ComplianceHook::CanCreate, + &module, + &f.admin, + ); + + assert_eq!( + f.vault.try_claim_deposit(&investor, &epoch).err(), + Some(Ok(soroban_sdk::Error::from_contract_error(306))) + ); + refused( + f.vault.try_cancel_deposit(&investor, &epoch), + VaultError::AlreadyPriced, + ); +} + #[test] fn a_delisted_holder_can_still_queue_an_exit() { let f = setup(); diff --git a/contracts/async-vault/src/test/lock_policy.rs b/contracts/async-vault/src/test/lock_policy.rs new file mode 100644 index 00000000..a323d474 --- /dev/null +++ b/contracts/async-vault/src/test/lock_policy.rs @@ -0,0 +1,52 @@ +use super::*; +use crate::lock_policy::{self, Decision, Exit, Standing}; + +#[test] +fn a_listed_holder_may_request_a_deposit() { + assert_eq!( + lock_policy::decide(Exit::RequestDeposit, Standing::Listed), + Decision::Proceed + ); +} + +#[test] +fn a_delisted_holder_may_not_request_a_deposit() { + assert_eq!( + lock_policy::decide(Exit::RequestDeposit, Standing::Delisted), + Decision::Refuse + ); +} + +#[test] +fn standing_is_listed_for_an_allowlisted_holder() { + let f = setup(); + let holder = f.investor(0); + + assert_eq!( + f.e.as_contract(&f.vault.address, || lock_policy::standing(&f.e, &holder)), + Standing::Listed + ); +} + +#[test] +fn standing_is_delisted_for_an_address_that_was_never_allowlisted() { + let f = setup(); + let stranger = f.unverified_investor(0); + + assert_eq!( + f.e.as_contract(&f.vault.address, || lock_policy::standing(&f.e, &stranger)), + Standing::Delisted + ); +} + +#[test] +fn standing_is_delisted_for_a_holder_listed_then_removed() { + let f = setup(); + let holder = f.investor(0); + f.identity.allow(&holder, &false, &f.compliance); + + assert_eq!( + f.e.as_contract(&f.vault.address, || lock_policy::standing(&f.e, &holder)), + Standing::Delisted + ); +} diff --git a/contracts/async-vault/src/test/mod.rs b/contracts/async-vault/src/test/mod.rs index 9a9b63bb..cac8a8d7 100644 --- a/contracts/async-vault/src/test/mod.rs +++ b/contracts/async-vault/src/test/mod.rs @@ -7,6 +7,7 @@ mod deposit; mod deposit_cap; mod epochs; mod events; +mod lock_policy; mod multi_epoch; mod notice; mod oracle_pricing; From 33b813492c53ac2f3b2366cc0f6ef2cb60943428 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:27:41 -0300 Subject: [PATCH 020/147] test(harness): check the allowlist refusal --- scripts/harness/guards.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/scripts/harness/guards.test.ts b/scripts/harness/guards.test.ts index 21038482..a51550f4 100644 --- a/scripts/harness/guards.test.ts +++ b/scripts/harness/guards.test.ts @@ -246,6 +246,20 @@ describe("invalid amounts and duplicate requests in an epoch", () => { }) }) +describe("who may subscribe", () => { + it("refuses a deposit request from an address that is not allowlisted", async () => { + const v = vault(c, outsider) + expectRefused( + await v.request_deposit({ + from: outsider.publicKey(), + amount: usdc(10), + }), + v, + "NotAllowlisted", + ) + }) +}) + const firstPrice = price(2) const secondPrice = (price(2) * 105n) / 100n let epochA = 0n From cf4e38547f015381e714659f696aa4cbcb870bc7 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:27:48 -0300 Subject: [PATCH 021/147] docs: say where a priced deposit's cash is --- AGENTS.md | 6 +++--- docs/COMPLIANCE_MATRIX.md | 25 +++++++++++++++---------- docs/TESTING.md | 2 +- docs/strata-product-and-architecture.md | 2 +- 4 files changed, 20 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cf92bdfd..79d465b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,9 +20,9 @@ result. Three states per side, none skipped: **pending, priced, claimed**. What the contracts enforce: -1. **Entry is gated** by a post-KYC allowlist, checked on the receiver of a - subscription (enforced on share claim in contracts today; request-time - check is targeted for a future milestone) and on every share transfer. +1. **Only allowlisted addresses enter.** The post-KYC allowlist is checked + when a subscription is requested, again when its shares are minted, and on + every share transfer. 2. **A covered claim always pays.** Once priced and covered, a cash claim cannot be blocked by a pause, a stale valuation, or the investor losing their allowlist place. Priced claims are never re-priced and never identity-gated; diff --git a/docs/COMPLIANCE_MATRIX.md b/docs/COMPLIANCE_MATRIX.md index ebf20b04..5730423a 100644 --- a/docs/COMPLIANCE_MATRIX.md +++ b/docs/COMPLIANCE_MATRIX.md @@ -29,7 +29,7 @@ Every investor account in the vault ecosystem exists in one of three primary com | Operation | Allowlisted | De-listed (Non-Frozen) | Frozen (Address or Partial) | Contract Mechanics & Regulatory Rationale | |---|---|---|---|---| -| **`request_deposit`** | ✅ Allowed | ⚠️ Allowed by SAC | ⚠️ Allowed by SAC | SAC USDC transfer moves funds into cancellable escrow. The vault does not check `IdentityVerifier` at subscription time to avoid coupling to external registry gas or third-party downtime. | +| **`request_deposit`** | ✅ Allowed | ❌ **Refused** | ⚠️ Allowed while still allowlisted | The vault asks the share token's identity verifier before any cash moves and refuses an address the token would not let hold shares (`NotAllowlisted`). Freezes are not checked at this step. | | **`cancel_deposit`** | ✅ Allowed | ✅ **Allowed** | ✅ **Allowed** | **Escape Hatch**: Calls `refund_deposit`. Escrowed settlement asset (USDC) is refunded directly to `controller`. Cash refunds bypass share token compliance checks. | | **`claim_deposit`** | ✅ Allowed | ❌ **Refused** | ⚠️ Minted if allowlisted | `RWATokenClient::mint` calls `verify_identity` and `can_create`, and does not check freezes. A de-listed receiver is refused with `IdentityVerificationFailed`. A frozen receiver still on the allowlist is minted, and cannot transfer those shares while frozen. | | **`request_redeem`** | ✅ Allowed | ✅ **Allowed** | ⚠️ Allowed, not guaranteed | **Exit Right**: the vault escrows the shares with `RWATokenClient::forced_transfer`, which checks neither the allowlist nor freezes, so a de-listed holder can start an exit. It also releases as much of a partial freeze as the escrow needs. | @@ -57,13 +57,15 @@ If an investor deposits into an open epoch but is de-listed (or KYC expires) bef - `refund_deposit` transfers the escrowed USDC back to the investor. - Because no shares were minted, returning cash restores the investor's funds without violating share token compliance rules. -### 3.3 De-Listing Between Deposit Request and Claim -If an investor deposits while allowlisted, but is de-listed after the epoch is closed and priced: -- **`claim_deposit` is refused**: The vault calls `RWATokenClient::mint`, which evaluates `can_create`. Because the receiver is no longer allowlisted, `verify_identity` fails with `IdentityVerificationFailed`. -- **`cancel_deposit` is blocked**: The epoch is already `Fulfilled`. As an anti-arbitrage safeguard, once an epoch has taken a valuation, cancellation is permanently closed (`VaultError::AlreadyPriced`). -- **Resolution**: The investor's settlement capital remains safely in vault escrow. The investor can either: - 1. Complete KYC refresh, upon which compliance re-allowlists the account and `claim_deposit` succeeds; or - 2. Compliance / governance resolves the claim out-of-band. +### 3.3 De-Listing Between Pricing and Claim +If an investor deposits while allowlisted but is de-listed after the epoch is priced: +- **The cash is no longer escrow.** Pricing moved it out of the cancellable bucket into the reserve, where it backs the shares the epoch promised. The investor's claim is to those shares, counted in `pending_mint_shares`. +- **`claim_deposit` is refused**: the share token calls `verify_identity` on the receiver when it mints, and fails with `IdentityVerificationFailed`. +- **`cancel_deposit` is refused** with `AlreadyPriced`: once an epoch has a price, cancellation is closed. +- **In a wind-down** her shares are counted in the supply snapshot, so her part of each round sits in `owed`, and `claim_wind_down` pays only shares the holder holds. Nobody can collect it. +- **Resolution today**: relisting. Once compliance re-allowlists the address, `claim_deposit` mints the shares and the wind-down pays them (`a_holder_delisted_after_pricing_can_only_leave_by_being_relisted`). + +An address that was never allowlisted cannot reach this state: `request_deposit` refuses it before any cash moves. ### 3.4 Redemption Cancellation Refusal When an investor requests redemption, their shares are transferred to the vault. @@ -95,8 +97,11 @@ The kit's compliance contract registers no modules, so `can_transfer` and `can_c The behavior specified in this matrix is verified by on-chain automated test suites in [`contracts/async-vault/src/test/compliance.rs`](../contracts/async-vault/src/test/compliance.rs): -* `a_non_allowlisted_investor_cannot_claim_shares`: Verifies unallowlisted caller cannot mint shares on claim. -* `allowlisting_after_the_fact_lets_the_claim_through`: Verifies KYC restoration unblocks claim. +* `an_investor_who_was_never_allowlisted_is_refused_before_any_cash_moves`: Verifies a non-allowlisted depositor is refused and nothing moves. +* `a_delisted_investor_cannot_request_a_deposit`: Verifies a de-listed depositor is refused at request time. +* `relisting_after_pricing_lets_the_claim_through`: Verifies KYC restoration unblocks a priced claim. +* `a_holder_delisted_after_pricing_can_only_leave_by_being_relisted`: Records the open case: no claim, no cancel, the wind-down holds her part. +* `a_compliance_module_that_refuses_the_mint_blocks_a_priced_claim`: Records that a compliance module can block a priced claim (a trust assumption). * `a_delisted_holder_can_still_queue_an_exit`: Verifies de-listed holders can queue redemption via forced transfer. * `a_delisted_holder_can_still_claim_their_exit`: Verifies de-listed holders always receive cash payout. * `a_delisted_investor_can_cancel_deposit_and_receive_asset`: Verifies de-listed depositors can cancel and receive cash back. diff --git a/docs/TESTING.md b/docs/TESTING.md index 082da65c..efb3799d 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -93,6 +93,6 @@ contract and oracle, plus a Stellar Asset Contract for the deposit asset. | --- | --- | --- | | Deposit asset (Stellar Asset Contract) moves in and out | [`contracts/async-vault/src/test/deposit.rs`](../contracts/async-vault/src/test/deposit.rs), [`contracts/async-vault/src/test/treasury.rs`](../contracts/async-vault/src/test/treasury.rs) | `request_deposit_updates_epoch_and_user_state`, `capital_makes_the_round_trip_to_the_custodian` | | The vault mints and burns shares through its role on the share token | [`contracts/async-vault/src/test/constructor.rs`](../contracts/async-vault/src/test/constructor.rs), [`contracts/async-vault/src/test/redeem.rs`](../contracts/async-vault/src/test/redeem.rs) | `the_vault_holds_the_manager_role_on_the_share_token`, `claim_redeem_pays_assets_and_burns_the_shares` | -| The share token checks the identity verifier 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) | `a_non_allowlisted_investor_cannot_claim_shares`, `a_frozen_or_delisted_holder_cannot_transfer_shares`, `manager_mints_to_allowlisted_account` | +| 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_proposal_replaces_the_code`, `a_migration_refuses_a_second_run` | diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index f5ad2473..f18762f8 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -310,7 +310,7 @@ batch boundary, though not the price it receives. #### Subscription -- Request: moves the deposit asset into escrow; at most one active request per controller. (Target: verifying the receiver is allowlisted at request time is targeted for a future milestone; in current contracts the allowlist is verified on share mint at claim time via SEP-57 hooks). +- Request: moves the deposit asset into escrow; at most one active request per controller. The vault first asks the share token's identity verifier and refuses an address that could not receive the shares, before any cash moves; the token checks again when it mints at claim time. - Pricing: the escrow leaves the cancellable bucket, the share quantity is set at the epoch's price, and the economic supply liability is recorded. To avoid unbounded loops that exceed Soroban transaction resource limits, physical share minting occurs lazily when each investor executes `claim_deposit`. - Cancellation: atomic, and returns the escrowed asset in full. Open while the epoch is open. Once sealed, cancellation remains open only until the epoch becomes priceable (the notice period elapses and a fresh attestation is available) or is fulfilled, preventing arbitrage against known prices while preserving an escape hatch for stalled epochs. Cancellation also remains open during an active wind-down, allowing pending depositors to recover their escrowed assets when pricing is closed. - Share claim: verifies the receiver and delivers the shares via `mint`. If verification fails, the position remains in pending mint and exits through administrative resolution or once allowlisted. No nominal refund exists after pricing. From 413625137faa1f4d069675f2cf613a13b803d184 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:54:05 -0300 Subject: [PATCH 022/147] feat(vault): reconcile an epoch once it settles --- contracts/async-vault/src/deposit.rs | 17 +- contracts/async-vault/src/epoch.rs | 10 +- contracts/async-vault/src/error.rs | 3 + contracts/async-vault/src/event.rs | 8 + contracts/async-vault/src/keys.rs | 1 + contracts/async-vault/src/lib.rs | 13 +- contracts/async-vault/src/redeem.rs | 11 +- contracts/async-vault/src/settlement.rs | 115 +++++++++ contracts/async-vault/src/state.rs | 9 + contracts/async-vault/src/test/events.rs | 26 +- contracts/async-vault/src/test/mod.rs | 1 + contracts/async-vault/src/test/multi_epoch.rs | 16 +- contracts/async-vault/src/test/settlement.rs | 236 ++++++++++++++++++ contracts/async-vault/src/wind_down.rs | 6 +- 14 files changed, 443 insertions(+), 29 deletions(-) create mode 100644 contracts/async-vault/src/settlement.rs create mode 100644 contracts/async-vault/src/test/settlement.rs diff --git a/contracts/async-vault/src/deposit.rs b/contracts/async-vault/src/deposit.rs index 109ac15b..64a3d5bb 100644 --- a/contracts/async-vault/src/deposit.rs +++ b/contracts/async-vault/src/deposit.rs @@ -7,6 +7,7 @@ use crate::event::{DepositCancelled, DepositClaimed, DepositRequested}; use crate::keys::DataKey; use crate::lock_policy::{self, Decision, Exit}; use crate::pricing::{Pricing, PricingScheme}; +use crate::settlement; use crate::state::{self, DepositRequest, EpochStatus}; use crate::treasury; use crate::wind_down; @@ -66,6 +67,7 @@ pub(crate) fn request(e: &Env, from: &Address, amount: i128) -> u64 { ); state::set_epoch(e, epoch_id, &epoch); state::set_cancellable_escrow(e, state::cancellable_escrow(e) + amount); + settlement::on_deposit_requested(e, epoch_id); let asset = state::get_addr(e, &DataKey::Asset); TokenClient::new(e, &asset).transfer(from, e.current_contract_address(), &amount); @@ -107,12 +109,14 @@ pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 { if treasury::free_reserve(e) < request.amount { panic_with_error!(e, VaultError::ClaimNotCovered); } - return refund_deposit(e, caller, epoch_id, request.amount); + refund_deposit(e, caller, epoch_id, request.amount); + settlement::on_deposit_claimed(e, epoch_id, 0); + return 0; } request.claimed = true; state::set_deposit_request(e, epoch_id, caller, &request); - state::set_pending_mint_shares(e, state::pending_mint_shares(e).saturating_sub(shares)); + state::set_pending_mint_shares(e, state::reduce(e, state::pending_mint_shares(e), shares)); let share_token = state::get_addr(e, &DataKey::ShareToken); RWATokenClient::new(e, &share_token).mint(caller, &shares, &e.current_contract_address()); @@ -124,6 +128,7 @@ pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 { shares, } .publish(e); + settlement::on_deposit_claimed(e, epoch_id, shares); shares } @@ -133,7 +138,7 @@ pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 { fn refund_deposit(e: &Env, controller: &Address, epoch_id: u64, amount: i128) -> i128 { let mut epoch = state::get_epoch(e, epoch_id) .unwrap_or_else(|| panic_with_error!(e, VaultError::EpochNotFound)); - epoch.total_deposited -= amount; + epoch.total_deposited = state::reduce(e, epoch.total_deposited, amount); state::set_epoch(e, epoch_id, &epoch); state::remove_deposit_request(e, epoch_id, controller); @@ -171,7 +176,11 @@ pub(crate) fn cancel(e: &Env, from: &Address, epoch_id: u64) -> i128 { let request = state::get_deposit_request(e, epoch_id, from) .unwrap_or_else(|| panic_with_error!(e, VaultError::RequestNotFound)); - state::set_cancellable_escrow(e, state::cancellable_escrow(e) - request.amount); + state::set_cancellable_escrow( + e, + state::reduce(e, state::cancellable_escrow(e), request.amount), + ); + settlement::on_deposit_cancelled(e, epoch_id); refund_deposit(e, from, epoch_id, request.amount); request.amount } diff --git a/contracts/async-vault/src/epoch.rs b/contracts/async-vault/src/epoch.rs index ce915f3e..b675e025 100644 --- a/contracts/async-vault/src/epoch.rs +++ b/contracts/async-vault/src/epoch.rs @@ -5,6 +5,7 @@ use crate::error::VaultError; use crate::event::{EpochClosed, EpochFulfilled}; use crate::keys::DataKey; use crate::pricing::{Pricing, PricingScheme}; +use crate::settlement; use crate::state::{self, EpochInfo, EpochStatus}; use crate::timing::{FulfilmentTiming, StandardTiming}; use crate::wind_down; @@ -108,8 +109,9 @@ pub(crate) fn fulfill(e: &Env, epoch_id: u64) -> i128 { .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)); state::set_committed(e, committed); + let mut shares_owed = 0; if epoch.total_deposited > 0 { - let shares_owed = Pricing::deposit_shares(e, epoch.total_deposited, share_price) + shares_owed = Pricing::deposit_shares(e, epoch.total_deposited, share_price) .unwrap_or_else(|err| panic_with_error!(e, err)); let updated_pending_mint = state::pending_mint_shares(e) .checked_add(shares_owed) @@ -124,7 +126,10 @@ pub(crate) fn fulfill(e: &Env, epoch_id: u64) -> i128 { state::set_pending_burn_shares(e, updated_pending_burn); } - state::set_cancellable_escrow(e, state::cancellable_escrow(e) - epoch.total_deposited); + state::set_cancellable_escrow( + e, + state::reduce(e, state::cancellable_escrow(e), epoch.total_deposited), + ); epoch.status = EpochStatus::Fulfilled; epoch.share_price = share_price; @@ -136,6 +141,7 @@ pub(crate) fn fulfill(e: &Env, epoch_id: u64) -> i128 { total_deposited: epoch.total_deposited, } .publish(e); + settlement::on_priced(e, epoch_id, shares_owed, owed); share_price } diff --git a/contracts/async-vault/src/error.rs b/contracts/async-vault/src/error.rs index ca0624f9..7f84ef75 100644 --- a/contracts/async-vault/src/error.rs +++ b/contracts/async-vault/src/error.rs @@ -118,4 +118,7 @@ pub enum VaultError { /// The depositor fails the share token's identity check, so the vault /// could never deliver the shares. Refused before any cash moves. NotAllowlisted = 6068, + /// A running total would go below zero, which means the vault's books + /// disagree with themselves. Fails loudly instead of clamping to zero. + AccountingMismatch = 6070, } diff --git a/contracts/async-vault/src/event.rs b/contracts/async-vault/src/event.rs index d7bae7b1..3d76cbc5 100644 --- a/contracts/async-vault/src/event.rs +++ b/contracts/async-vault/src/event.rs @@ -149,6 +149,14 @@ pub struct WindDownClaimed { pub assets: i128, } +#[contractevent] +pub struct EpochReconciled { + #[topic] + pub epoch: u64, + pub share_dust: i128, + pub cash_dust: i128, +} + #[contractevent] pub struct DepositCapUpdated { pub old_cap: Option, diff --git a/contracts/async-vault/src/keys.rs b/contracts/async-vault/src/keys.rs index e35fed58..0f6d1151 100644 --- a/contracts/async-vault/src/keys.rs +++ b/contracts/async-vault/src/keys.rs @@ -24,4 +24,5 @@ pub(crate) enum DataKey { WindDownOwed, WindDownPosition(Address), DepositCap, + Settlement(u64), } diff --git a/contracts/async-vault/src/lib.rs b/contracts/async-vault/src/lib.rs index 6290ae00..7d3173ee 100644 --- a/contracts/async-vault/src/lib.rs +++ b/contracts/async-vault/src/lib.rs @@ -10,6 +10,7 @@ mod lock_policy; mod pricing; mod redeem; mod roles; +mod settlement; mod state; mod timing; mod treasury; @@ -31,13 +32,14 @@ use state::FIRST_EPOCH; pub use error::VaultError; pub use event::{ ChangeApplied, ChangeCancelled, ChangeProposed, CustodianSet, Deployed, DepositCapUpdated, - DepositClaimed, DepositRequested, EpochClosed, EpochFulfilled, Funded, NoticeSet, - RedeemClaimed, RedeemRequested, WindDownActivated, WindDownClaimed, WindDownDelaySet, - WindDownProposalCancelled, WindDownProposed, WindDownRoundFinalized, + DepositClaimed, DepositRequested, EpochClosed, EpochFulfilled, EpochReconciled, Funded, + NoticeSet, RedeemClaimed, RedeemRequested, WindDownActivated, WindDownClaimed, + WindDownDelaySet, WindDownProposalCancelled, WindDownProposed, WindDownRoundFinalized, }; pub use governance::{VaultChange, VaultPendingChange}; pub use pricing::{DirectUnitPricing, PricingScheme}; pub use roles::VaultRoles; +pub use settlement::EpochSettlement; pub use state::{DepositRequest, EpochInfo, EpochStatus, RedeemRequest}; pub use upgrade::{MAX_UPGRADE_DELAY, MIN_UPGRADE_DELAY}; pub use wind_down::{WindDownInfo, WindDownPosition, WindDownStatus, MAX_WIND_DOWN_DELAY}; @@ -323,6 +325,11 @@ impl AsyncVault { state::get_epoch(e, epoch_id) } + /// What pricing promised the epoch and what has been paid against it. + pub fn get_epoch_settlement(e: &Env, epoch_id: u64) -> Option { + settlement::get(e, epoch_id) + } + pub fn get_deposit_request( e: &Env, epoch_id: u64, diff --git a/contracts/async-vault/src/redeem.rs b/contracts/async-vault/src/redeem.rs index c8b51d14..d2c19699 100644 --- a/contracts/async-vault/src/redeem.rs +++ b/contracts/async-vault/src/redeem.rs @@ -6,6 +6,7 @@ use crate::error::VaultError; use crate::event::{RedeemCancelled, RedeemClaimed, RedeemRequested}; use crate::keys::DataKey; use crate::pricing::{Pricing, PricingScheme}; +use crate::settlement; use crate::state::{self, EpochStatus, RedeemRequest}; use crate::treasury; use crate::wind_down; @@ -42,6 +43,7 @@ pub(crate) fn request(e: &Env, from: &Address, shares: i128) -> u64 { }, ); state::set_epoch(e, epoch_id, &epoch); + settlement::on_redeem_requested(e, epoch_id); let vault = e.current_contract_address(); let share_token = state::get_addr(e, &DataKey::ShareToken); @@ -79,11 +81,12 @@ pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 { state::set_pending_burn_shares( e, - state::pending_burn_shares(e).saturating_sub(request.shares), + state::reduce(e, state::pending_burn_shares(e), request.shares), ); if assets == 0 { return_shares(e, caller, epoch_id, request.shares); + settlement::on_redeem_claimed(e, epoch_id, 0); return 0; } @@ -98,7 +101,7 @@ pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 { request.claimed = true; state::set_redeem_request(e, epoch_id, caller, &request); - state::set_committed(e, state::committed(e).saturating_sub(assets)); + state::set_committed(e, state::reduce(e, state::committed(e), assets)); TokenClient::new(e, &asset).transfer(&vault, caller, &assets); RWATokenClient::new(e, &state::get_addr(e, &DataKey::ShareToken)).burn( @@ -114,6 +117,7 @@ pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 { assets, } .publish(e); + settlement::on_redeem_claimed(e, epoch_id, assets); assets } @@ -125,7 +129,7 @@ pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 { fn return_shares(e: &Env, controller: &Address, epoch_id: u64, shares: i128) { let mut epoch = state::get_epoch(e, epoch_id) .unwrap_or_else(|| panic_with_error!(e, VaultError::EpochNotFound)); - epoch.total_shares_redeeming -= shares; + epoch.total_shares_redeeming = state::reduce(e, epoch.total_shares_redeeming, shares); state::set_epoch(e, epoch_id, &epoch); state::remove_redeem_request(e, epoch_id, controller); @@ -163,5 +167,6 @@ pub(crate) fn cancel(e: &Env, from: &Address, epoch_id: u64) -> i128 { .unwrap_or_else(|| panic_with_error!(e, VaultError::RequestNotFound)); return_shares(e, from, epoch_id, request.shares); + settlement::on_redeem_cancelled(e, epoch_id); request.shares } diff --git a/contracts/async-vault/src/settlement.rs b/contracts/async-vault/src/settlement.rs new file mode 100644 index 00000000..6baeb408 --- /dev/null +++ b/contracts/async-vault/src/settlement.rs @@ -0,0 +1,115 @@ +use soroban_sdk::{contracttype, panic_with_error, Env}; + +use crate::error::VaultError; +use crate::event::EpochReconciled; +use crate::keys::DataKey; +use crate::state::{self, EpochStatus}; + +/// What pricing promised an epoch's requests and what has been paid against +/// it. Every claim floors, so the two drift apart by a few units; once the +/// last request settles the remainder leaves `pending_mint_shares` and +/// `committed`, and the cash stays in the reserve as fund money. +#[contracttype] +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct EpochSettlement { + pub deposits_open: u32, + pub redeems_open: u32, + pub shares_promised: i128, + pub shares_settled: i128, + pub assets_promised: i128, + pub assets_settled: i128, + pub reconciled: bool, +} + +pub(crate) fn get(e: &Env, epoch: u64) -> Option { + storage::get_persistent(e, &DataKey::Settlement(epoch)) +} + +fn load(e: &Env, epoch: u64) -> EpochSettlement { + get(e, epoch).unwrap_or_default() +} + +fn save(e: &Env, epoch: u64, s: &EpochSettlement) { + storage::set_persistent(e, &DataKey::Settlement(epoch), s); +} + +fn close_one(e: &Env, open: u32) -> u32 { + open.checked_sub(1) + .unwrap_or_else(|| panic_with_error!(e, VaultError::AccountingMismatch)) +} + +pub(crate) fn on_deposit_requested(e: &Env, epoch: u64) { + let mut s = load(e, epoch); + s.deposits_open += 1; + save(e, epoch, &s); +} + +pub(crate) fn on_deposit_cancelled(e: &Env, epoch: u64) { + let mut s = load(e, epoch); + s.deposits_open = close_one(e, s.deposits_open); + save(e, epoch, &s); +} + +pub(crate) fn on_redeem_requested(e: &Env, epoch: u64) { + let mut s = load(e, epoch); + s.redeems_open += 1; + save(e, epoch, &s); +} + +pub(crate) fn on_redeem_cancelled(e: &Env, epoch: u64) { + let mut s = load(e, epoch); + s.redeems_open = close_one(e, s.redeems_open); + save(e, epoch, &s); +} + +/// Called once the epoch is stored as fulfilled. +pub(crate) fn on_priced(e: &Env, epoch: u64, shares_promised: i128, assets_promised: i128) { + let mut s = load(e, epoch); + s.shares_promised = shares_promised; + s.assets_promised = assets_promised; + reconcile_if_settled(e, epoch, &mut s); + save(e, epoch, &s); +} + +/// `shares` is zero for a claim that floored to nothing and was refunded. +pub(crate) fn on_deposit_claimed(e: &Env, epoch: u64, shares: i128) { + let mut s = load(e, epoch); + s.deposits_open = close_one(e, s.deposits_open); + s.shares_settled += shares; + reconcile_if_settled(e, epoch, &mut s); + save(e, epoch, &s); +} + +/// `assets` is zero for a claim that paid nothing and returned the shares. +pub(crate) fn on_redeem_claimed(e: &Env, epoch: u64, assets: i128) { + let mut s = load(e, epoch); + s.redeems_open = close_one(e, s.redeems_open); + s.assets_settled += assets; + reconcile_if_settled(e, epoch, &mut s); + save(e, epoch, &s); +} + +fn reconcile_if_settled(e: &Env, epoch: u64, s: &mut EpochSettlement) { + if s.reconciled || s.deposits_open > 0 || s.redeems_open > 0 { + return; + } + let priced = state::get_epoch(e, epoch).is_some_and(|ep| ep.status == EpochStatus::Fulfilled); + if !priced { + return; + } + let share_dust = state::reduce(e, s.shares_promised, s.shares_settled); + let cash_dust = state::reduce(e, s.assets_promised, s.assets_settled); + state::set_pending_mint_shares( + e, + state::reduce(e, state::pending_mint_shares(e), share_dust), + ); + state::set_committed(e, state::reduce(e, state::committed(e), cash_dust)); + s.reconciled = true; + + EpochReconciled { + epoch, + share_dust, + cash_dust, + } + .publish(e); +} diff --git a/contracts/async-vault/src/state.rs b/contracts/async-vault/src/state.rs index b201dce1..64a1419f 100644 --- a/contracts/async-vault/src/state.rs +++ b/contracts/async-vault/src/state.rs @@ -5,6 +5,15 @@ use crate::keys::DataKey; pub(crate) const FIRST_EPOCH: u64 = 1; +/// Takes `amount` off a running total that can never be negative. A result +/// below zero means the books disagree, so it fails loudly. +pub(crate) fn reduce(e: &Env, total: i128, amount: i128) -> i128 { + match total.checked_sub(amount) { + Some(left) if left >= 0 => left, + _ => panic_with_error!(e, VaultError::AccountingMismatch), + } +} + #[contracttype] #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum EpochStatus { diff --git a/contracts/async-vault/src/test/events.rs b/contracts/async-vault/src/test/events.rs index 38caa3bf..39fdb963 100644 --- a/contracts/async-vault/src/test/events.rs +++ b/contracts/async-vault/src/test/events.rs @@ -1,7 +1,7 @@ use soroban_sdk::{testutils::Events as _, Event as _}; use super::*; -use crate::{DepositClaimed, DepositRequested, EpochClosed, EpochFulfilled}; +use crate::{DepositClaimed, DepositRequested, EpochClosed, EpochFulfilled, EpochReconciled}; #[test] fn requesting_a_deposit_publishes_the_request() { @@ -62,7 +62,7 @@ fn pricing_an_epoch_publishes_the_share_price() { } #[test] -fn claiming_a_deposit_publishes_the_shares_paid() { +fn claiming_the_last_deposit_publishes_the_shares_paid_and_the_settlement() { let f = setup(); let user = f.investor(1_000); f.vault.request_deposit(&user, &400); @@ -72,13 +72,21 @@ fn claiming_a_deposit_publishes_the_shares_paid() { assert_eq!( f.e.events().all().filter_by_contract(&f.vault.address), - [DepositClaimed { - controller: user, - epoch: 1, - amount: 400, - shares: 200, - } - .to_xdr(&f.e, &f.vault.address)] + [ + DepositClaimed { + controller: user, + epoch: 1, + amount: 400, + shares: 200, + } + .to_xdr(&f.e, &f.vault.address), + EpochReconciled { + epoch: 1, + share_dust: 0, + cash_dust: 0, + } + .to_xdr(&f.e, &f.vault.address), + ] ); } diff --git a/contracts/async-vault/src/test/mod.rs b/contracts/async-vault/src/test/mod.rs index cac8a8d7..38d48534 100644 --- a/contracts/async-vault/src/test/mod.rs +++ b/contracts/async-vault/src/test/mod.rs @@ -13,6 +13,7 @@ mod notice; mod oracle_pricing; mod pricing; mod redeem; +mod settlement; mod supply; mod timing; mod treasury; diff --git a/contracts/async-vault/src/test/multi_epoch.rs b/contracts/async-vault/src/test/multi_epoch.rs index 3bbe2c0c..a82c70ff 100644 --- a/contracts/async-vault/src/test/multi_epoch.rs +++ b/contracts/async-vault/src/test/multi_epoch.rs @@ -46,16 +46,16 @@ fn a_younger_epoch_can_be_priced_first() { ); } -/// One aggregate floor is credited at pricing, one floor per claimant is debited. -/// The sum of floors is at most the floor of the sum, so a residue is left behind -/// that no path releases. Three odd share counts lose half a unit each; the odd -/// aggregate loses only half. +/// Each claim floors, so the epoch pays out less than its total credited. Once +/// the last claim settles, the shortfall leaves `committed` and stays in the +/// reserve: nothing is owed for it, and nobody is paid it. #[test] -fn the_rounding_residue_is_never_released() { +fn the_rounding_residue_stays_in_the_reserve_once_the_epoch_settles() { let f = setup(); let a = f.holder(101); let b = f.holder(103); let c = f.holder(105); + let held = f.balance(&f.vault.address); f.vault.request_redeem(&a, &101); f.vault.request_redeem(&b, &103); @@ -72,8 +72,10 @@ fn the_rounding_residue_is_never_released() { f.vault.claim_redeem(&b, &epoch); // floor(103 * 1.5) = 154 f.vault.claim_redeem(&c, &epoch); // floor(105 * 1.5) = 157 - // 151 + 154 + 157 = 462, one short of the 463 credited. - assert_eq!(f.vault.committed(), 1); + // 151 + 154 + 157 = 462: the unit left over is no longer owed. + assert_eq!(f.vault.committed(), 0); + assert_eq!(f.balance(&f.vault.address), held - 462); + assert_eq!(f.vault.free_reserve(), held - 462); } /// One controller may hold a live redemption in two epochs at once, because the diff --git a/contracts/async-vault/src/test/settlement.rs b/contracts/async-vault/src/test/settlement.rs new file mode 100644 index 00000000..f445598a --- /dev/null +++ b/contracts/async-vault/src/test/settlement.rs @@ -0,0 +1,236 @@ +use proptest::prelude::*; +use soroban_sdk::{testutils::Events as _, Event as _}; + +use super::*; +use crate::{EpochFulfilled, EpochReconciled, RedeemClaimed}; + +/// Three assets a share: deposits of 4 and 5 promise floor(9 / 3) = 3 shares, +/// but each claim floors to 1. +#[test] +fn the_last_deposit_claim_clears_the_share_remainder() { + let f = setup(); + let a = f.investor(4); + let b = f.investor(5); + f.vault.request_deposit(&a, &4); + f.vault.request_deposit(&b, &5); + let epoch = f.fulfill_epoch(wad(3)); + assert_eq!(f.vault.pending_mint_shares(), 3); + + assert_eq!(f.vault.claim_deposit(&a, &epoch), 1); + assert_eq!(f.vault.pending_mint_shares(), 2); + assert_eq!(f.vault.claim_deposit(&b, &epoch), 1); + + assert_eq!(f.vault.pending_mint_shares(), 0); + let s = f.vault.get_epoch_settlement(&epoch).unwrap(); + assert!(s.reconciled); + assert_eq!((s.shares_promised, s.shares_settled), (3, 2)); +} + +/// At 1.5 assets a share, 101 and 103 shares promise floor(306) = 306 assets +/// but pay floor(151.5) + floor(154.5) = 305. +#[test] +fn the_last_redemption_claim_clears_the_cash_remainder_and_says_so_once() { + let f = setup(); + let a = f.holder(101); + let b = f.holder(103); + f.vault.request_redeem(&a, &101); + f.vault.request_redeem(&b, &103); + let epoch = f.fulfill_epoch(wad(3) / 2); + assert_eq!(f.vault.committed(), 306); + + f.vault.claim_redeem(&a, &epoch); + assert!(!f.vault.get_epoch_settlement(&epoch).unwrap().reconciled); + assert_eq!(f.vault.committed(), 155); + + f.vault.claim_redeem(&b, &epoch); + + assert_eq!( + f.e.events().all().filter_by_contract(&f.vault.address), + [ + RedeemClaimed { + controller: b, + epoch, + shares: 103, + assets: 154, + } + .to_xdr(&f.e, &f.vault.address), + EpochReconciled { + epoch, + share_dust: 0, + cash_dust: 1, + } + .to_xdr(&f.e, &f.vault.address), + ] + ); + assert_eq!(f.vault.committed(), 0); +} + +#[test] +fn an_epoch_with_no_requests_settles_when_it_is_priced() { + let f = setup(); + let epoch = f.close_epoch(); + f.attest(wad(2)); + + f.vault.fulfill_epoch(&epoch); + + assert_eq!( + f.e.events().all().filter_by_contract(&f.vault.address), + [ + EpochFulfilled { + epoch, + share_price: wad(2), + total_deposited: 0, + } + .to_xdr(&f.e, &f.vault.address), + EpochReconciled { + epoch, + share_dust: 0, + cash_dust: 0, + } + .to_xdr(&f.e, &f.vault.address), + ] + ); + assert!(f.vault.get_epoch_settlement(&epoch).unwrap().reconciled); +} + +#[test] +fn a_cancelled_request_does_not_hold_the_epoch_open() { + let f = setup(); + let a = f.investor(400); + let epoch = f.vault.request_deposit(&a, &400); + f.vault.cancel_deposit(&a, &epoch); + + f.fulfill_epoch(wad(2)); + + let s = f.vault.get_epoch_settlement(&epoch).unwrap(); + assert_eq!(s.deposits_open, 0); + assert!(s.reconciled); +} + +/// At two assets a share, deposits of 1 and 3 promise floor(4 / 2) = 2 shares. +/// The first claim floors to nothing and is refunded; the second gets 1. +#[test] +fn a_deposit_refunded_at_claim_counts_as_settled() { + let f = setup(); + let a = f.investor(1); + let b = f.investor(3); + let epoch = f.vault.request_deposit(&a, &1); + f.vault.request_deposit(&b, &3); + f.fulfill_epoch(wad(2)); + + assert_eq!(f.vault.claim_deposit(&a, &epoch), 0); + assert_eq!(f.vault.claim_deposit(&b, &epoch), 1); + + assert_eq!(f.vault.pending_mint_shares(), 0); + let s = f.vault.get_epoch_settlement(&epoch).unwrap(); + assert_eq!((s.shares_promised, s.shares_settled), (2, 1)); + assert!(s.reconciled); +} + +#[test] +fn a_redemption_that_pays_nothing_counts_as_settled() { + let f = setup(); + let a = f.holder(1); + let epoch = f.vault.request_redeem(&a, &1); + f.fulfill_epoch(wad(1) / 2); + + assert_eq!(f.vault.claim_redeem(&a, &epoch), 0); + + assert_eq!(f.shares(&a), 1); + let s = f.vault.get_epoch_settlement(&epoch).unwrap(); + assert_eq!( + (s.assets_promised, s.assets_settled, s.redeems_open), + (0, 0, 0) + ); + assert!(s.reconciled); +} + +#[test] +#[should_panic(expected = "Error(Contract, #6070)")] +fn a_total_that_would_go_below_zero_fails_loudly() { + let f = setup(); + f.e.as_contract(&f.vault.address, || crate::state::reduce(&f.e, 1, 2)); +} + +/// A request that can never settle holds its epoch open, so the remainder +/// stays in the totals. Here the redemption pays nothing and the holder, since +/// delisted, cannot take the shares back. +#[test] +fn a_request_that_cannot_settle_keeps_its_epoch_open() { + let f = setup(); + let a = f.holder(1); + let b = f.holder(3); + let epoch = f.vault.request_redeem(&a, &1); + f.vault.request_redeem(&b, &3); + f.fulfill_epoch(wad(1) / 2); + f.identity.allow(&a, &false, &f.compliance); + + assert_eq!( + f.vault.try_claim_redeem(&a, &epoch).err(), + Some(Ok(soroban_sdk::Error::from_contract_error(304))) + ); + assert_eq!(f.vault.claim_redeem(&b, &epoch), 1); + + let s = f.vault.get_epoch_settlement(&epoch).unwrap(); + assert_eq!(s.redeems_open, 1); + assert!(!s.reconciled); + assert_eq!(f.vault.committed(), s.assets_promised - s.assets_settled); +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(24))] + + /// Any mix of deposits, cancellations, redemptions, refunds and + /// zero-asset claims, claimed in any order, leaves nothing behind once + /// every request settles. + #[test] + fn a_settled_epoch_leaves_no_remainder_in_the_totals( + deposits in proptest::collection::vec(1i128..5_000, 1..5), + cancels in proptest::collection::vec(any::(), 5), + redeems in proptest::collection::vec(1i128..500, 0..4), + price_tenths in 5i128..=20, + order in proptest::collection::vec(any::(), 8), + ) { + let f = setup(); + let holders: std::vec::Vec<(Address, i128)> = + redeems.iter().map(|shares| (f.holder(*shares), *shares)).collect(); + let mut investors = std::vec::Vec::new(); + for (i, amount) in deposits.iter().enumerate() { + let who = f.investor(*amount); + let epoch = f.vault.request_deposit(&who, amount); + if cancels[i] { + f.vault.cancel_deposit(&who, &epoch); + } else { + investors.push(who); + } + } + for (who, shares) in &holders { + f.vault.request_redeem(who, shares); + } + let epoch = f.fulfill_epoch(wad(1) * price_tenths / 10); + + let mut claims: std::vec::Vec<(u16, bool, &Address)> = investors + .iter() + .map(|who| (true, who)) + .chain(holders.iter().map(|(who, _)| (false, who))) + .enumerate() + .map(|(i, (is_deposit, who))| (order[i], is_deposit, who)) + .collect(); + claims.sort_by_key(|(key, _, _)| *key); + for (_, is_deposit, who) in claims { + if is_deposit { + f.vault.claim_deposit(who, &epoch); + } else { + f.vault.claim_redeem(who, &epoch); + } + } + + let s = f.vault.get_epoch_settlement(&epoch).unwrap(); + prop_assert!(s.reconciled); + prop_assert!(s.shares_settled <= s.shares_promised); + prop_assert!(s.assets_settled <= s.assets_promised); + prop_assert_eq!(f.vault.pending_mint_shares(), 0); + prop_assert_eq!(f.vault.committed(), 0); + prop_assert_eq!(f.vault.cancellable_escrow(), 0); + } +} diff --git a/contracts/async-vault/src/wind_down.rs b/contracts/async-vault/src/wind_down.rs index 028faf79..ca2aed10 100644 --- a/contracts/async-vault/src/wind_down.rs +++ b/contracts/async-vault/src/wind_down.rs @@ -279,7 +279,11 @@ pub(crate) fn claim(e: &Env, holder: &Address) -> i128 { } if assets > 0 { - storage::set_instance(e, &DataKey::WindDownOwed, &(owed(e) - assets)); + storage::set_instance( + e, + &DataKey::WindDownOwed, + &state::reduce(e, owed(e), assets), + ); let asset = state::get_addr(e, &DataKey::Asset); TokenClient::new(e, &asset).transfer(&vault, holder, &assets); } From e541600436c542351368061d7d76b07df4195cbe Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:56:52 -0300 Subject: [PATCH 023/147] test(vault): pin why wind-down dust stays owed --- contracts/async-vault/src/test/wind_down.rs | 45 +++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/contracts/async-vault/src/test/wind_down.rs b/contracts/async-vault/src/test/wind_down.rs index e141597d..ec14bc30 100644 --- a/contracts/async-vault/src/test/wind_down.rs +++ b/contracts/async-vault/src/test/wind_down.rs @@ -618,3 +618,48 @@ fn propose_wind_down_reports_active_when_active() { .unwrap(); assert_eq!(err, VaultError::WindDownActive.into()); } + +/// Each holder's payout floors, so `owed` can keep a unit per holder after +/// everyone has claimed. That unit is not free to release: a later round lets +/// each floor catch up and needs it. +#[test] +fn a_later_round_needs_the_units_an_earlier_round_left_in_owed() { + let f = setup(); + let a = f.holder(1); + let b = f.holder(1); + f.empty_reserve(); + f.vault.propose_wind_down(&f.admin); + f.vault.activate_wind_down(); + + f.vault.fund(&f.custodian, &1); + assert_eq!(f.vault.finalize_wind_down_round(), 1); + assert_eq!(f.vault.claim_wind_down(&a), 0); + assert_eq!(f.vault.claim_wind_down(&b), 0); + assert_eq!(f.vault.wind_down_owed(), 1); + + f.vault.fund(&f.custodian, &1); + assert_eq!(f.vault.finalize_wind_down_round(), 1); + assert_eq!(f.vault.claim_wind_down(&a), 1); + assert_eq!(f.vault.claim_wind_down(&b), 1); + + assert_eq!(f.vault.wind_down_owed(), 0); + assert_eq!(f.balance(&f.vault.address), 0); +} + +#[test] +fn after_every_holder_claims_owed_keeps_at_most_one_unit_per_holder() { + let f = setup(); + let holders = [f.holder(1), f.holder(2), f.holder(4)]; + f.empty_reserve(); + f.vault.propose_wind_down(&f.admin); + f.vault.activate_wind_down(); + f.vault.fund(&f.custodian, &5); + f.vault.finalize_wind_down_round(); + + for holder in &holders { + f.vault.claim_wind_down(holder); + } + + assert!(f.vault.wind_down_owed() <= holders.len() as i128); + assert!(f.balance(&f.vault.address) >= f.vault.wind_down_owed()); +} From a966d6d2ffa2bf5dbdc3f0ec43756884669f037f Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:01:33 -0300 Subject: [PATCH 024/147] docs: describe epoch settlement --- docs/TESTING.md | 3 ++- docs/kit-spec.md | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/TESTING.md b/docs/TESTING.md index efb3799d..893b3e4f 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -80,8 +80,9 @@ so time rules and TTL can be reached in one test. | Behaviour | Tests | Key test names | | --- | --- | --- | -| The vault publishes each step of a deposit with its amounts | [`contracts/async-vault/src/test/events.rs`](../contracts/async-vault/src/test/events.rs) | `requesting_a_deposit_publishes_the_request`, `closing_an_epoch_publishes_its_totals`, `pricing_an_epoch_publishes_the_share_price`, `claiming_a_deposit_publishes_the_shares_paid` | +| The vault publishes each step of a deposit with its amounts | [`contracts/async-vault/src/test/events.rs`](../contracts/async-vault/src/test/events.rs) | `requesting_a_deposit_publishes_the_request`, `closing_an_epoch_publishes_its_totals`, `pricing_an_epoch_publishes_the_share_price`, `claiming_the_last_deposit_publishes_the_shares_paid_and_the_settlement` | | A refused call publishes nothing | [`contracts/async-vault/src/test/events.rs`](../contracts/async-vault/src/test/events.rs) | `a_refused_call_publishes_nothing` | +| An epoch's rounding remainder is cleared once its last request settles, a settled epoch leaves nothing in the totals whatever the claim order (property test), and a request that can never settle keeps its epoch open | [`contracts/async-vault/src/test/settlement.rs`](../contracts/async-vault/src/test/settlement.rs) | `the_last_redemption_claim_clears_the_cash_remainder_and_says_so_once`, `an_epoch_with_no_requests_settles_when_it_is_priced`, `a_settled_epoch_leaves_no_remainder_in_the_totals`, `a_request_that_cannot_settle_keeps_its_epoch_open` | | The oracle publishes the valuation it stored | [`contracts/nav-oracle/src/test.rs`](../contracts/nav-oracle/src/test.rs) | `attesting_publishes_the_stored_valuation` | ## Calls between contracts diff --git a/docs/kit-spec.md b/docs/kit-spec.md index d1bb1271..e294fd4c 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -68,6 +68,8 @@ The contracts enforce a set of mathematical and state-machine invariants across $$\text{assets\_to\_shares}(\text{shares\_to\_assets}(s)) \le s$$ - **Testable Property**: For all values of $a, s \in [1, 10^{30}]$ and $\text{NAV} \in [\text{min\_answer}, \text{max\_answer}]$, a round-trip conversion never creates value. Remainder satisfies: $$(a \cdot \text{SCALE}) - (s \cdot \text{NAV}) < \text{NAV}$$ +- **Settlement**: pricing records what each epoch promised (`shares_promised`, `assets_promised`, see `get_epoch_settlement`). Claims floor one by one, so together they pay at most that. When the last request of a priced epoch settles, the remainder leaves `pending_mint_shares` and `committed` and the vault publishes `EpochReconciled { epoch, share_dust, cash_dust }`. The cash stays in the reserve, owed to nobody, and reaches every holder through the NAV. A running total that would go below zero fails with `AccountingMismatch` (6070) instead of clamping. +- **A request that can never settle** keeps its epoch open, and that epoch's remainder stays in `pending_mint_shares` and `committed`. Two cases: a zero-asset redemption whose holder the share token refuses, and a priced deposit whose mint a compliance module refuses (`a_request_that_cannot_settle_keeps_its_epoch_open`). The remainder is at most a few units per epoch, and it is still owed to nobody. ### Invariant 8: Bounded Governance Timelocks - **Definition**: Upgrades and wind-downs cannot execute without advance notice, and delays cannot be set to invalid boundaries. @@ -86,6 +88,7 @@ The contracts enforce a set of mathematical and state-machine invariants across - **Mathematical Statement**: $$\sum_{i} \text{payout}_i \le \text{round\_assets}$$ - **Testable Property**: In any distribution round with arbitrary share distributions and rounding remainders, the vault remains solvent for the last claimant. Active wind-down is strictly irreversible. +- Each payout floors per holder, so after every holder has claimed, `wind_down_owed` keeps at most one unit per holder. That remainder is not released: a later round lets each holder's floor catch up and needs it (`a_later_round_needs_the_units_an_earlier_round_left_in_owed`, `after_every_holder_claims_owed_keeps_at_most_one_unit_per_holder`). --- From 8f3a6c357b9c10fe76869e4d59088857212d9171 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:46:54 -0300 Subject: [PATCH 025/147] fix(vault): address settlement review round 1 --- contracts/async-vault/src/deposit.rs | 4 +- contracts/async-vault/src/test/compliance.rs | 2 +- contracts/async-vault/src/test/settlement.rs | 30 +++++++++++++- contracts/async-vault/src/test/wind_down.rs | 42 ++++++++++++++++++++ docs/TESTING.md | 2 +- docs/kit-spec.md | 6 ++- 6 files changed, 77 insertions(+), 9 deletions(-) diff --git a/contracts/async-vault/src/deposit.rs b/contracts/async-vault/src/deposit.rs index 64a3d5bb..a3b18672 100644 --- a/contracts/async-vault/src/deposit.rs +++ b/contracts/async-vault/src/deposit.rs @@ -135,7 +135,7 @@ pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 { /// Returns the settlement asset and clears the request. Used by cancellation and /// by a claim whose conversion floors to zero. -fn refund_deposit(e: &Env, controller: &Address, epoch_id: u64, amount: i128) -> i128 { +fn refund_deposit(e: &Env, controller: &Address, epoch_id: u64, amount: i128) { let mut epoch = state::get_epoch(e, epoch_id) .unwrap_or_else(|| panic_with_error!(e, VaultError::EpochNotFound)); epoch.total_deposited = state::reduce(e, epoch.total_deposited, amount); @@ -151,8 +151,6 @@ fn refund_deposit(e: &Env, controller: &Address, epoch_id: u64, amount: i128) -> amount, } .publish(e); - - 0 } pub(crate) fn cancel(e: &Env, from: &Address, epoch_id: u64) -> i128 { diff --git a/contracts/async-vault/src/test/compliance.rs b/contracts/async-vault/src/test/compliance.rs index 16dd192a..ff242647 100644 --- a/contracts/async-vault/src/test/compliance.rs +++ b/contracts/async-vault/src/test/compliance.rs @@ -97,7 +97,7 @@ fn a_holder_delisted_after_pricing_can_only_leave_by_being_relisted() { } #[contract] -struct RefusesEveryMint; +pub(super) struct RefusesEveryMint; #[contractimpl] impl RefusesEveryMint { diff --git a/contracts/async-vault/src/test/settlement.rs b/contracts/async-vault/src/test/settlement.rs index f445598a..fc6beaeb 100644 --- a/contracts/async-vault/src/test/settlement.rs +++ b/contracts/async-vault/src/test/settlement.rs @@ -1,5 +1,6 @@ use proptest::prelude::*; use soroban_sdk::{testutils::Events as _, Event as _}; +use stellar_tokens::rwa::compliance::ComplianceHook; use super::*; use crate::{EpochFulfilled, EpochReconciled, RedeemClaimed}; @@ -177,6 +178,31 @@ fn a_request_that_cannot_settle_keeps_its_epoch_open() { assert_eq!(f.vault.committed(), s.assets_promised - s.assets_settled); } +/// The other case that can never settle: a compliance module refuses a +/// priced deposit's mint even for an allowlisted investor. The epoch stays +/// open and its remainder is never reconciled, the same as a refused +/// redemption. +#[test] +fn a_refused_mint_keeps_a_priced_deposit_epoch_open() { + let f = setup(); + let investor = f.investor(1_000); + let epoch = f.vault.request_deposit(&investor, &400); + f.fulfill_epoch(wad(2)); + + let module = f.e.register(super::compliance::RefusesEveryMint, ()); + ComplianceClient::new(&f.e, &f.share.compliance()).add_module_to( + &ComplianceHook::CanCreate, + &module, + &f.admin, + ); + + assert!(f.vault.try_claim_deposit(&investor, &epoch).is_err()); + + let s = f.vault.get_epoch_settlement(&epoch).unwrap(); + assert_eq!(s.deposits_open, 1); + assert!(!s.reconciled); +} + proptest! { #![proptest_config(ProptestConfig::with_cases(24))] @@ -185,9 +211,9 @@ proptest! { /// every request settles. #[test] fn a_settled_epoch_leaves_no_remainder_in_the_totals( - deposits in proptest::collection::vec(1i128..5_000, 1..5), + deposits in proptest::collection::vec(prop_oneof![Just(1i128), 1i128..5_000], 1..5), cancels in proptest::collection::vec(any::(), 5), - redeems in proptest::collection::vec(1i128..500, 0..4), + redeems in proptest::collection::vec(prop_oneof![Just(1i128), 1i128..500], 0..4), price_tenths in 5i128..=20, order in proptest::collection::vec(any::(), 8), ) { diff --git a/contracts/async-vault/src/test/wind_down.rs b/contracts/async-vault/src/test/wind_down.rs index ec14bc30..73056f01 100644 --- a/contracts/async-vault/src/test/wind_down.rs +++ b/contracts/async-vault/src/test/wind_down.rs @@ -1,3 +1,5 @@ +use proptest::prelude::*; + use super::*; #[test] @@ -663,3 +665,43 @@ fn after_every_holder_claims_owed_keeps_at_most_one_unit_per_holder() { assert!(f.vault.wind_down_owed() <= holders.len() as i128); assert!(f.balance(&f.vault.address) >= f.vault.wind_down_owed()); } + +proptest! { + #![proptest_config(ProptestConfig::with_cases(24))] + + /// The bound only holds for shares the snapshot counted and that were + /// actually surrendered: random holders, each fully claiming, over a + /// random number of funding rounds. `owed` never reaches the holder + /// count, because each holder's own floor loses less than one unit. + #[test] + fn owed_stays_below_the_holder_count_once_every_share_is_surrendered( + holdings in proptest::collection::vec(1i128..1_000, 1..6), + extra_fundings in proptest::collection::vec(1i128..1_000, 0..3), + ) { + let f = setup(); + let holders: std::vec::Vec
= + holdings.iter().map(|shares| f.holder(*shares)).collect(); + let total_shares: i128 = holdings.iter().sum(); + let backer = f.investor(total_shares + 10_000); + f.empty_reserve(); + f.vault.propose_wind_down(&f.admin); + f.vault.activate_wind_down(); + + // Funds exactly the snapshot, so the first round always credits + // something whatever the random holdings: every holder below then + // always has a round to claim against. + f.vault.fund(&backer, &total_shares); + f.vault.finalize_wind_down_round(); + + for amount in &extra_fundings { + f.vault.fund(&backer, amount); + let _ = f.vault.try_finalize_wind_down_round(); + } + + for holder in &holders { + f.vault.claim_wind_down(holder); + } + + prop_assert!(f.vault.wind_down_owed() < holders.len() as i128); + } +} diff --git a/docs/TESTING.md b/docs/TESTING.md index 893b3e4f..4d700632 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -82,7 +82,7 @@ so time rules and TTL can be reached in one test. | --- | --- | --- | | The vault publishes each step of a deposit with its amounts | [`contracts/async-vault/src/test/events.rs`](../contracts/async-vault/src/test/events.rs) | `requesting_a_deposit_publishes_the_request`, `closing_an_epoch_publishes_its_totals`, `pricing_an_epoch_publishes_the_share_price`, `claiming_the_last_deposit_publishes_the_shares_paid_and_the_settlement` | | A refused call publishes nothing | [`contracts/async-vault/src/test/events.rs`](../contracts/async-vault/src/test/events.rs) | `a_refused_call_publishes_nothing` | -| An epoch's rounding remainder is cleared once its last request settles, a settled epoch leaves nothing in the totals whatever the claim order (property test), and a request that can never settle keeps its epoch open | [`contracts/async-vault/src/test/settlement.rs`](../contracts/async-vault/src/test/settlement.rs) | `the_last_redemption_claim_clears_the_cash_remainder_and_says_so_once`, `an_epoch_with_no_requests_settles_when_it_is_priced`, `a_settled_epoch_leaves_no_remainder_in_the_totals`, `a_request_that_cannot_settle_keeps_its_epoch_open` | +| An epoch's rounding remainder is cleared once its last request settles, a settled epoch leaves nothing in the totals whatever the claim order (property test), and a request that can never settle (a refused redemption or a refused mint) keeps its epoch open | [`contracts/async-vault/src/test/settlement.rs`](../contracts/async-vault/src/test/settlement.rs) | `the_last_redemption_claim_clears_the_cash_remainder_and_says_so_once`, `an_epoch_with_no_requests_settles_when_it_is_priced`, `a_settled_epoch_leaves_no_remainder_in_the_totals`, `a_request_that_cannot_settle_keeps_its_epoch_open`, `a_refused_mint_keeps_a_priced_deposit_epoch_open` | | The oracle publishes the valuation it stored | [`contracts/nav-oracle/src/test.rs`](../contracts/nav-oracle/src/test.rs) | `attesting_publishes_the_stored_valuation` | ## Calls between contracts diff --git a/docs/kit-spec.md b/docs/kit-spec.md index e294fd4c..fb0b48af 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -69,7 +69,9 @@ The contracts enforce a set of mathematical and state-machine invariants across - **Testable Property**: For all values of $a, s \in [1, 10^{30}]$ and $\text{NAV} \in [\text{min\_answer}, \text{max\_answer}]$, a round-trip conversion never creates value. Remainder satisfies: $$(a \cdot \text{SCALE}) - (s \cdot \text{NAV}) < \text{NAV}$$ - **Settlement**: pricing records what each epoch promised (`shares_promised`, `assets_promised`, see `get_epoch_settlement`). Claims floor one by one, so together they pay at most that. When the last request of a priced epoch settles, the remainder leaves `pending_mint_shares` and `committed` and the vault publishes `EpochReconciled { epoch, share_dust, cash_dust }`. The cash stays in the reserve, owed to nobody, and reaches every holder through the NAV. A running total that would go below zero fails with `AccountingMismatch` (6070) instead of clamping. -- **A request that can never settle** keeps its epoch open, and that epoch's remainder stays in `pending_mint_shares` and `committed`. Two cases: a zero-asset redemption whose holder the share token refuses, and a priced deposit whose mint a compliance module refuses (`a_request_that_cannot_settle_keeps_its_epoch_open`). The remainder is at most a few units per epoch, and it is still owed to nobody. +- **A request that can never settle** keeps its epoch open, so that epoch's remainder — fewer units than it has open requests of that kind — never leaves `pending_mint_shares` and `committed`. Two cases: a zero-asset redemption whose holder the share token refuses (`a_request_that_cannot_settle_keeps_its_epoch_open`), and a priced deposit whose mint a compliance module refuses (`a_refused_mint_keeps_a_priced_deposit_epoch_open`). It is still owed to nobody. + +**Settlement and upgrades.** The settlement record counts a request only from the moment it exists. A vault upgraded in place to this version while any request is open would under-count its open epochs: claims in epochs priced before the upgrade would revert, and a mixed epoch could reconcile early. This version must therefore be deployed fresh, or reached by upgrade only once no request is open anywhere in the vault. ### Invariant 8: Bounded Governance Timelocks - **Definition**: Upgrades and wind-downs cannot execute without advance notice, and delays cannot be set to invalid boundaries. @@ -88,7 +90,7 @@ The contracts enforce a set of mathematical and state-machine invariants across - **Mathematical Statement**: $$\sum_{i} \text{payout}_i \le \text{round\_assets}$$ - **Testable Property**: In any distribution round with arbitrary share distributions and rounding remainders, the vault remains solvent for the last claimant. Active wind-down is strictly irreversible. -- Each payout floors per holder, so after every holder has claimed, `wind_down_owed` keeps at most one unit per holder. That remainder is not released: a later round lets each holder's floor catch up and needs it (`a_later_round_needs_the_units_an_earlier_round_left_in_owed`, `after_every_holder_claims_owed_keeps_at_most_one_unit_per_holder`). +- Each payout floors per holder. Once every holder whose shares were all surrendered has claimed, `wind_down_owed` keeps less than one unit per such holder, plus whatever value the snapshot counted in shares that never actually minted (for example `pending_mint_shares` from an epoch that promised more shares than it later minted). That remainder is not released: a later round lets each holder's floor catch up and needs it (`a_later_round_needs_the_units_an_earlier_round_left_in_owed`, `after_every_holder_claims_owed_keeps_at_most_one_unit_per_holder`, `owed_stays_below_the_holder_count_once_every_share_is_surrendered`). --- From 4721abe06026cc697bcad0be745582352f5a197b Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 02:09:24 -0300 Subject: [PATCH 026/147] fix(vault): correct refused-mint settlement wording --- contracts/async-vault/src/test/settlement.rs | 9 ++++++--- docs/kit-spec.md | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/contracts/async-vault/src/test/settlement.rs b/contracts/async-vault/src/test/settlement.rs index fc6beaeb..398b03b5 100644 --- a/contracts/async-vault/src/test/settlement.rs +++ b/contracts/async-vault/src/test/settlement.rs @@ -179,9 +179,10 @@ fn a_request_that_cannot_settle_keeps_its_epoch_open() { } /// The other case that can never settle: a compliance module refuses a -/// priced deposit's mint even for an allowlisted investor. The epoch stays -/// open and its remainder is never reconciled, the same as a refused -/// redemption. +/// priced deposit's mint even for an allowlisted investor. Unlike a refused +/// redemption, this is not rounding dust: the mint reverts, so the whole +/// entitlement this deposit promised — all 200 shares, for 400 assets at a +/// price of 2 — stays in `pending_mint_shares` until the mint is allowed. #[test] fn a_refused_mint_keeps_a_priced_deposit_epoch_open() { let f = setup(); @@ -198,7 +199,9 @@ fn a_refused_mint_keeps_a_priced_deposit_epoch_open() { assert!(f.vault.try_claim_deposit(&investor, &epoch).is_err()); + assert_eq!(f.vault.pending_mint_shares(), 200); let s = f.vault.get_epoch_settlement(&epoch).unwrap(); + assert_eq!((s.shares_promised, s.shares_settled), (200, 0)); assert_eq!(s.deposits_open, 1); assert!(!s.reconciled); } diff --git a/docs/kit-spec.md b/docs/kit-spec.md index fb0b48af..583ea7dc 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -69,7 +69,7 @@ The contracts enforce a set of mathematical and state-machine invariants across - **Testable Property**: For all values of $a, s \in [1, 10^{30}]$ and $\text{NAV} \in [\text{min\_answer}, \text{max\_answer}]$, a round-trip conversion never creates value. Remainder satisfies: $$(a \cdot \text{SCALE}) - (s \cdot \text{NAV}) < \text{NAV}$$ - **Settlement**: pricing records what each epoch promised (`shares_promised`, `assets_promised`, see `get_epoch_settlement`). Claims floor one by one, so together they pay at most that. When the last request of a priced epoch settles, the remainder leaves `pending_mint_shares` and `committed` and the vault publishes `EpochReconciled { epoch, share_dust, cash_dust }`. The cash stays in the reserve, owed to nobody, and reaches every holder through the NAV. A running total that would go below zero fails with `AccountingMismatch` (6070) instead of clamping. -- **A request that can never settle** keeps its epoch open, so that epoch's remainder — fewer units than it has open requests of that kind — never leaves `pending_mint_shares` and `committed`. Two cases: a zero-asset redemption whose holder the share token refuses (`a_request_that_cannot_settle_keeps_its_epoch_open`), and a priced deposit whose mint a compliance module refuses (`a_refused_mint_keeps_a_priced_deposit_epoch_open`). It is still owed to nobody. +- **A request that can never settle** keeps its epoch open, and the two cases leave different amounts behind. A zero-asset redemption whose holder the share token refuses (`a_request_that_cannot_settle_keeps_its_epoch_open`) leaves only the epoch's ordinary rounding remainder in `committed` — fewer units than the epoch has open requests of that kind — because that request was never owed any cash. A priced deposit whose mint a compliance module refuses (`a_refused_mint_keeps_a_priced_deposit_epoch_open`) is not rounding dust: the mint reverts, so that deposit's whole share entitlement stays in `pending_mint_shares`, on top of the epoch's own remainder, until the mint is allowed. Either way it is still owed to nobody. **Settlement and upgrades.** The settlement record counts a request only from the moment it exists. A vault upgraded in place to this version while any request is open would under-count its open epochs: claims in epochs priced before the upgrade would revert, and a mixed epoch could reconcile early. This version must therefore be deployed fresh, or reached by upgrade only once no request is open anywhere in the vault. From 7dca4e5a8286f496d4cdefe1147b20b5c2db540e Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 02:27:29 -0300 Subject: [PATCH 027/147] fix(vault): reopen cancellation while paused --- contracts/async-vault/src/epoch.rs | 10 +++--- contracts/async-vault/src/test/cancel.rs | 45 ++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/contracts/async-vault/src/epoch.rs b/contracts/async-vault/src/epoch.rs index b675e025..2f5293b7 100644 --- a/contracts/async-vault/src/epoch.rs +++ b/contracts/async-vault/src/epoch.rs @@ -1,5 +1,6 @@ use bindings::{OracleFeedClient, OracleState}; use soroban_sdk::{panic_with_error, Env}; +use stellar_contract_utils::pausable; use crate::error::VaultError; use crate::event::{EpochClosed, EpochFulfilled}; @@ -49,11 +50,12 @@ pub(crate) fn fulfilable(e: &Env, epoch: &EpochInfo) -> Result<(), VaultError> { price_readable(e, epoch) } -/// True when the epoch's price can already be read, which is what closes the -/// cancellation window: cancelling after this would be declining a price the -/// investor has seen. +/// True when the epoch's price can already be read and the vault could take +/// it, which is what closes the cancellation window: cancelling after this +/// would be declining a price the investor has seen. A paused vault cannot +/// price, so its sealed epochs stay cancellable until the pause lifts. pub(crate) fn is_priceable(e: &Env, epoch: &EpochInfo) -> bool { - epoch.status == EpochStatus::Pending && price_readable(e, epoch).is_ok() + epoch.status == EpochStatus::Pending && !pausable::paused(e) && price_readable(e, epoch).is_ok() } pub(crate) fn close(e: &Env) -> u64 { diff --git a/contracts/async-vault/src/test/cancel.rs b/contracts/async-vault/src/test/cancel.rs index 8bd64a91..6b31797d 100644 --- a/contracts/async-vault/src/test/cancel.rs +++ b/contracts/async-vault/src/test/cancel.rs @@ -293,3 +293,48 @@ fn a_sealed_epoch_cancels_while_the_nav_is_paused() { assert_eq!(f.vault.cancel_redeem(&holder, &epoch), 200); assert_eq!(f.shares(&holder), 200); } + +/// A paused vault cannot price, so a readable price no longer closes the +/// window: the investor may leave while the pause lasts, and the window closes +/// again once governance lifts it. +#[test] +fn a_paused_vault_reopens_cancellation_of_an_epoch_it_could_price() { + let f = setup(); + let first = f.investor(1_000); + let second = f.investor(1_000); + let epoch = f.vault.request_deposit(&first, &400); + f.vault.request_deposit(&second, &400); + f.close_epoch(); + f.attest(wad(2)); + refused( + f.vault.try_cancel_deposit(&first, &epoch), + VaultError::PriceAvailable, + ); + + f.vault.pause(&f.guardian); + assert_eq!(f.vault.cancel_deposit(&first, &epoch), 400); + + f.vault.unpause(&f.admin); + refused( + f.vault.try_cancel_deposit(&second, &epoch), + VaultError::PriceAvailable, + ); +} + +/// The window closes on the first readable price after the close and reopens +/// if that price lapses before the epoch is priced. +#[test] +fn cancellation_reopens_when_the_price_that_closed_it_expires() { + let f = setup(); + let inv = f.investor(1_000); + let epoch = f.vault.request_deposit(&inv, &400); + f.close_epoch(); + f.attest(wad(2)); + refused( + f.vault.try_cancel_deposit(&inv, &epoch), + VaultError::PriceAvailable, + ); + + f.advance(3_601); // past the freshness window + assert_eq!(f.vault.cancel_deposit(&inv, &epoch), 400); +} From 532653135f0a024cb13d0b38bb76eec5cb74581a Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 02:29:45 -0300 Subject: [PATCH 028/147] docs: write down the cancel window rules --- docs/kit-spec.md | 12 +++++++++--- docs/strata-product-and-architecture.md | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/kit-spec.md b/docs/kit-spec.md index 583ea7dc..e93acf31 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -51,14 +51,20 @@ The contracts enforce a set of mathematical and state-machine invariants across - `request_redeem` succeeds and locks shares into escrow. - `claim_redeem` and `claim_deposit` remain open. - `cancel_redeem` remains open; only the share token's own transfer pause can refuse it (Invariant 6). + - `cancel_deposit` and `cancel_redeem` stay open for a sealed epoch while the vault is paused, because a paused vault cannot price it (Invariant 6). ### Invariant 6: Single-Step Atomic Cancellation -- **Definition**: Cancellation is an all-or-nothing operation, open while an epoch cannot be priced and subject to the share token's transfer pause for redemptions. +- **Definition**: Cancellation is all-or-nothing, and it is open exactly while the vault could not price the epoch now. Three rules decide it: + 1. A readable price closes cancellation, even while the notice still runs. A price is readable when the feed is valid and its report was accepted at or after the epoch closed. + 2. If the vault can no longer price the epoch — the report expires, the NAV is paused, or the vault is paused — cancellation opens again. That is the way out of a stalled feed, so escrow is never stuck. An investor cannot cause the reopening; only a lapsed attester, a raised NAV pause, or a paused vault can. + 3. The epoch is priced at the latest valid report at fulfil time that was accepted at or after the close — not necessarily the report that first closed the window. - **Testable Property**: - - While an epoch is `Open`, or `Pending` with `priceable_at > now` (or feed stale/paused), `cancel_deposit` and `cancel_redeem` return 100% of escrowed assets/shares atomically. + - While an epoch is `Open`, or `Pending` with no readable price or with the vault paused, `cancel_deposit` and `cancel_redeem` return 100% of the escrow. + - A readable price refuses cancellation with `PriceAvailable`, even before `priceable_at`; once that report expires, cancellation works again (`cancellation_reopens_when_the_price_that_closed_it_expires`). + - Pausing the vault reopens cancellation; unpausing closes it again while the price is still readable (`a_paused_vault_reopens_cancellation_of_an_epoch_it_could_price`). - `cancel_redeem` is refused with `PausableError::EnforcedPause` while the share token's transfer pause is active; the request stays pending and the cash claim remains open. - - Once `now >= priceable_at` and a fresh attestation is available, cancellation is refused with `PriceAvailable`. - Once fulfilled, cancellation is refused with `AlreadyPriced`. +- **Operating note**: keep `freshness_duration` longer than the notice, so a normal epoch's price does not lapse and reopen the window on its own. ### Invariant 7: Directional Rounding & Non-Creation of Value - **Definition**: Arithmetic conversions round in the vault's favor, preventing arbitrage and value creation from rounding residue. diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index f18762f8..3288965b 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -312,7 +312,7 @@ batch boundary, though not the price it receives. - Request: moves the deposit asset into escrow; at most one active request per controller. The vault first asks the share token's identity verifier and refuses an address that could not receive the shares, before any cash moves; the token checks again when it mints at claim time. - Pricing: the escrow leaves the cancellable bucket, the share quantity is set at the epoch's price, and the economic supply liability is recorded. To avoid unbounded loops that exceed Soroban transaction resource limits, physical share minting occurs lazily when each investor executes `claim_deposit`. -- Cancellation: atomic, and returns the escrowed asset in full. Open while the epoch is open. Once sealed, cancellation remains open only until the epoch becomes priceable (the notice period elapses and a fresh attestation is available) or is fulfilled, preventing arbitrage against known prices while preserving an escape hatch for stalled epochs. Cancellation also remains open during an active wind-down, allowing pending depositors to recover their escrowed assets when pricing is closed. +- Cancellation: atomic, and returns the escrowed asset in full. Open while the epoch is open. Once sealed, a readable price closes it, even while the notice still runs: an investor who can see the price cannot decline it. If the vault can no longer price the epoch — the report expires, the NAV is paused, or the vault is paused — it opens again, so escrow is never stuck behind a stalled feed. The epoch is then priced at the latest valid report at fulfil time. Cancellation also remains open during an active wind-down, allowing pending depositors to recover their escrowed assets when pricing is closed. - Share claim: verifies the receiver and delivers the shares via `mint`. If verification fails, the position remains in pending mint and exits through administrative resolution or once allowlisted. No nominal refund exists after pricing. #### Redemption From 68527ee4a7566e8ff925232f8597ac0986748a96 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:42:48 -0300 Subject: [PATCH 029/147] feat(vault): send a delisted claim to the queue --- contracts/async-vault/src/deposit.rs | 46 ++- contracts/async-vault/src/error.rs | 2 + contracts/async-vault/src/event.rs | 19 ++ contracts/async-vault/src/keys.rs | 1 + contracts/async-vault/src/lib.rs | 12 +- contracts/async-vault/src/lock_policy.rs | 47 ++- contracts/async-vault/src/redeem.rs | 67 +++- contracts/async-vault/src/test/compliance.rs | 52 +-- contracts/async-vault/src/test/delisted.rs | 308 ++++++++++++++++++ contracts/async-vault/src/test/lock_policy.rs | 158 ++++++++- contracts/async-vault/src/test/mod.rs | 1 + contracts/async-vault/src/test/wind_down.rs | 46 ++- contracts/async-vault/src/wind_down.rs | 25 +- scripts/harness/guards.test.ts | 4 +- 14 files changed, 730 insertions(+), 58 deletions(-) create mode 100644 contracts/async-vault/src/test/delisted.rs diff --git a/contracts/async-vault/src/deposit.rs b/contracts/async-vault/src/deposit.rs index a3b18672..67f1bdaf 100644 --- a/contracts/async-vault/src/deposit.rs +++ b/contracts/async-vault/src/deposit.rs @@ -3,19 +3,22 @@ use stellar_tokens::rwa::RWATokenClient; use crate::epoch; use crate::error::VaultError; -use crate::event::{DepositCancelled, DepositClaimed, DepositRequested}; +use crate::event::{DepositCancelled, DepositClaimed, DepositConvertedToExit, DepositRequested}; use crate::keys::DataKey; use crate::lock_policy::{self, Decision, Exit}; use crate::pricing::{Pricing, PricingScheme}; +use crate::redeem; use crate::settlement; use crate::state::{self, DepositRequest, EpochStatus}; use crate::treasury; use crate::wind_down; -fn refuse_unless_allowlisted(e: &Env, from: &Address) { +fn refuse_unless_allowed_in(e: &Env, from: &Address) { let standing = lock_policy::standing(e, from); - if lock_policy::decide(Exit::RequestDeposit, standing) == Decision::Refuse { - panic_with_error!(e, VaultError::NotAllowlisted); + if lock_policy::decide(Exit::RequestDeposit, standing, wind_down::is_active(e)) + != Decision::Proceed + { + panic_with_error!(e, lock_policy::refused_with(standing)); } } @@ -39,7 +42,7 @@ pub(crate) fn request(e: &Env, from: &Address, amount: i128) -> u64 { if amount <= 0 { panic_with_error!(e, VaultError::InvalidAmount); } - refuse_unless_allowlisted(e, from); + refuse_unless_allowed_in(e, from); refuse_if_exceeds_cap(e, amount); let epoch_id = state::current_epoch(e); @@ -99,6 +102,12 @@ pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 { panic_with_error!(e, VaultError::AlreadyClaimed); } + let standing = lock_policy::standing(e, caller); + let decision = lock_policy::decide(Exit::ClaimDeposit, standing, wind_down::is_active(e)); + if matches!(decision, Decision::Refuse | Decision::Hold) { + panic_with_error!(e, lock_policy::refused_with(standing)); + } + let shares = Pricing::deposit_shares(e, request.amount, epoch.share_price) .unwrap_or_else(|err| panic_with_error!(e, err)); @@ -118,6 +127,11 @@ pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 { state::set_deposit_request(e, epoch_id, caller, &request); state::set_pending_mint_shares(e, state::reduce(e, state::pending_mint_shares(e), shares)); + if decision != Decision::Proceed { + convert_to_exit(e, caller, epoch_id, shares, decision); + return 0; + } + let share_token = state::get_addr(e, &DataKey::ShareToken); RWATokenClient::new(e, &share_token).mint(caller, &shares, &e.current_contract_address()); @@ -133,6 +147,28 @@ pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 { shares } +/// The token refuses this holder shares, so they exit in cash: next price, or the wind-down payout. +fn convert_to_exit(e: &Env, holder: &Address, epoch_id: u64, shares: i128, decision: Decision) { + let into_epoch = if decision == Decision::Surrender { + wind_down::credit(e, holder, shares); + 0 + } else { + let vault = e.current_contract_address(); + let share_token = state::get_addr(e, &DataKey::ShareToken); + RWATokenClient::new(e, &share_token).mint(&vault, &shares, &vault); + redeem::queue_escrowed(e, holder, shares) + }; + + DepositConvertedToExit { + controller: holder.clone(), + epoch: epoch_id, + into_epoch, + shares, + } + .publish(e); + settlement::on_deposit_claimed(e, epoch_id, shares); +} + /// Returns the settlement asset and clears the request. Used by cancellation and /// by a claim whose conversion floors to zero. fn refund_deposit(e: &Env, controller: &Address, epoch_id: u64, amount: i128) { diff --git a/contracts/async-vault/src/error.rs b/contracts/async-vault/src/error.rs index 7f84ef75..47ab3c25 100644 --- a/contracts/async-vault/src/error.rs +++ b/contracts/async-vault/src/error.rs @@ -121,4 +121,6 @@ pub enum VaultError { /// A running total would go below zero, which means the vault's books /// disagree with themselves. Fails loudly instead of clamping to zero. AccountingMismatch = 6070, + /// The holder is frozen: nothing moves for them until the freeze is lifted. + Frozen = 6071, } diff --git a/contracts/async-vault/src/event.rs b/contracts/async-vault/src/event.rs index 3d76cbc5..33c55f68 100644 --- a/contracts/async-vault/src/event.rs +++ b/contracts/async-vault/src/event.rs @@ -149,6 +149,25 @@ pub struct WindDownClaimed { pub assets: i128, } +/// An escrowed redemption burned into the controller's wind-down entitlement. +#[contractevent] +pub struct RedemptionSurrendered { + #[topic] + pub controller: Address, + pub epoch: u64, + pub shares: i128, +} + +/// A priced deposit queued to exit in `into_epoch`; zero means the wind-down entitlement. +#[contractevent] +pub struct DepositConvertedToExit { + #[topic] + pub controller: Address, + pub epoch: u64, + pub into_epoch: u64, + pub shares: i128, +} + #[contractevent] pub struct EpochReconciled { #[topic] diff --git a/contracts/async-vault/src/keys.rs b/contracts/async-vault/src/keys.rs index 0f6d1151..ad78eec9 100644 --- a/contracts/async-vault/src/keys.rs +++ b/contracts/async-vault/src/keys.rs @@ -23,6 +23,7 @@ pub(crate) enum DataKey { WindDownAcc, WindDownOwed, WindDownPosition(Address), + WindDownCredited, DepositCap, Settlement(u64), } diff --git a/contracts/async-vault/src/lib.rs b/contracts/async-vault/src/lib.rs index 7d3173ee..5b5b3f2a 100644 --- a/contracts/async-vault/src/lib.rs +++ b/contracts/async-vault/src/lib.rs @@ -32,9 +32,10 @@ use state::FIRST_EPOCH; pub use error::VaultError; pub use event::{ ChangeApplied, ChangeCancelled, ChangeProposed, CustodianSet, Deployed, DepositCapUpdated, - DepositClaimed, DepositRequested, EpochClosed, EpochFulfilled, EpochReconciled, Funded, - NoticeSet, RedeemClaimed, RedeemRequested, WindDownActivated, WindDownClaimed, - WindDownDelaySet, WindDownProposalCancelled, WindDownProposed, WindDownRoundFinalized, + DepositClaimed, DepositConvertedToExit, DepositRequested, EpochClosed, EpochFulfilled, + EpochReconciled, Funded, NoticeSet, RedeemClaimed, RedeemRequested, RedemptionSurrendered, + WindDownActivated, WindDownClaimed, WindDownDelaySet, WindDownProposalCancelled, + WindDownProposed, WindDownRoundFinalized, }; pub use governance::{VaultChange, VaultPendingChange}; pub use pricing::{DirectUnitPricing, PricingScheme}; @@ -352,8 +353,9 @@ impl AsyncVault { deposit::cancel(e, &from, epoch_id) } - /// Cancels an unpriced redemption. Refused for a controller the share token - /// will not let hold shares; that controller exits through the cash claim. + /// Cancels an unpriced redemption. Outside a wind-down the shares come + /// back, so a delisted or frozen controller is refused; in one the escrow + /// joins the controller's entitlement, and a frozen controller is held. pub fn cancel_redeem(e: &Env, from: Address, epoch_id: u64) -> i128 { redeem::cancel(e, &from, epoch_id) } diff --git a/contracts/async-vault/src/lock_policy.rs b/contracts/async-vault/src/lock_policy.rs index e0704e08..3f94d4eb 100644 --- a/contracts/async-vault/src/lock_policy.rs +++ b/contracts/async-vault/src/lock_policy.rs @@ -1,6 +1,7 @@ use soroban_sdk::{Address, Env}; use stellar_tokens::rwa::{identity_verification::IdentityVerifierClient, RWATokenClient}; +use crate::error::VaultError; use crate::keys::DataKey; use crate::state; @@ -9,13 +10,17 @@ use crate::state; pub(crate) enum Standing { Listed, Delisted, + Frozen, } -/// The holder's standing, asked of the identity verifier the share token -/// itself uses, so the vault and the token never disagree. +/// The holder's standing, asked of the share token and its identity verifier, +/// so the vault and the token never disagree. pub(crate) fn standing(e: &Env, holder: &Address) -> Standing { - let token = state::get_addr(e, &DataKey::ShareToken); - let verifier = RWATokenClient::new(e, &token).identity_verifier(); + let token = RWATokenClient::new(e, &state::get_addr(e, &DataKey::ShareToken)); + if token.is_frozen(holder) { + return Standing::Frozen; + } + let verifier = token.identity_verifier(); match IdentityVerifierClient::new(e, &verifier).try_verify_identity(holder) { Ok(Ok(())) => Standing::Listed, _ => Standing::Delisted, @@ -26,18 +31,44 @@ pub(crate) fn standing(e: &Env, holder: &Address) -> Standing { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum Exit { RequestDeposit, + ClaimDeposit, + CancelRedeem, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum Decision { Proceed, Refuse, + /// The priced shares join the holder's redemption in the open epoch. + ConvertToExit, + /// The shares join the holder's wind-down entitlement. + Surrender, + /// Nothing moves until the freeze is lifted. + Hold, } /// What `exit` does for a holder in `standing`. No storage, no calls. -pub(crate) fn decide(exit: Exit, standing: Standing) -> Decision { - match (exit, standing) { - (Exit::RequestDeposit, Standing::Listed) => Decision::Proceed, - (Exit::RequestDeposit, Standing::Delisted) => Decision::Refuse, +pub(crate) fn decide(exit: Exit, standing: Standing, winding_down: bool) -> Decision { + match (exit, standing, winding_down) { + (Exit::RequestDeposit, Standing::Listed, _) => Decision::Proceed, + (Exit::RequestDeposit, Standing::Delisted | Standing::Frozen, _) => Decision::Refuse, + + (Exit::ClaimDeposit, Standing::Listed, _) => Decision::Proceed, + (Exit::ClaimDeposit, Standing::Delisted, false) => Decision::ConvertToExit, + (Exit::ClaimDeposit, Standing::Delisted, true) => Decision::Surrender, + (Exit::ClaimDeposit, Standing::Frozen, _) => Decision::Hold, + + (Exit::CancelRedeem, Standing::Listed, false) => Decision::Proceed, + (Exit::CancelRedeem, Standing::Delisted, false) => Decision::Refuse, + (Exit::CancelRedeem, Standing::Listed | Standing::Delisted, true) => Decision::Surrender, + (Exit::CancelRedeem, Standing::Frozen, _) => Decision::Hold, + } +} + +/// The error an exit fails with when `decide` refuses or holds it. +pub(crate) fn refused_with(standing: Standing) -> VaultError { + match standing { + Standing::Frozen => VaultError::Frozen, + Standing::Listed | Standing::Delisted => VaultError::NotAllowlisted, } } diff --git a/contracts/async-vault/src/redeem.rs b/contracts/async-vault/src/redeem.rs index d2c19699..ff612bbc 100644 --- a/contracts/async-vault/src/redeem.rs +++ b/contracts/async-vault/src/redeem.rs @@ -3,8 +3,9 @@ use stellar_tokens::{fungible::FungibleTokenClient, rwa::RWATokenClient}; use crate::epoch; use crate::error::VaultError; -use crate::event::{RedeemCancelled, RedeemClaimed, RedeemRequested}; +use crate::event::{RedeemCancelled, RedeemClaimed, RedeemRequested, RedemptionSurrendered}; use crate::keys::DataKey; +use crate::lock_policy::{self, Decision, Exit}; use crate::pricing::{Pricing, PricingScheme}; use crate::settlement; use crate::state::{self, EpochStatus, RedeemRequest}; @@ -122,6 +123,37 @@ pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 { assets } +/// Queues shares the vault already holds, adding to any redemption open in the epoch. +pub(crate) fn queue_escrowed(e: &Env, controller: &Address, shares: i128) -> u64 { + let epoch_id = state::current_epoch(e); + let mut epoch = state::get_epoch(e, epoch_id) + .unwrap_or_else(|| panic_with_error!(e, VaultError::EpochNotFound)); + epoch.total_shares_redeeming = epoch + .total_shares_redeeming + .checked_add(shares) + .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)); + + let request = match state::get_redeem_request(e, epoch_id, controller) { + Some(mut open) => { + open.shares = open + .shares + .checked_add(shares) + .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)); + open + } + None => { + settlement::on_redeem_requested(e, epoch_id); + RedeemRequest { + shares, + claimed: false, + } + } + }; + state::set_redeem_request(e, epoch_id, controller, &request); + state::set_epoch(e, epoch_id, &epoch); + epoch_id +} + /// Moves escrowed shares back to the controller and clears the request. The /// checked transfer is deliberate: the share token decides whether this holder /// may receive a regulated share, so a frozen or de-listed controller is refused @@ -155,18 +187,47 @@ pub(crate) fn cancel(e: &Env, from: &Address, epoch_id: u64) -> i128 { panic_with_error!(e, VaultError::AlreadyPriced); } + let winding_down = wind_down::is_active(e); + // Cancellation is an escape hatch, not a choice. Once the feed can price a // sealed epoch, the price is knowable and the only way out is to take it. // Fulfilment is closed during a wind-down, so this epoch will never take a // price. There is nothing to decline. - if !wind_down::is_active(e) && epoch::is_priceable(e, &epoch) { + if !winding_down && epoch::is_priceable(e, &epoch) { panic_with_error!(e, VaultError::PriceAvailable); } let request = state::get_redeem_request(e, epoch_id, from) .unwrap_or_else(|| panic_with_error!(e, VaultError::RequestNotFound)); - return_shares(e, from, epoch_id, request.shares); + let standing = lock_policy::standing(e, from); + match lock_policy::decide(Exit::CancelRedeem, standing, winding_down) { + Decision::Proceed => return_shares(e, from, epoch_id, request.shares), + Decision::Surrender => surrender_escrow(e, from, epoch_id, request.shares), + Decision::Refuse | Decision::Hold | Decision::ConvertToExit => { + panic_with_error!(e, lock_policy::refused_with(standing)) + } + } settlement::on_redeem_cancelled(e, epoch_id); request.shares } + +/// Burns the escrow into the entitlement, so no token pause or allowlist place can hold it. +fn surrender_escrow(e: &Env, controller: &Address, epoch_id: u64, shares: i128) { + let mut epoch = state::get_epoch(e, epoch_id) + .unwrap_or_else(|| panic_with_error!(e, VaultError::EpochNotFound)); + epoch.total_shares_redeeming = state::reduce(e, epoch.total_shares_redeeming, shares); + state::set_epoch(e, epoch_id, &epoch); + state::remove_redeem_request(e, epoch_id, controller); + + let vault = e.current_contract_address(); + RWATokenClient::new(e, &state::get_addr(e, &DataKey::ShareToken)).burn(&vault, &shares, &vault); + wind_down::credit(e, controller, shares); + + RedemptionSurrendered { + controller: controller.clone(), + epoch: epoch_id, + shares, + } + .publish(e); +} diff --git a/contracts/async-vault/src/test/compliance.rs b/contracts/async-vault/src/test/compliance.rs index ff242647..7c42c258 100644 --- a/contracts/async-vault/src/test/compliance.rs +++ b/contracts/async-vault/src/test/compliance.rs @@ -49,7 +49,6 @@ fn relisting_after_pricing_lets_the_claim_through() { f.vault.request_deposit(&investor, &400); f.fulfill_epoch(wad(2)); f.identity.allow(&investor, &false, &f.compliance); - assert!(f.vault.try_claim_deposit(&investor, &1).is_err()); f.identity.allow(&investor, &true, &f.compliance); @@ -57,25 +56,17 @@ fn relisting_after_pricing_lets_the_claim_through() { assert_eq!(f.shares(&investor), 200); } -/// Today's behaviour for a holder delisted after their deposit was priced: -/// relisting is the only way out. The cash joined the reserve at pricing, so -/// her claim is to shares, and the wind-down holds her part in `owed` where -/// nobody can collect it. +/// Delisted after pricing, she cannot cancel, and a wind-down still pays her slice. #[test] -fn a_holder_delisted_after_pricing_can_only_leave_by_being_relisted() { +fn a_holder_delisted_after_pricing_is_paid_through_a_wind_down() { let f = setup(); let investor = f.investor(1_000); let epoch = f.vault.request_deposit(&investor, &400); f.fulfill_epoch(wad(2)); assert_eq!(f.vault.cancellable_escrow(), 0); assert_eq!(f.vault.pending_mint_shares(), 200); - f.identity.allow(&investor, &false, &f.compliance); - assert_eq!( - f.vault.try_claim_deposit(&investor, &epoch).err(), - Some(Ok(soroban_sdk::Error::from_contract_error(304))) - ); refused( f.vault.try_cancel_deposit(&investor, &epoch), VaultError::AlreadyPriced, @@ -83,15 +74,8 @@ fn a_holder_delisted_after_pricing_can_only_leave_by_being_relisted() { f.vault.propose_wind_down(&f.admin); f.vault.activate_wind_down(); + assert_eq!(f.vault.claim_deposit(&investor, &epoch), 0); assert_eq!(f.vault.finalize_wind_down_round(), 400); - refused( - f.vault.try_claim_wind_down(&investor), - VaultError::NoEntitlement, - ); - assert_eq!(f.vault.wind_down_owed(), 400); - - f.identity.allow(&investor, &true, &f.compliance); - assert_eq!(f.vault.claim_deposit(&investor, &epoch), 200); assert_eq!(f.vault.claim_wind_down(&investor), 400); assert_eq!(f.vault.wind_down_owed(), 0); } @@ -180,8 +164,10 @@ fn a_delisted_holder_cannot_cancel_redemption_back_to_shares() { // Delisted before pricing f.identity.allow(&user, &false, &f.compliance); - // Cannot return shares to a delisted account (refused by share transfer compliance) - assert!(f.vault.try_cancel_redeem(&user, &epoch).is_err()); + refused( + f.vault.try_cancel_redeem(&user, &epoch), + VaultError::NotAllowlisted, + ); assert_eq!(f.shares(&user), 300); // Exit proceeds through cash claim once fulfilled @@ -201,7 +187,7 @@ fn a_frozen_holder_cannot_cancel_redemption_back_to_shares() { assert!(f.share.is_frozen(&user)); // Cannot return shares to a frozen account - assert!(f.vault.try_cancel_redeem(&user, &epoch).is_err()); + refused(f.vault.try_cancel_redeem(&user, &epoch), VaultError::Frozen); assert_eq!(f.shares(&user), 300); // Exit proceeds through cash claim once fulfilled @@ -231,16 +217,32 @@ fn a_frozen_or_delisted_holder_cannot_transfer_shares() { } #[test] -fn a_frozen_investor_on_the_allowlist_is_still_minted() { +fn a_frozen_investor_cannot_request_a_deposit() { + let f = setup(); + let investor = f.investor(1_000); + f.share.set_address_frozen(&investor, &true, &f.compliance); + + refused( + f.vault.try_request_deposit(&investor, &400), + VaultError::Frozen, + ); + assert_eq!(f.balance(&investor), 1_000); +} + +#[test] +fn a_frozen_investor_is_not_minted_until_unfrozen() { let f = setup(); let investor = f.investor(1_000); f.vault.request_deposit(&investor, &400); f.fulfill_epoch(wad(2)); - f.share.set_address_frozen(&investor, &true, &f.compliance); + refused(f.vault.try_claim_deposit(&investor, &1), VaultError::Frozen); + assert_eq!(f.shares(&investor), 0); + assert!(!f.vault.get_deposit_request(&1, &investor).unwrap().claimed); + + f.share.set_address_frozen(&investor, &false, &f.compliance); assert_eq!(f.vault.claim_deposit(&investor, &1), 200); - assert_eq!(f.shares(&investor), 200); } #[test] diff --git a/contracts/async-vault/src/test/delisted.rs b/contracts/async-vault/src/test/delisted.rs new file mode 100644 index 00000000..8209ed9b --- /dev/null +++ b/contracts/async-vault/src/test/delisted.rs @@ -0,0 +1,308 @@ +use proptest::prelude::*; +use soroban_sdk::{testutils::Events as _, Event as _}; + +use super::*; +use crate::{DepositConvertedToExit, EpochReconciled, RedemptionSurrendered}; + +/// A 400 deposit priced at 2.0 for 200 shares, then delisted before the claim. +fn delisted_with_a_priced_deposit(f: &Fixture) -> (Address, u64) { + let investor = f.investor(1_000); + let epoch = f.vault.request_deposit(&investor, &400); + f.fulfill_epoch(wad(2)); + f.identity.allow(&investor, &false, &f.compliance); + (investor, epoch) +} + +#[test] +fn a_delisted_holders_priced_deposit_joins_their_redemption_in_the_open_epoch() { + let f = setup(); + let (investor, epoch) = delisted_with_a_priced_deposit(&f); + let open = f.vault.current_epoch(); + + assert_eq!(f.vault.claim_deposit(&investor, &epoch), 0); + + assert_eq!( + f.e.events().all().filter_by_contract(&f.vault.address), + [ + DepositConvertedToExit { + controller: investor.clone(), + epoch, + into_epoch: open, + shares: 200, + } + .to_xdr(&f.e, &f.vault.address), + EpochReconciled { + epoch, + share_dust: 0, + cash_dust: 0, + } + .to_xdr(&f.e, &f.vault.address), + ] + ); + assert_eq!(f.shares(&investor), 0); + assert_eq!(f.shares(&f.vault.address), 200); + assert_eq!(f.vault.pending_mint_shares(), 0); + assert_eq!( + f.vault.get_redeem_request(&open, &investor).unwrap().shares, + 200 + ); + refused( + f.vault.try_claim_deposit(&investor, &epoch), + VaultError::AlreadyClaimed, + ); +} + +/// A fall since her deposit priced is hers, as it would be for anyone leaving. +#[test] +fn the_delisted_holder_is_paid_at_the_next_epochs_price() { + let f = setup(); + let (investor, epoch) = delisted_with_a_priced_deposit(&f); + f.vault.claim_deposit(&investor, &epoch); + + let exit = f.fulfill_epoch(wad(8) / 5); + + assert_eq!(f.vault.claim_redeem(&investor, &exit), 320); + assert_eq!(f.balance(&investor), 600 + 320); +} + +#[test] +fn the_delisted_holder_exits_at_the_same_price_as_everyone_in_that_epoch() { + let f = setup(); + let other = f.holder(200); + let (investor, epoch) = delisted_with_a_priced_deposit(&f); + f.vault.claim_deposit(&investor, &epoch); + f.vault.request_redeem(&other, &200); + + let exit = f.fulfill_epoch(wad(8) / 5); + + assert_eq!(f.vault.claim_redeem(&investor, &exit), 320); + assert_eq!(f.vault.claim_redeem(&other, &exit), 320); +} + +#[test] +fn a_conversion_adds_to_a_redemption_already_open_in_the_epoch() { + let f = setup(); + let investor = f.holder(100); + StellarAssetClient::new(&f.e, &f.asset).mint(&investor, &400); + let epoch = f.vault.request_deposit(&investor, &400); + f.fulfill_epoch(wad(2)); + let open = f.vault.current_epoch(); + f.vault.request_redeem(&investor, &100); + f.identity.allow(&investor, &false, &f.compliance); + + f.vault.claim_deposit(&investor, &epoch); + + assert_eq!( + f.vault.get_redeem_request(&open, &investor).unwrap().shares, + 300 + ); + assert_eq!( + f.vault.get_epoch(&open).unwrap().total_shares_redeeming, + 300 + ); + assert_eq!(f.vault.get_epoch_settlement(&open).unwrap().redeems_open, 1); +} + +#[test] +fn in_a_wind_down_the_shares_are_credited_before_the_first_round() { + let f = setup(); + let (investor, epoch) = delisted_with_a_priced_deposit(&f); + f.vault.propose_wind_down(&f.admin); + f.vault.activate_wind_down(); + + assert_eq!(f.vault.claim_deposit(&investor, &epoch), 0); + assert_eq!(f.vault.finalize_wind_down_round(), 400); + + assert_eq!(f.vault.claim_wind_down(&investor), 400); + assert_eq!(f.vault.wind_down_owed(), 0); +} + +#[test] +fn in_a_wind_down_the_shares_are_credited_after_the_first_round() { + let f = setup(); + let (investor, epoch) = delisted_with_a_priced_deposit(&f); + f.vault.propose_wind_down(&f.admin); + f.vault.activate_wind_down(); + assert_eq!(f.vault.finalize_wind_down_round(), 400); + + f.vault.claim_deposit(&investor, &epoch); + + assert_eq!(f.vault.claim_wind_down(&investor), 400); + assert_eq!(f.vault.wind_down_owed(), 0); +} + +/// Without the credit in the snapshot, the first round would owe more than the vault holds. +#[test] +fn a_credit_before_the_first_round_leaves_every_holder_their_share() { + let f = setup(); + let other = f.holder(200); + let (investor, epoch) = delisted_with_a_priced_deposit(&f); + f.vault.propose_wind_down(&f.admin); + f.vault.activate_wind_down(); + f.vault.claim_deposit(&investor, &epoch); + + assert_eq!(f.vault.finalize_wind_down_round(), 800); + assert_eq!(f.vault.claim_wind_down(&investor), 400); + assert_eq!(f.vault.claim_wind_down(&other), 400); + assert_eq!(f.balance(&f.vault.address), 0); +} + +#[test] +fn a_frozen_and_delisted_holders_claim_is_held() { + let f = setup(); + let (investor, epoch) = delisted_with_a_priced_deposit(&f); + f.share.set_address_frozen(&investor, &true, &f.compliance); + + refused( + f.vault.try_claim_deposit(&investor, &epoch), + VaultError::Frozen, + ); + assert_eq!(f.vault.pending_mint_shares(), 200); + assert!( + !f.vault + .get_deposit_request(&epoch, &investor) + .unwrap() + .claimed + ); +} + +/// Converted while the wind-down was only proposed, the redemption will never be priced. +#[test] +fn a_conversion_caught_by_a_wind_down_is_paid_from_the_rounds() { + let f = setup(); + let other = f.holder(200); + let (investor, epoch) = delisted_with_a_priced_deposit(&f); + f.vault.propose_wind_down(&f.admin); + f.vault.claim_deposit(&investor, &epoch); + let open = f.vault.current_epoch(); + f.vault.activate_wind_down(); + + assert_eq!(f.vault.cancel_redeem(&investor, &open), 200); + + assert_eq!( + f.e.events().all().filter_by_contract(&f.vault.address), + [RedemptionSurrendered { + controller: investor.clone(), + epoch: open, + shares: 200, + } + .to_xdr(&f.e, &f.vault.address)] + ); + assert_eq!(f.vault.get_redeem_request(&open, &investor), None); + assert_eq!(f.shares(&f.vault.address), 0); + assert_eq!(f.vault.finalize_wind_down_round(), 800); + assert_eq!(f.vault.claim_wind_down(&investor), 400); + assert_eq!(f.vault.claim_wind_down(&other), 400); + assert_eq!(f.balance(&f.vault.address), 0); +} + +#[test] +fn a_redemption_surrendered_after_the_first_round_keeps_the_rounds_solvent() { + let f = setup(); + let other = f.holder(200); + let (investor, epoch) = delisted_with_a_priced_deposit(&f); + f.vault.propose_wind_down(&f.admin); + f.vault.claim_deposit(&investor, &epoch); + let open = f.vault.current_epoch(); + f.vault.activate_wind_down(); + assert_eq!(f.vault.finalize_wind_down_round(), 800); + + f.vault.cancel_redeem(&investor, &open); + + assert_eq!(f.vault.claim_wind_down(&investor), 400); + assert_eq!(f.vault.claim_wind_down(&other), 400); + assert_eq!(f.vault.wind_down_owed(), 0); +} + +#[test] +fn a_delisted_holders_unpriced_redemption_joins_the_wind_down_when_cancelled() { + let f = setup(); + let investor = f.holder(200); + let other = f.holder(200); + let epoch = f.vault.request_redeem(&investor, &200); + f.identity.allow(&investor, &false, &f.compliance); + f.vault.propose_wind_down(&f.admin); + f.vault.activate_wind_down(); + + assert_eq!(f.vault.cancel_redeem(&investor, &epoch), 200); + + assert_eq!(f.vault.finalize_wind_down_round(), 800); + assert_eq!(f.vault.claim_wind_down(&investor), 400); + assert_eq!(f.vault.claim_wind_down(&other), 400); +} + +#[test] +fn a_conversion_waits_while_the_vault_is_delisted() { + let f = setup(); + let investor = f.investor(1_000); + let epoch = f.vault.request_deposit(&investor, &1_000); + f.fulfill_epoch(wad(2)); + f.identity.allow(&investor, &false, &f.compliance); + f.identity.allow(&f.vault.address, &false, &f.compliance); + + assert_eq!( + f.vault.try_claim_deposit(&investor, &epoch), + Err(Ok(soroban_sdk::Error::from_contract_error(304))) + ); + assert!(f.vault.get_deposit_request(&epoch, &investor).is_some()); + + f.identity.allow(&f.vault.address, &true, &f.compliance); + assert_eq!(f.vault.claim_deposit(&investor, &epoch), 0); + let open = f.vault.current_epoch(); + assert_eq!( + f.vault.get_redeem_request(&open, &investor).unwrap().shares, + 500 + ); +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(16))] + + /// Conversions in any order among the other claims still settle the epoch. + #[test] + fn conversions_settle_the_epoch_in_any_order( + deposits in proptest::collection::vec(1i128..5_000, 1..5), + delisted in proptest::collection::vec(any::(), 4), + order in proptest::collection::vec(any::(), 4), + price_tenths in 5i128..=20, + ) { + let f = setup(); + let investors: std::vec::Vec
= deposits + .iter() + .map(|amount| { + let who = f.investor(*amount); + f.vault.request_deposit(&who, amount); + who + }) + .collect(); + let epoch = f.fulfill_epoch(wad(1) * price_tenths / 10); + let open = f.vault.current_epoch(); + + let mut claims: std::vec::Vec<(u16, usize)> = + (0..investors.len()).map(|i| (order[i], i)).collect(); + claims.sort(); + let mut converted = 0i128; + for (_, i) in claims { + let who = &investors[i]; + if delisted[i] { + f.identity.allow(who, &false, &f.compliance); + } + let queued = || f.vault.get_redeem_request(&open, who).map_or(0, |r| r.shares); + let before = queued(); + let paid = f.vault.claim_deposit(who, &epoch); + if delisted[i] { + prop_assert_eq!(paid, 0); + converted += queued() - before; + } + } + + let s = f.vault.get_epoch_settlement(&epoch).unwrap(); + prop_assert!(s.reconciled); + prop_assert_eq!(f.vault.pending_mint_shares(), 0); + prop_assert_eq!( + f.vault.get_epoch(&open).unwrap().total_shares_redeeming, + converted + ); + prop_assert_eq!(f.shares(&f.vault.address), converted); + } +} diff --git a/contracts/async-vault/src/test/lock_policy.rs b/contracts/async-vault/src/test/lock_policy.rs index a323d474..2dedef3c 100644 --- a/contracts/async-vault/src/test/lock_policy.rs +++ b/contracts/async-vault/src/test/lock_policy.rs @@ -1,22 +1,149 @@ use super::*; use crate::lock_policy::{self, Decision, Exit, Standing}; +fn in_and_out_of_a_wind_down(exit: Exit, standing: Standing) -> [Decision; 2] { + [ + lock_policy::decide(exit, standing, false), + lock_policy::decide(exit, standing, true), + ] +} + #[test] fn a_listed_holder_may_request_a_deposit() { assert_eq!( - lock_policy::decide(Exit::RequestDeposit, Standing::Listed), - Decision::Proceed + in_and_out_of_a_wind_down(Exit::RequestDeposit, Standing::Listed), + [Decision::Proceed; 2] ); } #[test] fn a_delisted_holder_may_not_request_a_deposit() { assert_eq!( - lock_policy::decide(Exit::RequestDeposit, Standing::Delisted), + in_and_out_of_a_wind_down(Exit::RequestDeposit, Standing::Delisted), + [Decision::Refuse; 2] + ); +} + +#[test] +fn a_frozen_holder_may_not_request_a_deposit() { + assert_eq!( + in_and_out_of_a_wind_down(Exit::RequestDeposit, Standing::Frozen), + [Decision::Refuse; 2] + ); +} + +#[test] +fn a_listed_holder_claims_the_shares() { + assert_eq!( + lock_policy::decide(Exit::ClaimDeposit, Standing::Listed, false), + Decision::Proceed + ); +} + +#[test] +fn a_delisted_holders_claim_joins_the_exit_queue() { + assert_eq!( + lock_policy::decide(Exit::ClaimDeposit, Standing::Delisted, false), + Decision::ConvertToExit + ); +} + +#[test] +fn a_frozen_holders_claim_is_held() { + assert_eq!( + lock_policy::decide(Exit::ClaimDeposit, Standing::Frozen, false), + Decision::Hold + ); +} + +#[test] +fn in_a_wind_down_a_listed_holder_claims_the_shares() { + assert_eq!( + lock_policy::decide(Exit::ClaimDeposit, Standing::Listed, true), + Decision::Proceed + ); +} + +#[test] +fn in_a_wind_down_a_delisted_holders_claim_joins_the_entitlement() { + assert_eq!( + lock_policy::decide(Exit::ClaimDeposit, Standing::Delisted, true), + Decision::Surrender + ); +} + +#[test] +fn in_a_wind_down_a_frozen_holders_claim_is_held() { + assert_eq!( + lock_policy::decide(Exit::ClaimDeposit, Standing::Frozen, true), + Decision::Hold + ); +} + +#[test] +fn a_listed_holder_may_cancel_a_redemption_back_to_shares() { + assert_eq!( + lock_policy::decide(Exit::CancelRedeem, Standing::Listed, false), + Decision::Proceed + ); +} + +#[test] +fn a_delisted_holder_may_not_cancel_a_redemption_back_to_shares() { + assert_eq!( + lock_policy::decide(Exit::CancelRedeem, Standing::Delisted, false), Decision::Refuse ); } +#[test] +fn a_frozen_holders_redemption_is_held() { + assert_eq!( + lock_policy::decide(Exit::CancelRedeem, Standing::Frozen, false), + Decision::Hold + ); +} + +#[test] +fn in_a_wind_down_a_listed_holders_cancelled_redemption_joins_the_entitlement() { + assert_eq!( + lock_policy::decide(Exit::CancelRedeem, Standing::Listed, true), + Decision::Surrender + ); +} + +#[test] +fn in_a_wind_down_a_delisted_holders_cancelled_redemption_joins_the_entitlement() { + assert_eq!( + lock_policy::decide(Exit::CancelRedeem, Standing::Delisted, true), + Decision::Surrender + ); +} + +#[test] +fn in_a_wind_down_a_frozen_holders_redemption_is_held() { + assert_eq!( + lock_policy::decide(Exit::CancelRedeem, Standing::Frozen, true), + Decision::Hold + ); +} + +#[test] +fn a_frozen_holder_is_refused_as_frozen() { + assert_eq!( + lock_policy::refused_with(Standing::Frozen), + VaultError::Frozen + ); +} + +#[test] +fn a_delisted_holder_is_refused_as_not_allowlisted() { + assert_eq!( + lock_policy::refused_with(Standing::Delisted), + VaultError::NotAllowlisted + ); +} + #[test] fn standing_is_listed_for_an_allowlisted_holder() { let f = setup(); @@ -50,3 +177,28 @@ fn standing_is_delisted_for_a_holder_listed_then_removed() { Standing::Delisted ); } + +#[test] +fn standing_is_frozen_for_a_frozen_holder_on_the_allowlist() { + let f = setup(); + let holder = f.investor(0); + f.share.set_address_frozen(&holder, &true, &f.compliance); + + assert_eq!( + f.e.as_contract(&f.vault.address, || lock_policy::standing(&f.e, &holder)), + Standing::Frozen + ); +} + +#[test] +fn standing_is_frozen_for_a_holder_both_frozen_and_delisted() { + let f = setup(); + let holder = f.investor(0); + f.identity.allow(&holder, &false, &f.compliance); + f.share.set_address_frozen(&holder, &true, &f.compliance); + + assert_eq!( + f.e.as_contract(&f.vault.address, || lock_policy::standing(&f.e, &holder)), + Standing::Frozen + ); +} diff --git a/contracts/async-vault/src/test/mod.rs b/contracts/async-vault/src/test/mod.rs index 38d48534..b807f916 100644 --- a/contracts/async-vault/src/test/mod.rs +++ b/contracts/async-vault/src/test/mod.rs @@ -3,6 +3,7 @@ mod compliance; mod constructor; mod controls; mod conversions; +mod delisted; mod deposit; mod deposit_cap; mod epochs; diff --git a/contracts/async-vault/src/test/wind_down.rs b/contracts/async-vault/src/test/wind_down.rs index 73056f01..b79b9796 100644 --- a/contracts/async-vault/src/test/wind_down.rs +++ b/contracts/async-vault/src/test/wind_down.rs @@ -249,7 +249,7 @@ fn an_epoch_sealed_before_the_announcement_is_still_cancellable() { } #[test] -fn an_escrowed_redemption_is_still_cancellable_after_the_announcement() { +fn an_escrowed_redemption_cancelled_in_a_wind_down_joins_the_payout() { let f = setup(); let user = f.holder(500); let epoch = f.vault.request_redeem(&user, &200); @@ -258,7 +258,10 @@ fn an_escrowed_redemption_is_still_cancellable_after_the_announcement() { wind_down_now(&f); assert_eq!(f.vault.cancel_redeem(&user, &epoch), 200); - assert_eq!(f.shares(&user), 500); + assert_eq!(f.shares(&user), 300); + + let pot = f.vault.finalize_wind_down_round(); + assert_eq!(f.vault.claim_wind_down(&user), pot); } #[test] @@ -439,20 +442,51 @@ fn a_holder_with_no_shares_cannot_claim() { } #[test] -fn an_escrowed_redemption_is_recovered_then_surrendered() { +fn a_cancelled_redemption_joins_the_wind_down_payout() { let f = setup(); let user = f.holder(200); let epoch = f.vault.request_redeem(&user, &200); wind_down_now(&f); f.vault.finalize_wind_down_round(); - // The escrowed shares are inside the snapshot, so they must come back first. assert!(f.vault.try_claim_wind_down(&user).is_err()); f.vault.cancel_redeem(&user, &epoch); - let rest = f.vault.claim_wind_down(&user); - assert!(rest > 0); assert_eq!(f.shares(&user), 0); + assert!(f.vault.claim_wind_down(&user) > 0); +} + +#[test] +fn a_token_pause_does_not_hold_a_redemption_in_a_wind_down() { + let f = setup(); + let user = f.holder(200); + let epoch = f.vault.request_redeem(&user, &200); + wind_down_now(&f); + f.vault.finalize_wind_down_round(); + f.share.pause(&f.admin); + + f.vault.cancel_redeem(&user, &epoch); + + assert!(f.vault.claim_wind_down(&user) > 0); +} + +/// A freeze holds everything, even an escrow a wind-down would otherwise surrender. +#[test] +fn a_frozen_holders_redemption_stays_escrowed_in_a_wind_down() { + let f = setup(); + let user = f.holder(200); + let epoch = f.vault.request_redeem(&user, &200); + wind_down_now(&f); + f.vault.finalize_wind_down_round(); + f.share.set_address_frozen(&user, &true, &f.compliance); + + refused(f.vault.try_cancel_redeem(&user, &epoch), VaultError::Frozen); + assert_eq!(f.shares(&f.vault.address), 200); + assert!(f.vault.get_redeem_request(&epoch, &user).is_some()); + + f.share.set_address_frozen(&user, &false, &f.compliance); + f.vault.cancel_redeem(&user, &epoch); + assert_eq!(f.vault.claim_wind_down(&user), 400); } #[test] diff --git a/contracts/async-vault/src/wind_down.rs b/contracts/async-vault/src/wind_down.rs index ca2aed10..c390e8e0 100644 --- a/contracts/async-vault/src/wind_down.rs +++ b/contracts/async-vault/src/wind_down.rs @@ -149,17 +149,40 @@ pub(crate) fn supply_snapshot(e: &Env) -> i128 { storage::get_instance(e, &DataKey::WindDownSupply).unwrap_or(0) } +fn credited_before_snapshot(e: &Env) -> i128 { + storage::get_instance(e, &DataKey::WindDownCredited).unwrap_or(0) +} + /// Shares that exist or are owed: circulating, escrowed against an unpriced -/// redemption, and owed by a deposit that has been priced but not claimed. +/// redemption, owed by a deposit that has been priced but not claimed, and +/// credited straight to a holder's entitlement. fn snapshot_supply(e: &Env) -> i128 { let share_token = state::get_addr(e, &DataKey::ShareToken); FungibleTokenClient::new(e, &share_token) .total_supply() .saturating_sub(state::pending_burn_shares(e)) .checked_add(state::pending_mint_shares(e)) + .and_then(|supply| supply.checked_add(credited_before_snapshot(e))) .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)) } +/// Shares credited before the first round are counted into its snapshot, having left the supply. +pub(crate) fn credit(e: &Env, holder: &Address, shares: i128) { + let mut p = position(e, holder); + p.entitlement = p + .entitlement + .checked_add(shares) + .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)); + storage::set_persistent(e, &DataKey::WindDownPosition(holder.clone()), &p); + + if info(e).map(|wd| wd.round).unwrap_or(0) == 0 { + let credited = credited_before_snapshot(e) + .checked_add(shares) + .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)); + storage::set_instance(e, &DataKey::WindDownCredited, &credited); + } +} + pub(crate) fn finalize_round(e: &Env) -> i128 { let mut wd = info(e).unwrap_or_else(|| panic_with_error!(e, VaultError::WindDownNotActive)); if wd.status != WindDownStatus::Active { diff --git a/scripts/harness/guards.test.ts b/scripts/harness/guards.test.ts index a51550f4..8106e152 100644 --- a/scripts/harness/guards.test.ts +++ b/scripts/harness/guards.test.ts @@ -463,8 +463,8 @@ describe("a de-listed holder", () => { ) expectRefused( await vault(c, first).cancel_redeem({ from, epoch_id: epoch }), - shareToken(c, outsider), - "IdentityVerificationFailed", + vault(c, first), + "NotAllowlisted", ) }) From d4358904fc555a9454e16fc6d690c6f207220302 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:44:43 -0300 Subject: [PATCH 030/147] fix(vault): burn unpaid escrow during a wind-down --- contracts/async-vault/src/redeem.rs | 7 +++++-- contracts/async-vault/src/test/wind_down.rs | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/contracts/async-vault/src/redeem.rs b/contracts/async-vault/src/redeem.rs index ff612bbc..5ce3c7a2 100644 --- a/contracts/async-vault/src/redeem.rs +++ b/contracts/async-vault/src/redeem.rs @@ -85,7 +85,8 @@ pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 { state::reduce(e, state::pending_burn_shares(e), request.shares), ); - if assets == 0 { + // In a wind-down they are burned below instead: its snapshot left them out. + if assets == 0 && !wind_down::is_active(e) { return_shares(e, caller, epoch_id, request.shares); settlement::on_redeem_claimed(e, epoch_id, 0); return 0; @@ -104,7 +105,9 @@ pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 { state::set_redeem_request(e, epoch_id, caller, &request); state::set_committed(e, state::reduce(e, state::committed(e), assets)); - TokenClient::new(e, &asset).transfer(&vault, caller, &assets); + if assets > 0 { + TokenClient::new(e, &asset).transfer(&vault, caller, &assets); + } RWATokenClient::new(e, &state::get_addr(e, &DataKey::ShareToken)).burn( &vault, &request.shares, diff --git a/contracts/async-vault/src/test/wind_down.rs b/contracts/async-vault/src/test/wind_down.rs index b79b9796..2aef274c 100644 --- a/contracts/async-vault/src/test/wind_down.rs +++ b/contracts/async-vault/src/test/wind_down.rs @@ -489,6 +489,26 @@ fn a_frozen_holders_redemption_stays_escrowed_in_a_wind_down() { assert_eq!(f.vault.claim_wind_down(&user), 400); } +/// The snapshot left this escrow out, so its shares must not come back to be surrendered. +#[test] +fn a_redemption_that_pays_nothing_returns_no_shares_in_a_wind_down() { + let f = setup(); + let a = f.holder(200); + let b = f.holder(200); + let epoch = f.vault.request_redeem(&a, &1); + f.fulfill_epoch(WAD_SCALE / 2); + wind_down_now(&f); + assert_eq!(f.vault.finalize_wind_down_round(), 799); + + assert_eq!(f.vault.claim_redeem(&a, &epoch), 0); + let paid = f.vault.claim_wind_down(&a) + f.vault.claim_wind_down(&b); + + assert_eq!(paid, 799); + assert_eq!(f.vault.wind_down_owed(), 0); + assert_eq!(f.shares(&a), 0); + assert_eq!(f.shares(&f.vault.address), 0); +} + #[test] fn the_payouts_never_exceed_what_the_rounds_credited() { let f = setup(); From 338748a997344a12ab3751b56f131a84021a96ed Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:45:17 -0300 Subject: [PATCH 031/147] docs: describe the delisted exit path --- AGENTS.md | 3 ++- docs/COMPLIANCE_MATRIX.md | 30 +++++++++++++++---------- docs/kit-spec.md | 7 ++++-- docs/strata-product-and-architecture.md | 11 ++++----- 4 files changed, 31 insertions(+), 20 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 79d465b7..7b1d7c50 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,8 @@ What the contracts enforce: 2. **A covered claim always pays.** Once priced and covered, a cash claim cannot be blocked by a pause, a stale valuation, or the investor losing their allowlist place. Priced claims are never re-priced and never identity-gated; - a delisted, non-frozen investor leaves through the exit-only cash path. + a delisted, non-frozen investor leaves through the exit-only cash path, + including a deposit that was priced before the delisting. 3. **Cancellation is atomic and single-step**, open only until the attestation that prices the request is accepted. There is no instant exit. diff --git a/docs/COMPLIANCE_MATRIX.md b/docs/COMPLIANCE_MATRIX.md index 5730423a..a75ce99e 100644 --- a/docs/COMPLIANCE_MATRIX.md +++ b/docs/COMPLIANCE_MATRIX.md @@ -21,7 +21,7 @@ Every investor account in the vault ecosystem exists in one of three primary com 3. **Frozen (`Frozen`)**: - The account is subject to sanctions, legal freezing, or security hold (`is_frozen(account) == true` via `ShareToken::set_address_frozen`). - The share token refuses a `transfer` from or to a frozen address. Minting does not check freezes, and `forced_transfer` ignores them. - - A frozen investor is outside the payable-claims guarantee (design document §8.1). The vault does not check freezes today, so a frozen holder's exit still goes through; the kit does not promise it. + - A frozen investor is outside the payable-claims guarantee (design document §8.1). The vault asks the share token whether the address is frozen on `request_deposit`, `claim_deposit` and `cancel_redeem`: it refuses a frozen address's deposit request, and holds its priced deposit and escrowed redemption until the freeze is lifted (`Frozen`, 6071). Its other exits do not check freezes yet, so they still go through; the kit does not promise them. --- @@ -29,11 +29,11 @@ Every investor account in the vault ecosystem exists in one of three primary com | Operation | Allowlisted | De-listed (Non-Frozen) | Frozen (Address or Partial) | Contract Mechanics & Regulatory Rationale | |---|---|---|---|---| -| **`request_deposit`** | ✅ Allowed | ❌ **Refused** | ⚠️ Allowed while still allowlisted | The vault asks the share token's identity verifier before any cash moves and refuses an address the token would not let hold shares (`NotAllowlisted`). Freezes are not checked at this step. | +| **`request_deposit`** | ✅ Allowed | ❌ **Refused** | ❌ **Refused** | Before any cash moves, the vault asks the share token whether the address is frozen and then asks the token's identity verifier. It refuses a frozen address (`Frozen`) and an address the token would not let hold shares (`NotAllowlisted`). A partial freeze is not checked. | | **`cancel_deposit`** | ✅ Allowed | ✅ **Allowed** | ✅ **Allowed** | **Escape Hatch**: Calls `refund_deposit`. Escrowed settlement asset (USDC) is refunded directly to `controller`. Cash refunds bypass share token compliance checks. | -| **`claim_deposit`** | ✅ Allowed | ❌ **Refused** | ⚠️ Minted if allowlisted | `RWATokenClient::mint` calls `verify_identity` and `can_create`, and does not check freezes. A de-listed receiver is refused with `IdentityVerificationFailed`. A frozen receiver still on the allowlist is minted, and cannot transfer those shares while frozen. | +| **`claim_deposit`** | ✅ Allowed | ↪ **Moved to the exit queue** | ⛔ **Held** | A receiver the share token would refuse gets no shares: the vault mints them into its own escrow and adds them to the holder's redemption in the open epoch (`DepositConvertedToExit`), paid in cash at that epoch's price. In a wind-down they join the holder's wind-down entitlement. A receiver whose address is frozen, listed or not, is refused with `Frozen` (6071) and keeps the priced deposit until the freeze is lifted. A partial freeze is not checked. | | **`request_redeem`** | ✅ Allowed | ✅ **Allowed** | ⚠️ Allowed, not guaranteed | **Exit Right**: the vault escrows the shares with `RWATokenClient::forced_transfer`, which checks neither the allowlist nor freezes, so a de-listed holder can start an exit. It also releases as much of a partial freeze as the escrow needs. | -| **`cancel_redeem`** | ✅ Allowed | ❌ **Refused** | ❌ **Refused** | `return_shares` uses standard `FungibleTokenClient::transfer(vault, controller, shares)`, which executes `can_transfer`. Reverts if `controller` is de-listed or frozen. Holder cannot re-acquire shares. Refused with `EnforcedPause` while the share-token transfer pause is active; the request stays pending until the pause is lifted or the epoch prices. | +| **`cancel_redeem`** | ✅ Allowed | ❌ **Refused**; ↪ **surrendered** in a wind-down | ⛔ **Held** | Outside a wind-down the vault refuses a de-listed controller (`NotAllowlisted`) and holds one whose address is frozen (`Frozen`); anyone else gets the shares back through the token's checked `transfer`. That transfer is refused with `EnforcedPause` while the share-token transfer pause is active; the request stays pending until the pause is lifted or the epoch prices. In an active wind-down no shares come back: the escrow is burned into the controller's wind-down entitlement (`RedemptionSurrendered`), allowlisted or not, so no token pause can hold it. A frozen controller's escrow stays until the freeze is lifted. | | **`claim_redeem`** | ✅ Allowed | ✅ **Allowed** | ⚠️ Pays, not guaranteed | **Exit-Only Cash Path**: the settlement asset pays out from the liquid reserve and the escrowed shares are burned from the vault. The cash claim checks neither the allowlist nor freezes. | | **`transfer`** (Secondary) | ✅ Allowed | ❌ **Refused** | ❌ **Refused** | Standard peer-to-peer share transfer via SEP-57. Requires both sender and receiver to be allowlisted and neither to be frozen. | @@ -42,7 +42,7 @@ Every investor account in the vault ecosystem exists in one of three primary com ## 3. Deep Dive into Key Lifecycle Invariants ### 3.1 The Exit-Only Cash Path -A core guarantee of the Strata Vault Kit is that **a de-listed, non-frozen investor can always leave the fund in cash**. A frozen investor is outside this guarantee, as the design document states in §8.1: the vault does not check freezes today, so their exit goes through, but nothing promises it. +A core guarantee of the Strata Vault Kit is that **a de-listed, non-frozen investor can always leave the fund in cash**. A frozen investor is outside this guarantee, as the design document states in §8.1: the cash claim does not check freezes today, so their exit goes through, but nothing promises it. - **Mechanism**: 1. A de-listed holder queues an exit via `request_redeem`. The vault escrows the shares with `forced_transfer`. @@ -60,19 +60,23 @@ If an investor deposits into an open epoch but is de-listed (or KYC expires) bef ### 3.3 De-Listing Between Pricing and Claim If an investor deposits while allowlisted but is de-listed after the epoch is priced: - **The cash is no longer escrow.** Pricing moved it out of the cancellable bucket into the reserve, where it backs the shares the epoch promised. The investor's claim is to those shares, counted in `pending_mint_shares`. -- **`claim_deposit` is refused**: the share token calls `verify_identity` on the receiver when it mints, and fails with `IdentityVerificationFailed`. +- **`claim_deposit` moves the shares to the exit queue.** The share token would refuse to mint to her, so the vault mints the shares into its own escrow and adds them to her redemption in the open epoch. She is paid in cash at that epoch's price, like anyone leaving then, through `claim_redeem`. A fall in the price since her deposit was priced is hers, as it would be for any holder who exits. - **`cancel_deposit` is refused** with `AlreadyPriced`: once an epoch has a price, cancellation is closed. -- **In a wind-down** her shares are counted in the supply snapshot, so her part of each round sits in `owed`, and `claim_wind_down` pays only shares the holder holds. Nobody can collect it. -- **Resolution today**: relisting. Once compliance re-allowlists the address, `claim_deposit` mints the shares and the wind-down pays them (`a_holder_delisted_after_pricing_can_only_leave_by_being_relisted`). +- **In a wind-down**, which prices nothing further, the shares join her wind-down entitlement and `claim_wind_down` pays them. +- **In a wind-down, cancelling any escrowed redemption**, whether from her converted deposit or one she requested before the delisting, burns the escrow into the holder's wind-down entitlement (`RedemptionSurrendered`). No shares come back, for any holder. +- **The conversion needs the vault's own allowlist place**, because the vault mints the shares into its own escrow. If compliance removes the vault's address, her conversion waits until the vault is relisted. Nothing is lost. +- **Relisting first** still works: if compliance re-allowlists her before she claims, `claim_deposit` mints the shares to her. +- **A freeze holds her.** If she is also frozen, `claim_deposit` fails with `Frozen` (6071) and the priced deposit stays unclaimed until the freeze is lifted. An address that was never allowlisted cannot reach this state: `request_deposit` refuses it before any cash moves. ### 3.4 Redemption Cancellation Refusal When an investor requests redemption, their shares are transferred to the vault. If the investor becomes de-listed or frozen before pricing: -- Calling `cancel_redeem` requires transferring shares from the vault back to the investor's account (`return_shares`). -- `ShareToken::transfer` refuses a frozen receiver (`AddressFrozen`) and a receiver off the allowlist (`IdentityVerificationFailed`), so returning the shares is refused. +- Calling `cancel_redeem` would transfer shares from the vault back to the investor's account (`return_shares`). +- The vault asks the share token first and refuses a receiver off the allowlist (`NotAllowlisted`) or holds a frozen one (`Frozen`), so returning the shares is refused. - The investor is not allowed to re-enter share possession. They remain in the queue and exit into cash upon epoch fulfillment. +- In an active wind-down nothing is priced again, so a de-listed investor's cancel burns the escrow into their wind-down entitlement instead. A frozen investor's escrow stays until the freeze is lifted. --- @@ -100,7 +104,8 @@ The behavior specified in this matrix is verified by on-chain automated test sui * `an_investor_who_was_never_allowlisted_is_refused_before_any_cash_moves`: Verifies a non-allowlisted depositor is refused and nothing moves. * `a_delisted_investor_cannot_request_a_deposit`: Verifies a de-listed depositor is refused at request time. * `relisting_after_pricing_lets_the_claim_through`: Verifies KYC restoration unblocks a priced claim. -* `a_holder_delisted_after_pricing_can_only_leave_by_being_relisted`: Records the open case: no claim, no cancel, the wind-down holds her part. +* `a_delisted_holders_priced_deposit_joins_their_redemption_in_the_open_epoch`, `the_delisted_holder_is_paid_at_the_next_epochs_price`, `in_a_wind_down_the_shares_are_credited_before_the_first_round`, `a_conversion_caught_by_a_wind_down_is_paid_from_the_rounds`, `a_frozen_and_delisted_holders_claim_is_held`: Verify the exit path for a holder delisted after pricing ([`test/delisted.rs`](../contracts/async-vault/src/test/delisted.rs)). +* `a_holder_delisted_after_pricing_is_paid_through_a_wind_down`: The combined case, with the cancel refused as `AlreadyPriced` ([`test/compliance.rs`](../contracts/async-vault/src/test/compliance.rs)). * `a_compliance_module_that_refuses_the_mint_blocks_a_priced_claim`: Records that a compliance module can block a priced claim (a trust assumption). * `a_delisted_holder_can_still_queue_an_exit`: Verifies de-listed holders can queue redemption via forced transfer. * `a_delisted_holder_can_still_claim_their_exit`: Verifies de-listed holders always receive cash payout. @@ -108,6 +113,7 @@ The behavior specified in this matrix is verified by on-chain automated test sui * `a_delisted_holder_cannot_cancel_redemption_back_to_shares`: Verifies de-listed holders cannot abort redemption back into shares. * `a_frozen_holder_cannot_cancel_redemption_back_to_shares`: Verifies frozen holders cannot abort redemption back into shares. * `a_frozen_or_delisted_holder_cannot_transfer_shares`: Verifies secondary market transfers are strictly refused for de-listed and frozen accounts. -* `a_frozen_investor_on_the_allowlist_is_still_minted`: Records that minting does not check freezes. +* `a_frozen_investor_cannot_request_a_deposit`: Verifies a frozen depositor is refused at request time. +* `a_frozen_investor_is_not_minted_until_unfrozen`: Verifies a frozen investor's priced deposit is held until the freeze is lifted. * `a_frozen_holder_can_still_queue_an_exit`: Records that escrowing an exit does not check freezes. * `queueing_an_exit_releases_a_partial_freeze`: Records that the escrow releases as much of a partial freeze as it needs. diff --git a/docs/kit-spec.md b/docs/kit-spec.md index e93acf31..13c96114 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -50,7 +50,7 @@ The contracts enforce a set of mathematical and state-machine invariants across - `request_deposit` is refused by `#[when_not_paused]` (`PausableError::EnforcedPause`). - `request_redeem` succeeds and locks shares into escrow. - `claim_redeem` and `claim_deposit` remain open. - - `cancel_redeem` remains open; only the share token's own transfer pause can refuse it (Invariant 6). + - `cancel_redeem` remains open; outside a wind-down only the share token's own transfer pause can refuse it (Invariant 6). - `cancel_deposit` and `cancel_redeem` stay open for a sealed epoch while the vault is paused, because a paused vault cannot price it (Invariant 6). ### Invariant 6: Single-Step Atomic Cancellation @@ -62,7 +62,8 @@ The contracts enforce a set of mathematical and state-machine invariants across - While an epoch is `Open`, or `Pending` with no readable price or with the vault paused, `cancel_deposit` and `cancel_redeem` return 100% of the escrow. - A readable price refuses cancellation with `PriceAvailable`, even before `priceable_at`; once that report expires, cancellation works again (`cancellation_reopens_when_the_price_that_closed_it_expires`). - Pausing the vault reopens cancellation; unpausing closes it again while the price is still readable (`a_paused_vault_reopens_cancellation_of_an_epoch_it_could_price`). - - `cancel_redeem` is refused with `PausableError::EnforcedPause` while the share token's transfer pause is active; the request stays pending and the cash claim remains open. + - Outside a wind-down, `cancel_redeem` is refused with `PausableError::EnforcedPause` while the share token's transfer pause is active; the request stays pending and the cash claim remains open. + - In an active wind-down, `cancel_redeem` never returns shares: the escrow joins the holder's wind-down entitlement (`RedemptionSurrendered`), whether the holder is allowlisted or not. A frozen holder's escrow stays until the freeze is lifted. - Once fulfilled, cancellation is refused with `AlreadyPriced`. - **Operating note**: keep `freshness_duration` longer than the notice, so a normal epoch's price does not lapse and reopen the window on its own. @@ -96,6 +97,8 @@ The contracts enforce a set of mathematical and state-machine invariants across - **Mathematical Statement**: $$\sum_{i} \text{payout}_i \le \text{round\_assets}$$ - **Testable Property**: In any distribution round with arbitrary share distributions and rounding remainders, the vault remains solvent for the last claimant. Active wind-down is strictly irreversible. +- An escrowed redemption cancelled during the wind-down counts toward the holder's entitlement, and so does a priced deposit a delisted holder claims. Credited before the first round, those shares count in its supply snapshot (`a_credit_before_the_first_round_leaves_every_holder_their_share`). +- During an active wind-down, a priced redemption whose claim pays nothing burns its escrowed shares instead of returning them, because the snapshot left them out (`a_redemption_that_pays_nothing_returns_no_shares_in_a_wind_down`). - Each payout floors per holder. Once every holder whose shares were all surrendered has claimed, `wind_down_owed` keeps less than one unit per such holder, plus whatever value the snapshot counted in shares that never actually minted (for example `pending_mint_shares` from an epoch that promised more shares than it later minted). That remainder is not released: a later round lets each holder's floor catch up and needs it (`a_later_round_needs_the_units_an_earlier_round_left_in_owed`, `after_every_holder_claims_owed_keeps_at_most_one_unit_per_holder`, `owed_stays_below_the_holder_count_once_every_share_is_surrendered`). --- diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index 3288965b..72bfddc1 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -310,15 +310,15 @@ batch boundary, though not the price it receives. #### Subscription -- Request: moves the deposit asset into escrow; at most one active request per controller. The vault first asks the share token's identity verifier and refuses an address that could not receive the shares, before any cash moves; the token checks again when it mints at claim time. +- Request: moves the deposit asset into escrow; at most one active request per controller. The vault first asks the share token whether the address is frozen and asks the token's identity verifier, and refuses a frozen address or one that could not receive the shares, before any cash moves; the token checks again when it mints at claim time. - Pricing: the escrow leaves the cancellable bucket, the share quantity is set at the epoch's price, and the economic supply liability is recorded. To avoid unbounded loops that exceed Soroban transaction resource limits, physical share minting occurs lazily when each investor executes `claim_deposit`. - Cancellation: atomic, and returns the escrowed asset in full. Open while the epoch is open. Once sealed, a readable price closes it, even while the notice still runs: an investor who can see the price cannot decline it. If the vault can no longer price the epoch — the report expires, the NAV is paused, or the vault is paused — it opens again, so escrow is never stuck behind a stalled feed. The epoch is then priced at the latest valid report at fulfil time. Cancellation also remains open during an active wind-down, allowing pending depositors to recover their escrowed assets when pricing is closed. -- Share claim: verifies the receiver and delivers the shares via `mint`. If verification fails, the position remains in pending mint and exits through administrative resolution or once allowlisted. No nominal refund exists after pricing. +- Share claim: verifies the receiver and delivers the shares via `mint`. A receiver the token would refuse gets no shares: they join the holder's redemption in the open epoch and are paid in cash at that epoch's price; in a wind-down they join the holder's wind-down entitlement. A frozen receiver is held until the freeze is lifted. No nominal refund exists after pricing. #### Redemption - Request: moves shares into escrow; at most one active request per controller per epoch. -- Cancellation: atomic, and returns the escrowed shares under the same rules as subscription cancellation; stays open during an active wind-down. +- Cancellation: atomic, and returns the escrowed shares under the same rules as subscription cancellation; stays open during an active wind-down, where the escrow joins the holder's wind-down entitlement instead of coming back as shares. A frozen holder's cancellation is held until the freeze is lifted. - Pricing: the fixed cash liability enters committed at the epoch's price. Escrowed shares remain in the vault contract and are burned lazily as each claimant executes `claim_redeem`, preserving constant-bounded resource execution per transaction. Priced claims are never re-priced. - Coverage: a priced claim is payable when the liquid reserve covers that claim's own amount, in any order. An earlier unpaid claim never blocks a later one that is already covered. The gap between committed and liquid reserve is the uncovered amount treasury must top up. - Cash claim: pays the fixed amount; it does not depend on identity. A delisted, non-frozen investor uses the exit-only cash path and cannot cancel back to shares. @@ -448,8 +448,9 @@ interface today. holder who never surrenders their shares is never paid, because no entrypoint may iterate over holders; a refund owed to a deposit priced at zero shares is not tracked as a cash liability, so a round can credit the assets it needed; - and symmetrically, a redemption priced at zero assets before the snapshot - returns its shares outside the wind-down distribution. + and symmetrically, a redemption priced at zero assets pays nothing, and once + the wind-down is active its claim burns the escrowed shares instead of + returning them. - Funding a vault that is winding down cannot be undone. What arrives first covers priced exits and refundable escrow; the surplus is taken by the next round and credited to holders, and no entrypoint returns it. Treasury sends From 7cdc0ccafdf83c118dc9030a3fd185f96d5040b1 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:25:59 -0300 Subject: [PATCH 032/147] fix(vault): address delisted exit review round 1 --- contracts/async-vault/src/deposit.rs | 44 +++++++++++++------- contracts/async-vault/src/error.rs | 7 ++-- contracts/async-vault/src/lib.rs | 3 ++ contracts/async-vault/src/redeem.rs | 5 +-- contracts/async-vault/src/test/compliance.rs | 21 ++++++++++ contracts/async-vault/src/test/delisted.rs | 18 ++++++++ docs/COMPLIANCE_MATRIX.md | 2 +- docs/strata-product-and-architecture.md | 2 +- 8 files changed, 80 insertions(+), 22 deletions(-) diff --git a/contracts/async-vault/src/deposit.rs b/contracts/async-vault/src/deposit.rs index 67f1bdaf..7104cdb7 100644 --- a/contracts/async-vault/src/deposit.rs +++ b/contracts/async-vault/src/deposit.rs @@ -5,7 +5,7 @@ use crate::epoch; use crate::error::VaultError; use crate::event::{DepositCancelled, DepositClaimed, DepositConvertedToExit, DepositRequested}; use crate::keys::DataKey; -use crate::lock_policy::{self, Decision, Exit}; +use crate::lock_policy::{self, Decision, Exit, Standing}; use crate::pricing::{Pricing, PricingScheme}; use crate::redeem; use crate::settlement; @@ -104,8 +104,11 @@ pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 { let standing = lock_policy::standing(e, caller); let decision = lock_policy::decide(Exit::ClaimDeposit, standing, wind_down::is_active(e)); - if matches!(decision, Decision::Refuse | Decision::Hold) { - panic_with_error!(e, lock_policy::refused_with(standing)); + match decision { + Decision::Proceed | Decision::ConvertToExit | Decision::Surrender => {} + Decision::Refuse | Decision::Hold => { + panic_with_error!(e, lock_policy::refused_with(standing)) + } } let shares = Pricing::deposit_shares(e, request.amount, epoch.share_price) @@ -128,7 +131,7 @@ pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 { state::set_pending_mint_shares(e, state::reduce(e, state::pending_mint_shares(e), shares)); if decision != Decision::Proceed { - convert_to_exit(e, caller, epoch_id, shares, decision); + convert_to_exit(e, caller, epoch_id, shares, standing, decision); return 0; } @@ -147,16 +150,29 @@ pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 { shares } -/// The token refuses this holder shares, so they exit in cash: next price, or the wind-down payout. -fn convert_to_exit(e: &Env, holder: &Address, epoch_id: u64, shares: i128, decision: Decision) { - let into_epoch = if decision == Decision::Surrender { - wind_down::credit(e, holder, shares); - 0 - } else { - let vault = e.current_contract_address(); - let share_token = state::get_addr(e, &DataKey::ShareToken); - RWATokenClient::new(e, &share_token).mint(&vault, &shares, &vault); - redeem::queue_escrowed(e, holder, shares) +/// The holder is off the allowlist, so the shares exit in cash: next price, or the wind-down. +fn convert_to_exit( + e: &Env, + holder: &Address, + epoch_id: u64, + shares: i128, + standing: Standing, + decision: Decision, +) { + let into_epoch = match decision { + Decision::ConvertToExit => { + let vault = e.current_contract_address(); + let share_token = state::get_addr(e, &DataKey::ShareToken); + RWATokenClient::new(e, &share_token).mint(&vault, &shares, &vault); + redeem::queue_escrowed(e, holder, shares) + } + Decision::Surrender => { + wind_down::credit(e, holder, shares); + 0 + } + Decision::Proceed | Decision::Refuse | Decision::Hold => { + panic_with_error!(e, lock_policy::refused_with(standing)) + } }; DepositConvertedToExit { diff --git a/contracts/async-vault/src/error.rs b/contracts/async-vault/src/error.rs index 47ab3c25..efd64132 100644 --- a/contracts/async-vault/src/error.rs +++ b/contracts/async-vault/src/error.rs @@ -115,12 +115,13 @@ pub enum VaultError { /// As many changes as the timelock holds already wait. Apply or cancel one /// first. TooManyPendingChanges = 6063, - /// The depositor fails the share token's identity check, so the vault - /// could never deliver the shares. Refused before any cash moves. + /// The holder fails the share token's identity check, so the vault takes no + /// deposit request from them and returns no escrowed shares to them. NotAllowlisted = 6068, /// A running total would go below zero, which means the vault's books /// disagree with themselves. Fails loudly instead of clamping to zero. AccountingMismatch = 6070, - /// The holder is frozen: nothing moves for them until the freeze is lifted. + /// The holder is frozen: a deposit request is refused, and a share claim or + /// a redemption cancel waits until the freeze is lifted. Frozen = 6071, } diff --git a/contracts/async-vault/src/lib.rs b/contracts/async-vault/src/lib.rs index 5b5b3f2a..657677dc 100644 --- a/contracts/async-vault/src/lib.rs +++ b/contracts/async-vault/src/lib.rs @@ -344,6 +344,9 @@ impl AsyncVault { deposit::request(e, &from, amount) } + /// Returns the shares minted to the caller. Zero means none were: a deposit + /// too small for one share was refunded, or the caller is off the allowlist + /// and the shares joined an exit instead (`DepositConvertedToExit`). pub fn claim_deposit(e: &Env, caller: Address, epoch_id: u64) -> i128 { deposit::claim(e, &caller, epoch_id) } diff --git a/contracts/async-vault/src/redeem.rs b/contracts/async-vault/src/redeem.rs index 5ce3c7a2..21d0dbdd 100644 --- a/contracts/async-vault/src/redeem.rs +++ b/contracts/async-vault/src/redeem.rs @@ -158,9 +158,8 @@ pub(crate) fn queue_escrowed(e: &Env, controller: &Address, shares: i128) -> u64 } /// Moves escrowed shares back to the controller and clears the request. The -/// checked transfer is deliberate: the share token decides whether this holder -/// may receive a regulated share, so a frozen or de-listed controller is refused -/// here and exits through the cash claim instead. +/// transfer is the token's checked one: a cancel asks the lock policy first, but +/// on a zero-asset claim the token alone refuses a frozen or delisted controller. fn return_shares(e: &Env, controller: &Address, epoch_id: u64, shares: i128) { let mut epoch = state::get_epoch(e, epoch_id) .unwrap_or_else(|| panic_with_error!(e, VaultError::EpochNotFound)); diff --git a/contracts/async-vault/src/test/compliance.rs b/contracts/async-vault/src/test/compliance.rs index 7c42c258..4992fd11 100644 --- a/contracts/async-vault/src/test/compliance.rs +++ b/contracts/async-vault/src/test/compliance.rs @@ -243,6 +243,27 @@ fn a_frozen_investor_is_not_minted_until_unfrozen() { f.share.set_address_frozen(&investor, &false, &f.compliance); assert_eq!(f.vault.claim_deposit(&investor, &1), 200); + assert_eq!(f.shares(&investor), 200); +} + +#[test] +fn a_frozen_investors_zero_share_refund_is_held() { + let f = setup(); + let investor = f.investor(10); + let epoch = f.vault.request_deposit(&investor, &1); + f.fulfill_epoch(wad(2)); + f.share.set_address_frozen(&investor, &true, &f.compliance); + + refused( + f.vault.try_claim_deposit(&investor, &epoch), + VaultError::Frozen, + ); + assert_eq!(f.balance(&investor), 9); + assert!(f.vault.get_deposit_request(&epoch, &investor).is_some()); + + f.share.set_address_frozen(&investor, &false, &f.compliance); + assert_eq!(f.vault.claim_deposit(&investor, &epoch), 0); + assert_eq!(f.balance(&investor), 10); } #[test] diff --git a/contracts/async-vault/src/test/delisted.rs b/contracts/async-vault/src/test/delisted.rs index 8209ed9b..0814b8bc 100644 --- a/contracts/async-vault/src/test/delisted.rs +++ b/contracts/async-vault/src/test/delisted.rs @@ -111,6 +111,24 @@ fn in_a_wind_down_the_shares_are_credited_before_the_first_round() { f.vault.activate_wind_down(); assert_eq!(f.vault.claim_deposit(&investor, &epoch), 0); + assert_eq!( + f.e.events().all().filter_by_contract(&f.vault.address), + [ + DepositConvertedToExit { + controller: investor.clone(), + epoch, + into_epoch: 0, + shares: 200, + } + .to_xdr(&f.e, &f.vault.address), + EpochReconciled { + epoch, + share_dust: 0, + cash_dust: 0, + } + .to_xdr(&f.e, &f.vault.address), + ] + ); assert_eq!(f.vault.finalize_wind_down_round(), 400); assert_eq!(f.vault.claim_wind_down(&investor), 400); diff --git a/docs/COMPLIANCE_MATRIX.md b/docs/COMPLIANCE_MATRIX.md index a75ce99e..67b38cdc 100644 --- a/docs/COMPLIANCE_MATRIX.md +++ b/docs/COMPLIANCE_MATRIX.md @@ -31,7 +31,7 @@ Every investor account in the vault ecosystem exists in one of three primary com |---|---|---|---|---| | **`request_deposit`** | ✅ Allowed | ❌ **Refused** | ❌ **Refused** | Before any cash moves, the vault asks the share token whether the address is frozen and then asks the token's identity verifier. It refuses a frozen address (`Frozen`) and an address the token would not let hold shares (`NotAllowlisted`). A partial freeze is not checked. | | **`cancel_deposit`** | ✅ Allowed | ✅ **Allowed** | ✅ **Allowed** | **Escape Hatch**: Calls `refund_deposit`. Escrowed settlement asset (USDC) is refunded directly to `controller`. Cash refunds bypass share token compliance checks. | -| **`claim_deposit`** | ✅ Allowed | ↪ **Moved to the exit queue** | ⛔ **Held** | A receiver the share token would refuse gets no shares: the vault mints them into its own escrow and adds them to the holder's redemption in the open epoch (`DepositConvertedToExit`), paid in cash at that epoch's price. In a wind-down they join the holder's wind-down entitlement. A receiver whose address is frozen, listed or not, is refused with `Frozen` (6071) and keeps the priced deposit until the freeze is lifted. A partial freeze is not checked. | +| **`claim_deposit`** | ✅ Allowed | ↪ **Moved to the exit queue** | ⛔ **Held** | A receiver off the allowlist gets no shares: the vault mints them into its own escrow and adds them to the holder's redemption in the open epoch (`DepositConvertedToExit`), paid in cash at that epoch's price. In a wind-down they join the holder's wind-down entitlement. A compliance module that refuses the mint (`can_create`) still blocks the claim. A receiver whose address is frozen, listed or not, is refused with `Frozen` (6071) and keeps the priced deposit until the freeze is lifted. A partial freeze is not checked. | | **`request_redeem`** | ✅ Allowed | ✅ **Allowed** | ⚠️ Allowed, not guaranteed | **Exit Right**: the vault escrows the shares with `RWATokenClient::forced_transfer`, which checks neither the allowlist nor freezes, so a de-listed holder can start an exit. It also releases as much of a partial freeze as the escrow needs. | | **`cancel_redeem`** | ✅ Allowed | ❌ **Refused**; ↪ **surrendered** in a wind-down | ⛔ **Held** | Outside a wind-down the vault refuses a de-listed controller (`NotAllowlisted`) and holds one whose address is frozen (`Frozen`); anyone else gets the shares back through the token's checked `transfer`. That transfer is refused with `EnforcedPause` while the share-token transfer pause is active; the request stays pending until the pause is lifted or the epoch prices. In an active wind-down no shares come back: the escrow is burned into the controller's wind-down entitlement (`RedemptionSurrendered`), allowlisted or not, so no token pause can hold it. A frozen controller's escrow stays until the freeze is lifted. | | **`claim_redeem`** | ✅ Allowed | ✅ **Allowed** | ⚠️ Pays, not guaranteed | **Exit-Only Cash Path**: the settlement asset pays out from the liquid reserve and the escrowed shares are burned from the vault. The cash claim checks neither the allowlist nor freezes. | diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index 72bfddc1..89675773 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -313,7 +313,7 @@ batch boundary, though not the price it receives. - Request: moves the deposit asset into escrow; at most one active request per controller. The vault first asks the share token whether the address is frozen and asks the token's identity verifier, and refuses a frozen address or one that could not receive the shares, before any cash moves; the token checks again when it mints at claim time. - Pricing: the escrow leaves the cancellable bucket, the share quantity is set at the epoch's price, and the economic supply liability is recorded. To avoid unbounded loops that exceed Soroban transaction resource limits, physical share minting occurs lazily when each investor executes `claim_deposit`. - Cancellation: atomic, and returns the escrowed asset in full. Open while the epoch is open. Once sealed, a readable price closes it, even while the notice still runs: an investor who can see the price cannot decline it. If the vault can no longer price the epoch — the report expires, the NAV is paused, or the vault is paused — it opens again, so escrow is never stuck behind a stalled feed. The epoch is then priced at the latest valid report at fulfil time. Cancellation also remains open during an active wind-down, allowing pending depositors to recover their escrowed assets when pricing is closed. -- Share claim: verifies the receiver and delivers the shares via `mint`. A receiver the token would refuse gets no shares: they join the holder's redemption in the open epoch and are paid in cash at that epoch's price; in a wind-down they join the holder's wind-down entitlement. A frozen receiver is held until the freeze is lifted. No nominal refund exists after pricing. +- Share claim: verifies the receiver and delivers the shares via `mint`. A receiver off the allowlist gets no shares: they join the holder's redemption in the open epoch and are paid in cash at that epoch's price; in a wind-down they join the holder's wind-down entitlement. A frozen receiver is held until the freeze is lifted. No nominal refund exists after pricing. #### Redemption From e746950780fda1144d22f4712580a36ff7e3b701 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 05:13:14 -0300 Subject: [PATCH 033/147] feat(vault): hold a frozen holder's exits --- contracts/async-vault/src/deposit.rs | 8 ++ contracts/async-vault/src/error.rs | 5 +- contracts/async-vault/src/lock_policy.rs | 29 ++++- contracts/async-vault/src/redeem.rs | 27 +++- contracts/async-vault/src/test/compliance.rs | 81 +++++++++--- contracts/async-vault/src/test/lock_policy.rs | 119 ++++++++++++++++++ contracts/async-vault/src/test/wind_down.rs | 59 +++++++++ contracts/async-vault/src/wind_down.rs | 16 ++- docs/AUTHORIZATION_MATRIX.md | 2 +- docs/COMPLIANCE_MATRIX.md | 27 ++-- docs/kit-spec.md | 2 +- docs/strata-product-and-architecture.md | 7 +- 12 files changed, 343 insertions(+), 39 deletions(-) diff --git a/contracts/async-vault/src/deposit.rs b/contracts/async-vault/src/deposit.rs index 7104cdb7..d6421414 100644 --- a/contracts/async-vault/src/deposit.rs +++ b/contracts/async-vault/src/deposit.rs @@ -226,6 +226,14 @@ pub(crate) fn cancel(e: &Env, from: &Address, epoch_id: u64) -> i128 { let request = state::get_deposit_request(e, epoch_id, from) .unwrap_or_else(|| panic_with_error!(e, VaultError::RequestNotFound)); + let standing = lock_policy::standing(e, from); + match lock_policy::decide(Exit::CancelDeposit, standing, wind_down::is_active(e)) { + Decision::Proceed => {} + Decision::Refuse | Decision::Hold | Decision::ConvertToExit | Decision::Surrender => { + panic_with_error!(e, lock_policy::refused_with(standing)) + } + } + state::set_cancellable_escrow( e, state::reduce(e, state::cancellable_escrow(e), request.amount), diff --git a/contracts/async-vault/src/error.rs b/contracts/async-vault/src/error.rs index efd64132..bedafb71 100644 --- a/contracts/async-vault/src/error.rs +++ b/contracts/async-vault/src/error.rs @@ -121,7 +121,8 @@ pub enum VaultError { /// A running total would go below zero, which means the vault's books /// disagree with themselves. Fails loudly instead of clamping to zero. AccountingMismatch = 6070, - /// The holder is frozen: a deposit request is refused, and a share claim or - /// a redemption cancel waits until the freeze is lifted. + /// The holder is frozen. What the vault owes them is held, not paid, + /// returned or moved, until compliance lifts the freeze. A partial freeze + /// refuses only what would touch frozen shares. Frozen = 6071, } diff --git a/contracts/async-vault/src/lock_policy.rs b/contracts/async-vault/src/lock_policy.rs index 3f94d4eb..ec51cd00 100644 --- a/contracts/async-vault/src/lock_policy.rs +++ b/contracts/async-vault/src/lock_policy.rs @@ -1,5 +1,8 @@ use soroban_sdk::{Address, Env}; -use stellar_tokens::rwa::{identity_verification::IdentityVerifierClient, RWATokenClient}; +use stellar_tokens::{ + fungible::FungibleTokenClient, + rwa::{identity_verification::IdentityVerifierClient, RWATokenClient}, +}; use crate::error::VaultError; use crate::keys::DataKey; @@ -33,6 +36,10 @@ pub(crate) enum Exit { RequestDeposit, ClaimDeposit, CancelRedeem, + CancelDeposit, + RequestRedeem, + ClaimRedeem, + ClaimWindDown, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -62,6 +69,18 @@ pub(crate) fn decide(exit: Exit, standing: Standing, winding_down: bool) -> Deci (Exit::CancelRedeem, Standing::Delisted, false) => Decision::Refuse, (Exit::CancelRedeem, Standing::Listed | Standing::Delisted, true) => Decision::Surrender, (Exit::CancelRedeem, Standing::Frozen, _) => Decision::Hold, + + (Exit::CancelDeposit, Standing::Listed | Standing::Delisted, _) => Decision::Proceed, + (Exit::CancelDeposit, Standing::Frozen, _) => Decision::Hold, + + (Exit::RequestRedeem, Standing::Listed | Standing::Delisted, _) => Decision::Proceed, + (Exit::RequestRedeem, Standing::Frozen, _) => Decision::Hold, + + (Exit::ClaimRedeem, Standing::Listed | Standing::Delisted, _) => Decision::Proceed, + (Exit::ClaimRedeem, Standing::Frozen, _) => Decision::Hold, + + (Exit::ClaimWindDown, Standing::Listed | Standing::Delisted, _) => Decision::Proceed, + (Exit::ClaimWindDown, Standing::Frozen, _) => Decision::Hold, } } @@ -72,3 +91,11 @@ pub(crate) fn refused_with(standing: Standing) -> VaultError { Standing::Listed | Standing::Delisted => VaultError::NotAllowlisted, } } + +/// Shares the holder can move: the balance less any partial freeze. +pub(crate) fn free_shares(e: &Env, holder: &Address) -> i128 { + let token = state::get_addr(e, &DataKey::ShareToken); + let balance = FungibleTokenClient::new(e, &token).balance(holder); + let frozen = RWATokenClient::new(e, &token).get_frozen_tokens(holder); + (balance - frozen).max(0) +} diff --git a/contracts/async-vault/src/redeem.rs b/contracts/async-vault/src/redeem.rs index 21d0dbdd..7fd22789 100644 --- a/contracts/async-vault/src/redeem.rs +++ b/contracts/async-vault/src/redeem.rs @@ -20,6 +20,19 @@ pub(crate) fn request(e: &Env, from: &Address, shares: i128) -> u64 { panic_with_error!(e, VaultError::InvalidAmount); } + let standing = lock_policy::standing(e, from); + match lock_policy::decide(Exit::RequestRedeem, standing, wind_down::is_active(e)) { + Decision::Proceed => {} + Decision::Refuse | Decision::Hold | Decision::ConvertToExit | Decision::Surrender => { + panic_with_error!(e, lock_policy::refused_with(standing)) + } + } + // The escrow transfer releases a partial freeze, so frozen shares are + // kept out of it here. + if shares > lock_policy::free_shares(e, from) { + panic_with_error!(e, VaultError::Frozen); + } + let epoch_id = state::current_epoch(e); if state::get_redeem_request(e, epoch_id, from).is_some() { @@ -77,6 +90,14 @@ pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 { panic_with_error!(e, VaultError::AlreadyClaimed); } + let standing = lock_policy::standing(e, caller); + match lock_policy::decide(Exit::ClaimRedeem, standing, wind_down::is_active(e)) { + Decision::Proceed => {} + Decision::Refuse | Decision::Hold | Decision::ConvertToExit | Decision::Surrender => { + panic_with_error!(e, lock_policy::refused_with(standing)) + } + } + let assets = Pricing::redeem_assets(e, request.shares, epoch.share_price) .unwrap_or_else(|err| panic_with_error!(e, err)); @@ -157,9 +178,9 @@ pub(crate) fn queue_escrowed(e: &Env, controller: &Address, shares: i128) -> u64 epoch_id } -/// Moves escrowed shares back to the controller and clears the request. The -/// transfer is the token's checked one: a cancel asks the lock policy first, but -/// on a zero-asset claim the token alone refuses a frozen or delisted controller. +/// Moves escrowed shares back to the controller and clears the request. Both +/// callers ask the lock policy first; a delisted controller still meets the +/// token's own checked transfer here. fn return_shares(e: &Env, controller: &Address, epoch_id: u64, shares: i128) { let mut epoch = state::get_epoch(e, epoch_id) .unwrap_or_else(|| panic_with_error!(e, VaultError::EpochNotFound)); diff --git a/contracts/async-vault/src/test/compliance.rs b/contracts/async-vault/src/test/compliance.rs index 4992fd11..f908835f 100644 --- a/contracts/async-vault/src/test/compliance.rs +++ b/contracts/async-vault/src/test/compliance.rs @@ -177,23 +177,21 @@ fn a_delisted_holder_cannot_cancel_redemption_back_to_shares() { } #[test] -fn a_frozen_holder_cannot_cancel_redemption_back_to_shares() { +fn a_frozen_holder_is_paid_only_after_the_freeze_lifts() { let f = setup(); let user = f.holder(500); let epoch = f.vault.request_redeem(&user, &200); - - // Freeze address f.share.set_address_frozen(&user, &true, &f.compliance); - assert!(f.share.is_frozen(&user)); - // Cannot return shares to a frozen account refused(f.vault.try_cancel_redeem(&user, &epoch), VaultError::Frozen); + f.fulfill_epoch(wad(2)); + refused(f.vault.try_claim_redeem(&user, &epoch), VaultError::Frozen); + assert_eq!(f.vault.committed(), 400); assert_eq!(f.shares(&user), 300); - // Exit proceeds through cash claim once fulfilled - f.fulfill_epoch(wad(2)); + f.share.set_address_frozen(&user, &false, &f.compliance); assert_eq!(f.vault.claim_redeem(&user, &epoch), 400); - assert_eq!(f.balance(&user), 400); + assert_eq!(f.vault.committed(), 0); } #[test] @@ -267,25 +265,74 @@ fn a_frozen_investors_zero_share_refund_is_held() { } #[test] -fn a_frozen_holder_can_still_queue_an_exit() { +fn a_frozen_holder_cannot_queue_an_exit() { let f = setup(); let user = f.holder(500); - let epoch = f.vault.current_epoch(); - f.share.set_address_frozen(&user, &true, &f.compliance); - assert_eq!(f.vault.request_redeem(&user, &200), epoch); - assert_eq!(f.shares(&f.vault.address), 200); + refused(f.vault.try_request_redeem(&user, &200), VaultError::Frozen); + assert_eq!(f.shares(&user), 500); + assert_eq!(f.shares(&f.vault.address), 0); } #[test] -fn queueing_an_exit_releases_a_partial_freeze() { +fn a_partial_freeze_limits_an_exit_to_the_free_shares() { let f = setup(); let user = f.holder(500); f.share.freeze_partial_tokens(&user, &400, &f.compliance); - f.vault.request_redeem(&user, &300); + refused(f.vault.try_request_redeem(&user, &101), VaultError::Frozen); + f.vault.request_redeem(&user, &100); + + assert_eq!(f.shares(&user), 400); + assert_eq!(f.share.get_frozen_tokens(&user), 400); +} + +#[test] +fn a_frozen_investors_escrow_stays_until_the_freeze_lifts() { + let f = setup(); + let investor = f.investor(1_000); + let epoch = f.vault.request_deposit(&investor, &400); + f.share.set_address_frozen(&investor, &true, &f.compliance); + + refused( + f.vault.try_cancel_deposit(&investor, &epoch), + VaultError::Frozen, + ); + assert_eq!(f.vault.cancellable_escrow(), 400); + + f.share.set_address_frozen(&investor, &false, &f.compliance); + assert_eq!(f.vault.cancel_deposit(&investor, &epoch), 400); +} + +#[test] +fn a_frozen_holder_does_not_get_a_worthless_exit_back() { + let f = setup(); + let user = f.holder(1); + let epoch = f.vault.request_redeem(&user, &1); + f.fulfill_epoch(WAD_SCALE / 2); + f.share.set_address_frozen(&user, &true, &f.compliance); + + refused(f.vault.try_claim_redeem(&user, &epoch), VaultError::Frozen); + assert_eq!(f.vault.pending_burn_shares(), 1); + assert!(f.vault.get_redeem_request(&epoch, &user).is_some()); +} + +#[test] +fn a_frozen_holder_is_not_moved_into_the_exit_queue() { + let f = setup(); + let investor = f.investor(1_000); + let epoch = f.vault.request_deposit(&investor, &400); + f.fulfill_epoch(wad(2)); + f.identity.allow(&investor, &false, &f.compliance); + f.share.set_address_frozen(&investor, &true, &f.compliance); - assert_eq!(f.shares(&user), 200); - assert_eq!(f.share.get_frozen_tokens(&user), 200); + refused( + f.vault.try_claim_deposit(&investor, &epoch), + VaultError::Frozen, + ); + assert!(f + .vault + .get_redeem_request(&f.vault.current_epoch(), &investor) + .is_none()); } diff --git a/contracts/async-vault/src/test/lock_policy.rs b/contracts/async-vault/src/test/lock_policy.rs index 2dedef3c..715ed129 100644 --- a/contracts/async-vault/src/test/lock_policy.rs +++ b/contracts/async-vault/src/test/lock_policy.rs @@ -128,6 +128,102 @@ fn in_a_wind_down_a_frozen_holders_redemption_is_held() { ); } +#[test] +fn a_listed_holder_may_cancel_a_deposit() { + assert_eq!( + in_and_out_of_a_wind_down(Exit::CancelDeposit, Standing::Listed), + [Decision::Proceed; 2] + ); +} + +#[test] +fn a_delisted_holder_may_cancel_a_deposit() { + assert_eq!( + in_and_out_of_a_wind_down(Exit::CancelDeposit, Standing::Delisted), + [Decision::Proceed; 2] + ); +} + +#[test] +fn a_frozen_holders_deposit_cancellation_is_held() { + assert_eq!( + in_and_out_of_a_wind_down(Exit::CancelDeposit, Standing::Frozen), + [Decision::Hold; 2] + ); +} + +#[test] +fn a_listed_holder_may_request_a_redemption() { + assert_eq!( + in_and_out_of_a_wind_down(Exit::RequestRedeem, Standing::Listed), + [Decision::Proceed; 2] + ); +} + +#[test] +fn a_delisted_holder_may_request_a_redemption() { + assert_eq!( + in_and_out_of_a_wind_down(Exit::RequestRedeem, Standing::Delisted), + [Decision::Proceed; 2] + ); +} + +#[test] +fn a_frozen_holder_may_not_request_a_redemption() { + assert_eq!( + in_and_out_of_a_wind_down(Exit::RequestRedeem, Standing::Frozen), + [Decision::Hold; 2] + ); +} + +#[test] +fn a_listed_holder_claims_the_redemption() { + assert_eq!( + in_and_out_of_a_wind_down(Exit::ClaimRedeem, Standing::Listed), + [Decision::Proceed; 2] + ); +} + +#[test] +fn a_delisted_holder_claims_the_redemption() { + assert_eq!( + in_and_out_of_a_wind_down(Exit::ClaimRedeem, Standing::Delisted), + [Decision::Proceed; 2] + ); +} + +#[test] +fn a_frozen_holders_redemption_claim_is_held() { + assert_eq!( + in_and_out_of_a_wind_down(Exit::ClaimRedeem, Standing::Frozen), + [Decision::Hold; 2] + ); +} + +#[test] +fn a_listed_holder_claims_the_wind_down() { + assert_eq!( + in_and_out_of_a_wind_down(Exit::ClaimWindDown, Standing::Listed), + [Decision::Proceed; 2] + ); +} + +#[test] +fn a_delisted_holder_claims_the_wind_down() { + assert_eq!( + in_and_out_of_a_wind_down(Exit::ClaimWindDown, Standing::Delisted), + [Decision::Proceed; 2] + ); +} + +#[test] +fn a_frozen_holders_wind_down_claim_is_held() { + assert_eq!( + in_and_out_of_a_wind_down(Exit::ClaimWindDown, Standing::Frozen), + [Decision::Hold; 2] + ); +} + #[test] fn a_frozen_holder_is_refused_as_frozen() { assert_eq!( @@ -202,3 +298,26 @@ fn standing_is_frozen_for_a_holder_both_frozen_and_delisted() { Standing::Frozen ); } + +#[test] +fn free_shares_is_the_full_balance_when_nothing_is_frozen() { + let f = setup(); + let holder = f.holder(500); + + assert_eq!( + f.e.as_contract(&f.vault.address, || lock_policy::free_shares(&f.e, &holder)), + 500 + ); +} + +#[test] +fn free_shares_excludes_a_partial_freeze() { + let f = setup(); + let holder = f.holder(500); + f.share.freeze_partial_tokens(&holder, &400, &f.compliance); + + assert_eq!( + f.e.as_contract(&f.vault.address, || lock_policy::free_shares(&f.e, &holder)), + 100 + ); +} diff --git a/contracts/async-vault/src/test/wind_down.rs b/contracts/async-vault/src/test/wind_down.rs index 2aef274c..b22073a0 100644 --- a/contracts/async-vault/src/test/wind_down.rs +++ b/contracts/async-vault/src/test/wind_down.rs @@ -759,3 +759,62 @@ proptest! { prop_assert!(f.vault.wind_down_owed() < holders.len() as i128); } } + +#[test] +fn a_frozen_holders_slice_stays_owed_until_the_freeze_lifts() { + let f = setup(); + let frozen = f.holder(500); + let other = f.holder(500); + wind_down_now(&f); + f.vault.finalize_wind_down_round(); + f.share.set_address_frozen(&frozen, &true, &f.compliance); + + refused(f.vault.try_claim_wind_down(&frozen), VaultError::Frozen); + let paid = f.vault.claim_wind_down(&other); + assert_eq!(f.vault.wind_down_owed(), paid); + assert_eq!(f.shares(&frozen), 500); + + f.share.set_address_frozen(&frozen, &false, &f.compliance); + assert_eq!(f.vault.claim_wind_down(&frozen), paid); + assert_eq!(f.vault.wind_down_owed(), 0); +} + +#[test] +fn a_frozen_holders_escrowed_exit_waits_through_a_wind_down() { + let f = setup(); + let frozen = f.holder(500); + let _other = f.holder(500); + let epoch = f.vault.request_redeem(&frozen, &500); + f.share.set_address_frozen(&frozen, &true, &f.compliance); + wind_down_now(&f); + f.vault.finalize_wind_down_round(); + + refused( + f.vault.try_cancel_redeem(&frozen, &epoch), + VaultError::Frozen, + ); + refused(f.vault.try_claim_wind_down(&frozen), VaultError::Frozen); + + f.share.set_address_frozen(&frozen, &false, &f.compliance); + assert_eq!(f.vault.cancel_redeem(&frozen, &epoch), 500); + assert!(f.vault.claim_wind_down(&frozen) > 0); +} + +#[test] +fn a_partial_freeze_keeps_frozen_shares_out_of_a_wind_down_claim() { + let f = setup(); + let holder = f.holder(500); + let _other = f.holder(500); + wind_down_now(&f); + f.vault.finalize_wind_down_round(); + f.share.freeze_partial_tokens(&holder, &400, &f.compliance); + + let first = f.vault.claim_wind_down(&holder); + + assert_eq!(f.shares(&holder), 400); + assert_eq!(f.share.get_frozen_tokens(&holder), 400); + f.share + .unfreeze_partial_tokens(&holder, &400, &f.compliance); + let rest = f.vault.claim_wind_down(&holder); + assert_eq!(rest, 4 * first); +} diff --git a/contracts/async-vault/src/wind_down.rs b/contracts/async-vault/src/wind_down.rs index c390e8e0..f808d58c 100644 --- a/contracts/async-vault/src/wind_down.rs +++ b/contracts/async-vault/src/wind_down.rs @@ -8,6 +8,7 @@ use crate::event::{ WindDownProposed, WindDownRoundFinalized, }; use crate::keys::DataKey; +use crate::lock_policy::{self, Decision, Exit}; use crate::state; use crate::treasury; @@ -258,8 +259,7 @@ pub(crate) fn claimable(e: &Env, holder: &Address) -> i128 { return 0; } - let share_token = state::get_addr(e, &DataKey::ShareToken); - let held = FungibleTokenClient::new(e, &share_token).balance(holder); + let held = lock_policy::free_shares(e, holder); let p = position(e, holder); let entitlement = p.entitlement.saturating_add(held); @@ -276,10 +276,20 @@ pub(crate) fn claim(e: &Env, holder: &Address) -> i128 { panic_with_error!(e, VaultError::DistributionNotStarted); } + let standing = lock_policy::standing(e, holder); + match lock_policy::decide(Exit::ClaimWindDown, standing, is_active(e)) { + Decision::Proceed => {} + Decision::Refuse | Decision::Hold | Decision::ConvertToExit | Decision::Surrender => { + panic_with_error!(e, lock_policy::refused_with(standing)) + } + } + let vault = e.current_contract_address(); let share_token = state::get_addr(e, &DataKey::ShareToken); - let surrendered = FungibleTokenClient::new(e, &share_token).balance(holder); + // A partial freeze keeps its shares out of the surrender; they earn every + // round once the freeze is lifted and the holder claims again. + let surrendered = lock_policy::free_shares(e, holder); let mut p = position(e, holder); p.entitlement = p .entitlement diff --git a/docs/AUTHORIZATION_MATRIX.md b/docs/AUTHORIZATION_MATRIX.md index 27a565f9..20198a02 100644 --- a/docs/AUTHORIZATION_MATRIX.md +++ b/docs/AUTHORIZATION_MATRIX.md @@ -8,7 +8,7 @@ This document provides the complete authorization and role model specification f 1. **Direct Role Membership Checks**: Role membership is checked directly via `has_role(e, &caller, &role)` or `stellar_macros::only_role` / `only_admin`, never by enumerating members. 2. **Strict Caller Authentication**: No entrypoint takes a caller argument that it does not authenticate. Every function accepting a `caller: Address` executes `caller.require_auth()`. -3. **Exit-Only Guarantee**: A covered redemption claim always pays out cash, regardless of pause, staleness, or allowlist status. Delisted or frozen investors exit through the cash path and are prevented from re-entering share circulation. +3. **Exit guarantee, with one hold**: A covered redemption claim pays out cash regardless of pause, staleness or allowlist status. A delisted investor exits through the cash path and cannot re-enter share circulation. A frozen investor is held: the vault refuses their requests, cancellations and claims, and pays them only after the freeze lifts. 4. **Five Separate Authorities**: - **Governance (Admin)**: Root authority; upgrades, timelock parameters, custodian settings, unpause, oracle parameters, emergency NAV pause reset. - **Manager (Vault)**: Closes epochs. diff --git a/docs/COMPLIANCE_MATRIX.md b/docs/COMPLIANCE_MATRIX.md index 67b38cdc..c6b20fca 100644 --- a/docs/COMPLIANCE_MATRIX.md +++ b/docs/COMPLIANCE_MATRIX.md @@ -21,7 +21,9 @@ Every investor account in the vault ecosystem exists in one of three primary com 3. **Frozen (`Frozen`)**: - The account is subject to sanctions, legal freezing, or security hold (`is_frozen(account) == true` via `ShareToken::set_address_frozen`). - The share token refuses a `transfer` from or to a frozen address. Minting does not check freezes, and `forced_transfer` ignores them. - - A frozen investor is outside the payable-claims guarantee (design document §8.1). The vault asks the share token whether the address is frozen on `request_deposit`, `claim_deposit` and `cancel_redeem`: it refuses a frozen address's deposit request, and holds its priced deposit and escrowed redemption until the freeze is lifted (`Frozen`, 6071). Its other exits do not check freezes yet, so they still go through; the kit does not promise them. + - What the vault owes a frozen investor is **held**: not paid, not returned, not moved. Priced cash stays in `committed` and a wind-down slice stays in `wind_down_owed`, where no other holder can take it. Only an unfreeze releases it. A co-signed legal-order transfer (`legal_transfer`) can move shares in the investor's own wallet, but not value the vault holds for them: deposit escrow, priced but unminted shares, shares escrowed in a redemption, priced cash, or a wind-down slice. A partial freeze limits exits to the free balance. + + This follows how asset-freeze and blocking rules generally treat frozen property: held, not released, not returned to the owner and not confiscated, with the entitlement kept and recorded. It is not legal advice; counsel must confirm it for each jurisdiction. --- @@ -30,11 +32,12 @@ Every investor account in the vault ecosystem exists in one of three primary com | Operation | Allowlisted | De-listed (Non-Frozen) | Frozen (Address or Partial) | Contract Mechanics & Regulatory Rationale | |---|---|---|---|---| | **`request_deposit`** | ✅ Allowed | ❌ **Refused** | ❌ **Refused** | Before any cash moves, the vault asks the share token whether the address is frozen and then asks the token's identity verifier. It refuses a frozen address (`Frozen`) and an address the token would not let hold shares (`NotAllowlisted`). A partial freeze is not checked. | -| **`cancel_deposit`** | ✅ Allowed | ✅ **Allowed** | ✅ **Allowed** | **Escape Hatch**: Calls `refund_deposit`. Escrowed settlement asset (USDC) is refunded directly to `controller`. Cash refunds bypass share token compliance checks. | +| **`cancel_deposit`** | ✅ Allowed | ✅ **Allowed** | ❌ **Refused** (`Frozen`); the escrow stays | **Escape Hatch**: Calls `refund_deposit`. Escrowed settlement asset (USDC) is refunded directly to `controller`. The vault checks `is_frozen` on the share token before it refunds anything; once past that, cash refunds bypass share token compliance checks. | | **`claim_deposit`** | ✅ Allowed | ↪ **Moved to the exit queue** | ⛔ **Held** | A receiver off the allowlist gets no shares: the vault mints them into its own escrow and adds them to the holder's redemption in the open epoch (`DepositConvertedToExit`), paid in cash at that epoch's price. In a wind-down they join the holder's wind-down entitlement. A compliance module that refuses the mint (`can_create`) still blocks the claim. A receiver whose address is frozen, listed or not, is refused with `Frozen` (6071) and keeps the priced deposit until the freeze is lifted. A partial freeze is not checked. | -| **`request_redeem`** | ✅ Allowed | ✅ **Allowed** | ⚠️ Allowed, not guaranteed | **Exit Right**: the vault escrows the shares with `RWATokenClient::forced_transfer`, which checks neither the allowlist nor freezes, so a de-listed holder can start an exit. It also releases as much of a partial freeze as the escrow needs. | +| **`request_redeem`** | ✅ Allowed | ✅ **Allowed** | ❌ **Refused** (`Frozen`); a partial freeze caps the request to the free balance | **Exit Right**: the vault checks `is_frozen` and `get_frozen_tokens` on the share token before it escrows anything with `RWATokenClient::forced_transfer`. A de-listed, non-frozen holder can still start an exit; a frozen one cannot, and a partial freeze caps the request to the shares `get_frozen_tokens` leaves free. | | **`cancel_redeem`** | ✅ Allowed | ❌ **Refused**; ↪ **surrendered** in a wind-down | ⛔ **Held** | Outside a wind-down the vault refuses a de-listed controller (`NotAllowlisted`) and holds one whose address is frozen (`Frozen`); anyone else gets the shares back through the token's checked `transfer`. That transfer is refused with `EnforcedPause` while the share-token transfer pause is active; the request stays pending until the pause is lifted or the epoch prices. In an active wind-down no shares come back: the escrow is burned into the controller's wind-down entitlement (`RedemptionSurrendered`), allowlisted or not, so no token pause can hold it. A frozen controller's escrow stays until the freeze is lifted. | -| **`claim_redeem`** | ✅ Allowed | ✅ **Allowed** | ⚠️ Pays, not guaranteed | **Exit-Only Cash Path**: the settlement asset pays out from the liquid reserve and the escrowed shares are burned from the vault. The cash claim checks neither the allowlist nor freezes. | +| **`claim_redeem`** | ✅ Allowed | ✅ **Allowed** | ❌ **Refused** (`Frozen`); the cash stays in `committed` | **Exit-Only Cash Path**: the settlement asset pays out from the liquid reserve and the escrowed shares are burned from the vault. The cash claim checks `is_frozen` on the share token first; it does not check the allowlist. | +| **`claim_wind_down`** | ✅ Allowed | ✅ **Allowed** | ❌ **Refused** (`Frozen`); the slice stays in `wind_down_owed` | **Wind-Down Payout**: pays the holder's pro-rata entitlement and burns the free shares it surrenders. The vault checks `is_frozen` and `get_frozen_tokens` on the share token first; a partial freeze surrenders only the free shares, leaving the rest to earn later rounds once unfrozen. | | **`transfer`** (Secondary) | ✅ Allowed | ❌ **Refused** | ❌ **Refused** | Standard peer-to-peer share transfer via SEP-57. Requires both sender and receiver to be allowlisted and neither to be frozen. | --- @@ -42,14 +45,14 @@ Every investor account in the vault ecosystem exists in one of three primary com ## 3. Deep Dive into Key Lifecycle Invariants ### 3.1 The Exit-Only Cash Path -A core guarantee of the Strata Vault Kit is that **a de-listed, non-frozen investor can always leave the fund in cash**. A frozen investor is outside this guarantee, as the design document states in §8.1: the cash claim does not check freezes today, so their exit goes through, but nothing promises it. +A core guarantee of the Strata Vault Kit is that **a de-listed, non-frozen investor can always leave the fund in cash**. A frozen investor is held instead of paid, as §1 describes. - **Mechanism**: 1. A de-listed holder queues an exit via `request_redeem`. The vault escrows the shares with `forced_transfer`. 2. The epoch closes and is priced via attestation. 3. Once priced and covered, the cash liability is committed. 4. The investor calls `claim_redeem`. The cash payout transfers the settlement asset (USDC) directly to the investor's Stellar account, while the shares are burned from the vault's own balance. - 5. The cash transfer never queries `IdentityVerifier` or `ShareToken::is_frozen`. Once priced and covered, cash exits cannot be blocked. + 5. The cash transfer never queries `IdentityVerifier`. It does check `ShareToken::is_frozen` first: a frozen holder's claim is held, not paid, until the freeze lifts. Once priced, covered and unfrozen, cash exits cannot be blocked. ### 3.2 Deposit Cancellation for De-Listed Investors If an investor deposits into an open epoch but is de-listed (or KYC expires) before the epoch is priced: @@ -111,9 +114,15 @@ The behavior specified in this matrix is verified by on-chain automated test sui * `a_delisted_holder_can_still_claim_their_exit`: Verifies de-listed holders always receive cash payout. * `a_delisted_investor_can_cancel_deposit_and_receive_asset`: Verifies de-listed depositors can cancel and receive cash back. * `a_delisted_holder_cannot_cancel_redemption_back_to_shares`: Verifies de-listed holders cannot abort redemption back into shares. -* `a_frozen_holder_cannot_cancel_redemption_back_to_shares`: Verifies frozen holders cannot abort redemption back into shares. +* `a_frozen_holder_is_paid_only_after_the_freeze_lifts`: Verifies a frozen holder's cancel and claim are both held, and paid only once the freeze lifts. * `a_frozen_or_delisted_holder_cannot_transfer_shares`: Verifies secondary market transfers are strictly refused for de-listed and frozen accounts. * `a_frozen_investor_cannot_request_a_deposit`: Verifies a frozen depositor is refused at request time. * `a_frozen_investor_is_not_minted_until_unfrozen`: Verifies a frozen investor's priced deposit is held until the freeze is lifted. -* `a_frozen_holder_can_still_queue_an_exit`: Records that escrowing an exit does not check freezes. -* `queueing_an_exit_releases_a_partial_freeze`: Records that the escrow releases as much of a partial freeze as it needs. +* `a_frozen_holder_cannot_queue_an_exit`: Verifies a frozen holder cannot start a redemption. +* `a_partial_freeze_limits_an_exit_to_the_free_shares`: Verifies a partial freeze caps a redemption request to the free balance. +* `a_frozen_investors_escrow_stays_until_the_freeze_lifts`: Verifies a frozen depositor's cancel is held and the escrow is unaffected. +* `a_frozen_holder_does_not_get_a_worthless_exit_back`: Verifies a frozen holder's zero-asset claim is held rather than returning shares. +* `a_frozen_holder_is_not_moved_into_the_exit_queue`: Verifies a holder who is both de-listed and frozen is held, not converted to an exit. +* `a_frozen_holders_slice_stays_owed_until_the_freeze_lifts` ([`test/wind_down.rs`](../contracts/async-vault/src/test/wind_down.rs)): Verifies a frozen holder's wind-down slice stays in `wind_down_owed` until the freeze lifts. +* `a_frozen_holders_escrowed_exit_waits_through_a_wind_down`: Verifies a frozen holder's escrowed redemption and wind-down claim are both held through a wind-down. +* `a_partial_freeze_keeps_frozen_shares_out_of_a_wind_down_claim`: Verifies a partial freeze excludes frozen shares from a wind-down surrender. diff --git a/docs/kit-spec.md b/docs/kit-spec.md index 13c96114..2dcb5d68 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -42,7 +42,7 @@ The contracts enforce a set of mathematical and state-machine invariants across 1. The vault is paused (`paused == true`). 2. The oracle feed has expired (`is_stale == true`) or the NAV is paused. 3. The caller has been de-listed in the `IdentityVerifier`. - Priced claims are never re-priced and never identity-gated. A frozen investor is outside this guarantee (design document §8.1). + Priced claims are never re-priced and never identity-gated. A frozen investor's covered claim is held until the freeze is lifted (`VaultError::Frozen`); the cash stays in `committed`. ### Invariant 5: Exit Rights Never Gated or Pausable - **Definition**: Investors cannot be trapped in the vault by a vault pause. diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index 89675773..9bcc83d0 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -165,8 +165,11 @@ For the exhaustive access control matrix, dual `manager` role distinction, and e stale valuation can block the payment of an already-priced, funded cash claim. A pause can delay unpriced requests. Priced claims are never re-priced and never identity-gated. A delisted, non-frozen investor exits through the - exit-only cash path. Outside this guarantee, disclosed as trust assumptions: - a frozen investor, deposit-asset issuer controls, and reserve liquidity. + exit-only cash path. A frozen investor's claims are held, not paid, until + the freeze is lifted ([`COMPLIANCE_MATRIX.md`](./COMPLIANCE_MATRIX.md) §1). + A legal-order transfer moves only shares in the investor's wallet, never + value the vault holds. Outside this guarantee, disclosed as trust + assumptions: deposit-asset issuer controls and reserve liquidity. 6. **Split accounting, with priced and payable as separate states.** The vault exposes committed (priced redemption liabilities), cancellable escrow From 592b44bb1194de317407e7ac67f0b1dd6bba057d Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 05:46:44 -0300 Subject: [PATCH 034/147] fix(vault): address freeze review round 1 --- contracts/async-vault/src/lib.rs | 5 +++-- contracts/async-vault/src/redeem.rs | 10 +++++++--- contracts/async-vault/src/test/lock_policy.rs | 2 +- contracts/async-vault/src/test/redeem.rs | 6 +++++- contracts/async-vault/src/test/wind_down.rs | 11 +++++++++++ contracts/async-vault/src/wind_down.rs | 11 +++++++---- docs/COMPLIANCE_MATRIX.md | 10 +++++----- docs/kit-spec.md | 2 +- docs/strata-product-and-architecture.md | 11 +++++------ 9 files changed, 45 insertions(+), 23 deletions(-) diff --git a/contracts/async-vault/src/lib.rs b/contracts/async-vault/src/lib.rs index 657677dc..bbccd4aa 100644 --- a/contracts/async-vault/src/lib.rs +++ b/contracts/async-vault/src/lib.rs @@ -249,8 +249,9 @@ impl AsyncVault { wind_down::acc(e) } - /// Surrenders whatever the holder holds and pays their share of every round - /// finalised since they last claimed. + /// Surrenders the free shares the holder holds and pays their share of + /// every round finalised since they last claimed. A frozen holder is + /// refused; a partial freeze surrenders only the free shares. pub fn claim_wind_down(e: &Env, holder: Address) -> i128 { wind_down::claim(e, &holder) } diff --git a/contracts/async-vault/src/redeem.rs b/contracts/async-vault/src/redeem.rs index 7fd22789..c38490b1 100644 --- a/contracts/async-vault/src/redeem.rs +++ b/contracts/async-vault/src/redeem.rs @@ -27,10 +27,14 @@ pub(crate) fn request(e: &Env, from: &Address, shares: i128) -> u64 { panic_with_error!(e, lock_policy::refused_with(standing)) } } - // The escrow transfer releases a partial freeze, so frozen shares are - // kept out of it here. + // A request within the balance but short of the free shares needs frozen + // ones; beyond the balance entirely, the token's own check applies instead. if shares > lock_policy::free_shares(e, from) { - panic_with_error!(e, VaultError::Frozen); + let held = + FungibleTokenClient::new(e, &state::get_addr(e, &DataKey::ShareToken)).balance(from); + if shares <= held { + panic_with_error!(e, VaultError::Frozen); + } } let epoch_id = state::current_epoch(e); diff --git a/contracts/async-vault/src/test/lock_policy.rs b/contracts/async-vault/src/test/lock_policy.rs index 715ed129..40133a85 100644 --- a/contracts/async-vault/src/test/lock_policy.rs +++ b/contracts/async-vault/src/test/lock_policy.rs @@ -169,7 +169,7 @@ fn a_delisted_holder_may_request_a_redemption() { } #[test] -fn a_frozen_holder_may_not_request_a_redemption() { +fn a_frozen_holders_redemption_request_is_held() { assert_eq!( in_and_out_of_a_wind_down(Exit::RequestRedeem, Standing::Frozen), [Decision::Hold; 2] diff --git a/contracts/async-vault/src/test/redeem.rs b/contracts/async-vault/src/test/redeem.rs index b95d8c62..87803a2e 100644 --- a/contracts/async-vault/src/test/redeem.rs +++ b/contracts/async-vault/src/test/redeem.rs @@ -57,7 +57,11 @@ fn redeeming_more_shares_than_held_is_rejected() { let f = setup(); let user = f.holder(500); - assert!(f.vault.try_request_redeem(&user, &501).is_err()); + // Nothing is frozen, so the token's own balance check applies, not `Frozen`. + assert_eq!( + f.vault.try_request_redeem(&user, &501).err(), + Some(Ok(soroban_sdk::Error::from_contract_error(300))) + ); assert_eq!(f.shares(&user), 500); } diff --git a/contracts/async-vault/src/test/wind_down.rs b/contracts/async-vault/src/test/wind_down.rs index b22073a0..2a0c6cfd 100644 --- a/contracts/async-vault/src/test/wind_down.rs +++ b/contracts/async-vault/src/test/wind_down.rs @@ -800,6 +800,17 @@ fn a_frozen_holders_escrowed_exit_waits_through_a_wind_down() { assert!(f.vault.claim_wind_down(&frozen) > 0); } +#[test] +fn wind_down_claimable_is_zero_for_a_frozen_holder() { + let f = setup(); + let holder = f.holder(500); + wind_down_now(&f); + f.vault.finalize_wind_down_round(); + f.share.set_address_frozen(&holder, &true, &f.compliance); + + assert_eq!(f.vault.wind_down_claimable(&holder), 0); +} + #[test] fn a_partial_freeze_keeps_frozen_shares_out_of_a_wind_down_claim() { let f = setup(); diff --git a/contracts/async-vault/src/wind_down.rs b/contracts/async-vault/src/wind_down.rs index f808d58c..1427cddf 100644 --- a/contracts/async-vault/src/wind_down.rs +++ b/contracts/async-vault/src/wind_down.rs @@ -8,7 +8,7 @@ use crate::event::{ WindDownProposed, WindDownRoundFinalized, }; use crate::keys::DataKey; -use crate::lock_policy::{self, Decision, Exit}; +use crate::lock_policy::{self, Decision, Exit, Standing}; use crate::state; use crate::treasury; @@ -252,12 +252,15 @@ pub(crate) fn position(e: &Env, holder: &Address) -> WindDownPosition { ) } -/// What this holder would be paid if they claimed now, surrendering whatever -/// they hold. +/// What this holder would be paid if they claimed now, surrendering the free +/// shares they hold. Zero for a fully frozen holder, whom `claim` refuses. pub(crate) fn claimable(e: &Env, holder: &Address) -> i128 { if info(e).map(|wd| wd.round).unwrap_or(0) == 0 { return 0; } + if lock_policy::standing(e, holder) == Standing::Frozen { + return 0; + } let held = lock_policy::free_shares(e, holder); let p = position(e, holder); @@ -288,7 +291,7 @@ pub(crate) fn claim(e: &Env, holder: &Address) -> i128 { let share_token = state::get_addr(e, &DataKey::ShareToken); // A partial freeze keeps its shares out of the surrender; they earn every - // round once the freeze is lifted and the holder claims again. + // round regardless, and are paid once unfrozen and claimed again. let surrendered = lock_policy::free_shares(e, holder); let mut p = position(e, holder); p.entitlement = p diff --git a/docs/COMPLIANCE_MATRIX.md b/docs/COMPLIANCE_MATRIX.md index c6b20fca..02e8cd9d 100644 --- a/docs/COMPLIANCE_MATRIX.md +++ b/docs/COMPLIANCE_MATRIX.md @@ -21,7 +21,7 @@ Every investor account in the vault ecosystem exists in one of three primary com 3. **Frozen (`Frozen`)**: - The account is subject to sanctions, legal freezing, or security hold (`is_frozen(account) == true` via `ShareToken::set_address_frozen`). - The share token refuses a `transfer` from or to a frozen address. Minting does not check freezes, and `forced_transfer` ignores them. - - What the vault owes a frozen investor is **held**: not paid, not returned, not moved. Priced cash stays in `committed` and a wind-down slice stays in `wind_down_owed`, where no other holder can take it. Only an unfreeze releases it. A co-signed legal-order transfer (`legal_transfer`) can move shares in the investor's own wallet, but not value the vault holds for them: deposit escrow, priced but unminted shares, shares escrowed in a redemption, priced cash, or a wind-down slice. A partial freeze limits exits to the free balance. + - What the vault owes a frozen investor is **held**: not paid, not returned, not moved. Priced cash stays in `committed` and a wind-down slice stays in `wind_down_owed`, where no other holder can take it. Only an unfreeze releases it. A partial freeze limits exits to the free balance. This follows how asset-freeze and blocking rules generally treat frozen property: held, not released, not returned to the owner and not confiscated, with the entitlement kept and recorded. It is not legal advice; counsel must confirm it for each jurisdiction. @@ -32,12 +32,12 @@ Every investor account in the vault ecosystem exists in one of three primary com | Operation | Allowlisted | De-listed (Non-Frozen) | Frozen (Address or Partial) | Contract Mechanics & Regulatory Rationale | |---|---|---|---|---| | **`request_deposit`** | ✅ Allowed | ❌ **Refused** | ❌ **Refused** | Before any cash moves, the vault asks the share token whether the address is frozen and then asks the token's identity verifier. It refuses a frozen address (`Frozen`) and an address the token would not let hold shares (`NotAllowlisted`). A partial freeze is not checked. | -| **`cancel_deposit`** | ✅ Allowed | ✅ **Allowed** | ❌ **Refused** (`Frozen`); the escrow stays | **Escape Hatch**: Calls `refund_deposit`. Escrowed settlement asset (USDC) is refunded directly to `controller`. The vault checks `is_frozen` on the share token before it refunds anything; once past that, cash refunds bypass share token compliance checks. | +| **`cancel_deposit`** | ✅ Allowed | ✅ **Allowed** | ❌ **Refused** (`Frozen`); the escrow stays | **Escape Hatch**: Calls `refund_deposit`. Escrowed settlement asset (USDC) is refunded directly to `controller`. The vault checks `is_frozen` on the share token before it refunds anything; once past that, cash refunds bypass share token compliance checks. A partial freeze is not checked. | | **`claim_deposit`** | ✅ Allowed | ↪ **Moved to the exit queue** | ⛔ **Held** | A receiver off the allowlist gets no shares: the vault mints them into its own escrow and adds them to the holder's redemption in the open epoch (`DepositConvertedToExit`), paid in cash at that epoch's price. In a wind-down they join the holder's wind-down entitlement. A compliance module that refuses the mint (`can_create`) still blocks the claim. A receiver whose address is frozen, listed or not, is refused with `Frozen` (6071) and keeps the priced deposit until the freeze is lifted. A partial freeze is not checked. | | **`request_redeem`** | ✅ Allowed | ✅ **Allowed** | ❌ **Refused** (`Frozen`); a partial freeze caps the request to the free balance | **Exit Right**: the vault checks `is_frozen` and `get_frozen_tokens` on the share token before it escrows anything with `RWATokenClient::forced_transfer`. A de-listed, non-frozen holder can still start an exit; a frozen one cannot, and a partial freeze caps the request to the shares `get_frozen_tokens` leaves free. | | **`cancel_redeem`** | ✅ Allowed | ❌ **Refused**; ↪ **surrendered** in a wind-down | ⛔ **Held** | Outside a wind-down the vault refuses a de-listed controller (`NotAllowlisted`) and holds one whose address is frozen (`Frozen`); anyone else gets the shares back through the token's checked `transfer`. That transfer is refused with `EnforcedPause` while the share-token transfer pause is active; the request stays pending until the pause is lifted or the epoch prices. In an active wind-down no shares come back: the escrow is burned into the controller's wind-down entitlement (`RedemptionSurrendered`), allowlisted or not, so no token pause can hold it. A frozen controller's escrow stays until the freeze is lifted. | -| **`claim_redeem`** | ✅ Allowed | ✅ **Allowed** | ❌ **Refused** (`Frozen`); the cash stays in `committed` | **Exit-Only Cash Path**: the settlement asset pays out from the liquid reserve and the escrowed shares are burned from the vault. The cash claim checks `is_frozen` on the share token first; it does not check the allowlist. | -| **`claim_wind_down`** | ✅ Allowed | ✅ **Allowed** | ❌ **Refused** (`Frozen`); the slice stays in `wind_down_owed` | **Wind-Down Payout**: pays the holder's pro-rata entitlement and burns the free shares it surrenders. The vault checks `is_frozen` and `get_frozen_tokens` on the share token first; a partial freeze surrenders only the free shares, leaving the rest to earn later rounds once unfrozen. | +| **`claim_redeem`** | ✅ Allowed | ✅ **Allowed** | ❌ **Refused** (`Frozen`); the cash stays in `committed` | **Exit-Only Cash Path**: the settlement asset pays out from the liquid reserve and the escrowed shares are burned from the vault. The cash claim checks `is_frozen` on the share token first; it does not check the allowlist. A partial freeze is not checked. | +| **`claim_wind_down`** | ✅ Allowed | ✅ **Allowed** | ❌ **Refused** (`Frozen`); the slice stays in `wind_down_owed` | **Wind-Down Payout**: pays the holder's pro-rata entitlement and burns the free shares it surrenders. The vault checks `is_frozen` and `get_frozen_tokens` on the share token first; a partial freeze surrenders only the free shares — the rest keeps earning every round and is paid once unfrozen. | | **`transfer`** (Secondary) | ✅ Allowed | ❌ **Refused** | ❌ **Refused** | Standard peer-to-peer share transfer via SEP-57. Requires both sender and receiver to be allowlisted and neither to be frozen. | --- @@ -52,7 +52,7 @@ A core guarantee of the Strata Vault Kit is that **a de-listed, non-frozen inves 2. The epoch closes and is priced via attestation. 3. Once priced and covered, the cash liability is committed. 4. The investor calls `claim_redeem`. The cash payout transfers the settlement asset (USDC) directly to the investor's Stellar account, while the shares are burned from the vault's own balance. - 5. The cash transfer never queries `IdentityVerifier`. It does check `ShareToken::is_frozen` first: a frozen holder's claim is held, not paid, until the freeze lifts. Once priced, covered and unfrozen, cash exits cannot be blocked. + 5. The cash transfer asks the identity verifier through `standing`, but the claim does not depend on its answer: a de-listed holder is treated the same as a listed one. It does check `ShareToken::is_frozen` first: a frozen holder's claim is held, not paid, until the freeze lifts. Once priced, covered and unfrozen, cash exits cannot be blocked. ### 3.2 Deposit Cancellation for De-Listed Investors If an investor deposits into an open epoch but is de-listed (or KYC expires) before the epoch is priced: diff --git a/docs/kit-spec.md b/docs/kit-spec.md index 2dcb5d68..a65727d3 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -172,7 +172,7 @@ Strata distributes control across five native Stellar multisig authorities and p | **Guardian** | Low-threshold fast-reaction multisig | `pause` (vault); `pause_nav` (oracle emergency price freeze). | **Cannot unpause**; cannot move funds; cannot block redemption requests; cannot block payable claims or cancellations. | | **Treasury** | Operational capital multisig | `deploy_to_custodian` (moves free reserve); `fund` (returns assets from custodian). | **Cannot deploy while anything is uncovered**; cannot touch escrowed subscription assets; cannot alter parameters or pause. | | **Attester** | High-security attestation multisig | `attest` (submits NAV report with proof reference). | **Cannot price outside deviation band**; cannot violate cooldown; cannot operate on the vault directly. | -| **Compliance** | Legal / KYC operator multisig | Manage allowlist (`allow`), topic claims; token interventions (`freeze`, `forced_transfer`, `recovery`). | **Cannot touch vault reserve**; cannot pause vault operations; cannot bypass exit-only cash redemptions. | +| **Compliance** | Legal / KYC operator multisig | Manage allowlist (`allow`), topic claims; token interventions (`freeze`, `forced_transfer`, `recovery`). | **Cannot touch vault reserve**; cannot pause vault operations; cannot bypass exit-only cash redemptions for an unfrozen holder — freezing holds a covered claim until it lifts, rather than paying or redirecting it. | | **Manager** | Operational keeper account / vault | `close_epoch` (vault); `mint`, `burn`, `forced_transfer` (share token). | **Cannot choose prices**; cannot prevent epoch fulfillment; cannot alter timelocks. | | **Permissionless** | Any caller / keeper / investor | `fulfill_epoch`, `request_deposit`, `cancel_deposit`, `claim_deposit`, `request_redeem`, `cancel_redeem`, `claim_redeem`, `activate_wind_down`, `finalize_wind_down_round`, `claim_wind_down`. | Operations are strictly constrained by contract state, timing, and solvency guards. | diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index 9bcc83d0..29664a39 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -167,9 +167,8 @@ For the exhaustive access control matrix, dual `manager` role distinction, and e and never identity-gated. A delisted, non-frozen investor exits through the exit-only cash path. A frozen investor's claims are held, not paid, until the freeze is lifted ([`COMPLIANCE_MATRIX.md`](./COMPLIANCE_MATRIX.md) §1). - A legal-order transfer moves only shares in the investor's wallet, never - value the vault holds. Outside this guarantee, disclosed as trust - assumptions: deposit-asset issuer controls and reserve liquidity. + Outside this guarantee, disclosed as trust assumptions: deposit-asset + issuer controls and reserve liquidity. 6. **Split accounting, with priced and payable as separate states.** The vault exposes committed (priced redemption liabilities), cancellable escrow @@ -315,16 +314,16 @@ batch boundary, though not the price it receives. - Request: moves the deposit asset into escrow; at most one active request per controller. The vault first asks the share token whether the address is frozen and asks the token's identity verifier, and refuses a frozen address or one that could not receive the shares, before any cash moves; the token checks again when it mints at claim time. - Pricing: the escrow leaves the cancellable bucket, the share quantity is set at the epoch's price, and the economic supply liability is recorded. To avoid unbounded loops that exceed Soroban transaction resource limits, physical share minting occurs lazily when each investor executes `claim_deposit`. -- Cancellation: atomic, and returns the escrowed asset in full. Open while the epoch is open. Once sealed, a readable price closes it, even while the notice still runs: an investor who can see the price cannot decline it. If the vault can no longer price the epoch — the report expires, the NAV is paused, or the vault is paused — it opens again, so escrow is never stuck behind a stalled feed. The epoch is then priced at the latest valid report at fulfil time. Cancellation also remains open during an active wind-down, allowing pending depositors to recover their escrowed assets when pricing is closed. +- Cancellation: atomic, and returns the escrowed asset in full. Open while the epoch is open. Once sealed, a readable price closes it, even while the notice still runs: an investor who can see the price cannot decline it. If the vault can no longer price the epoch — the report expires, the NAV is paused, or the vault is paused — it opens again, so escrow is never stuck behind a stalled feed. The epoch is then priced at the latest valid report at fulfil time. Cancellation also remains open during an active wind-down, allowing pending depositors to recover their escrowed assets when pricing is closed. A frozen depositor's cancellation is held until the freeze is lifted, in a wind-down too. - Share claim: verifies the receiver and delivers the shares via `mint`. A receiver off the allowlist gets no shares: they join the holder's redemption in the open epoch and are paid in cash at that epoch's price; in a wind-down they join the holder's wind-down entitlement. A frozen receiver is held until the freeze is lifted. No nominal refund exists after pricing. #### Redemption -- Request: moves shares into escrow; at most one active request per controller per epoch. +- Request: moves shares into escrow; at most one active request per controller per epoch. A frozen holder's request is held until the freeze is lifted; a partial freeze caps it to the free shares. - Cancellation: atomic, and returns the escrowed shares under the same rules as subscription cancellation; stays open during an active wind-down, where the escrow joins the holder's wind-down entitlement instead of coming back as shares. A frozen holder's cancellation is held until the freeze is lifted. - Pricing: the fixed cash liability enters committed at the epoch's price. Escrowed shares remain in the vault contract and are burned lazily as each claimant executes `claim_redeem`, preserving constant-bounded resource execution per transaction. Priced claims are never re-priced. - Coverage: a priced claim is payable when the liquid reserve covers that claim's own amount, in any order. An earlier unpaid claim never blocks a later one that is already covered. The gap between committed and liquid reserve is the uncovered amount treasury must top up. -- Cash claim: pays the fixed amount; it does not depend on identity. A delisted, non-frozen investor uses the exit-only cash path and cannot cancel back to shares. +- Cash claim: pays the fixed amount; it does not depend on identity. A delisted, non-frozen investor uses the exit-only cash path and cannot cancel back to shares. A frozen investor's claim is held until the freeze is lifted. ```mermaid sequenceDiagram From bd627a72f3c953b16aa5ce04a84b0022ee012492 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 06:34:25 -0300 Subject: [PATCH 035/147] feat(token): timelock admin changes after setup --- Cargo.lock | 2 + contracts/share-token/Cargo.toml | 6 + contracts/share-token/src/contract.rs | 123 ++++ contracts/share-token/src/error.rs | 27 + contracts/share-token/src/event.rs | 33 + contracts/share-token/src/governance.rs | 177 ++++++ contracts/share-token/src/lib.rs | 6 + contracts/share-token/src/roles.rs | 5 + contracts/share-token/src/test_governance.rs | 606 +++++++++++++++++++ crates/bindings/src/lib.rs | 11 +- 10 files changed, 995 insertions(+), 1 deletion(-) create mode 100644 contracts/share-token/src/event.rs create mode 100644 contracts/share-token/src/governance.rs create mode 100644 contracts/share-token/src/test_governance.rs diff --git a/Cargo.lock b/Cargo.lock index de5da08b..8b6319a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1708,6 +1708,7 @@ dependencies = [ name = "share-token" version = "0.1.0" dependencies = [ + "bindings", "compliance", "identity-verifier", "soroban-sdk", @@ -1716,6 +1717,7 @@ dependencies = [ "stellar-macros", "stellar-tokens", "storage", + "timelock", ] [[package]] diff --git a/contracts/share-token/Cargo.toml b/contracts/share-token/Cargo.toml index d06350fc..f26c96d0 100644 --- a/contracts/share-token/Cargo.toml +++ b/contracts/share-token/Cargo.toml @@ -16,8 +16,14 @@ stellar-macros = { workspace = true } stellar-tokens = { workspace = true } stellar-contract-utils = { workspace = true } storage = { path = "../../crates/storage" } +timelock = { path = "../../crates/timelock" } +bindings = { path = "../../crates/bindings" } [dev-dependencies] soroban-sdk = { workspace = true, features = ["testutils"] } compliance = { path = "../compliance" } identity-verifier = { path = "../identity-verifier" } + +[features] +# Builds a stand-in for a later token version, used only by the upgrade tests. +upgrade_probe = [] diff --git a/contracts/share-token/src/contract.rs b/contracts/share-token/src/contract.rs index 7cd1c853..7734d20a 100644 --- a/contracts/share-token/src/contract.rs +++ b/contracts/share-token/src/contract.rs @@ -1,11 +1,14 @@ use crate::error::ShareTokenError; +use crate::governance::{self, TokenChange, TokenPendingChange}; use crate::roles::COMPLIANCE_ROLE; +use bindings::VaultBound; use soroban_sdk::{ contract, contractimpl, panic_with_error, Address, Env, MuxedAddress, String, Symbol, Vec, }; use stellar_access::access_control::{self as access_control, AccessControl}; use stellar_contract_utils::pausable::{self as pausable, Pausable}; +use stellar_contract_utils::upgradeable; use stellar_macros::{only_admin, only_any_role, only_role}; use stellar_tokens::{ fungible::{Base, ContractOverrides, FungibleToken}, @@ -36,8 +39,69 @@ impl ShareToken { ); RWA::set_compliance(e, &compliance); RWA::set_identity_verifier(e, &identity_verifier); + upgradeable::set_schema_version(e, 1); storage::bump_instance(e); } + + pub fn schema_version(e: &Env) -> u32 { + upgradeable::get_schema_version(e) + } + + /// Names the vault whose clock this token'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: TokenChange, 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) + } +} + +#[contractimpl(contracttrait)] +impl VaultBound for ShareToken { + fn vault(e: &Env) -> Option
{ + timelock::setup::source(e) + } + + fn setup_open(e: &Env) -> bool { + timelock::setup::is_open(e) + } } #[contractimpl(contracttrait)] @@ -86,6 +150,36 @@ impl FungibleToken for ShareToken { #[contractimpl(contracttrait)] impl AccessControl for ShareToken { + /// Instant during setup; after it, propose the grant through the timelock. + 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); + } + + /// Instant for `compliance`. Revoking `manager` waits like a grant, + /// because the vault needs it to pay claims. + fn revoke_role(e: &Env, account: Address, role: Symbol, caller: Address) { + governance::require_revoke_allowed(e, &role); + storage::bump_instance(e); + access_control::revoke_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, ShareTokenError::UseTimelock); + } + /// Refused. A token without an admin could never be unpaused, never rotate /// the manager role and never replace the vault that holds it. /// `transfer_admin_role` and `accept_admin_transfer` are the way out. @@ -140,13 +234,42 @@ impl RWAToken for ShareToken { #[only_admin] fn set_compliance(e: &Env, compliance: Address, operator: Address) { operator.require_auth(); + governance::require_setup_open(e); storage::bump_instance(e); RWA::set_compliance(e, &compliance); } #[only_admin] fn set_identity_verifier(e: &Env, identity_verifier: Address, operator: Address) { operator.require_auth(); + governance::require_setup_open(e); storage::bump_instance(e); RWA::set_identity_verifier(e, &identity_verifier); } } + +/// Stands in for a later token version in the upgrade tests. Never deployed. +#[cfg(feature = "upgrade_probe")] +#[soroban_sdk::contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum ProbeError { + AlreadyMigrated = 7100, +} + +#[cfg(feature = "upgrade_probe")] +#[contractimpl] +impl ShareToken { + #[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 + } +} diff --git a/contracts/share-token/src/error.rs b/contracts/share-token/src/error.rs index 1188392d..37ef98d0 100644 --- a/contracts/share-token/src/error.rs +++ b/contracts/share-token/src/error.rs @@ -10,4 +10,31 @@ pub enum ShareTokenError { /// management need it. The admin hands over through `transfer_admin_role` /// and `accept_admin_transfer` instead. AdminRequired = 7000, + /// No pending change has this id. + ChangeNotFound = 7001, + /// The change's delay has not elapsed. + ChangeNotReady = 7002, + /// As many changes as the timelock holds already wait. + TooManyPendingChanges = 7003, + /// Changes are instant until setup finishes; there is nothing to propose yet. + SetupOpen = 7004, + /// Setup has finished, so this change waits for the timelock. Propose it. + UseTimelock = 7005, + /// Only possible while setup is open, and only once. + SetupClosed = 7006, + /// Setup cannot finish before the token is bound to its vault. + VaultNotBound = 7007, + /// The vault is winding down, so no change may be proposed or applied. + WindDownActive = 7008, + /// The vault is paused, which holds every timelocked change. + VaultPaused = 7009, + /// The change's delay is shorter than the vault's notice, so an investor + /// could not complete an exit before it lands. + DelayBelowNotice = 7010, + /// An upgrade already waits. Cancel it before proposing another. + UpgradeAlreadyPending = 7011, + /// 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 = 7016, } diff --git a/contracts/share-token/src/event.rs b/contracts/share-token/src/event.rs new file mode 100644 index 00000000..524c24ae --- /dev/null +++ b/contracts/share-token/src/event.rs @@ -0,0 +1,33 @@ +use soroban_sdk::{contractevent, Address}; + +use crate::governance::TokenChange; + +#[contractevent] +pub struct ChangeProposed { + #[topic] + pub id: u32, + pub change: TokenChange, + pub eta: u64, +} + +#[contractevent] +pub struct ChangeCancelled { + #[topic] + pub id: u32, +} + +#[contractevent] +pub struct ChangeApplied { + #[topic] + pub id: u32, + pub change: TokenChange, +} + +#[contractevent] +pub struct SetupFinished {} + +#[contractevent] +pub struct VaultBound { + #[topic] + pub vault: Address, +} diff --git a/contracts/share-token/src/governance.rs b/contracts/share-token/src/governance.rs new file mode 100644 index 00000000..e90b1203 --- /dev/null +++ b/contracts/share-token/src/governance.rs @@ -0,0 +1,177 @@ +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 stellar_tokens::rwa::RWA; +use timelock::{queue, setup, Clock, Refusal, TimelockHost}; + +use crate::error::ShareTokenError; +use crate::event::{ChangeApplied, ChangeCancelled, ChangeProposed, SetupFinished, VaultBound}; +use crate::roles::MANAGER_ROLE; +use crate::ShareToken; + +/// 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 TokenChange { + Upgrade(BytesN<32>), + GrantRole(Symbol, Address), + RevokeRole(Symbol, Address), + Admin(Address), + Compliance(Address), + IdentityVerifier(Address), +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TokenPendingChange { + pub id: u32, + pub change: TokenChange, + pub eta: u64, +} + +/// The token keeps no clock of its own: it reads the vault's. +impl TimelockHost for ShareToken { + fn clock(e: &Env) -> Result { + timelock::clock::from_source(e) + } +} + +fn refuse(e: &Env, refusal: Refusal) -> ! { + match refusal { + Refusal::WindingDown => panic_with_error!(e, ShareTokenError::WindDownActive), + Refusal::Paused => panic_with_error!(e, ShareTokenError::VaultPaused), + Refusal::NotElapsed => panic_with_error!(e, ShareTokenError::ChangeNotReady), + Refusal::BelowExitWindow => panic_with_error!(e, ShareTokenError::DelayBelowNotice), + Refusal::NotFound => panic_with_error!(e, ShareTokenError::ChangeNotFound), + Refusal::TooMany => panic_with_error!(e, ShareTokenError::TooManyPendingChanges), + Refusal::SetupOpen => panic_with_error!(e, ShareTokenError::SetupOpen), + Refusal::SetupClosed | Refusal::AlreadyBound => { + panic_with_error!(e, ShareTokenError::SetupClosed) + } + Refusal::Unbound => panic_with_error!(e, ShareTokenError::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, ShareTokenError::UseTimelock); + } +} + +/// Taking `manager` away stops every claim, so after setup it waits like a +/// grant. Taking any other role away moves no value and stays instant. +pub(crate) fn require_revoke_allowed(e: &Env, role: &Symbol) { + if *role == MANAGER_ROLE { + require_setup_open(e); + } +} + +fn upgrade_pending(e: &Env) -> bool { + queue::open_ids(e).iter().any(|id| { + queue::pending::(e, id) + .is_some_and(|(change, _)| matches!(change, TokenChange::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, ShareTokenError::AdminTransferPending); + } + setup::finish::(e).unwrap_or_else(|r| refuse(e, r)); + SetupFinished {}.publish(e); +} + +pub(crate) fn propose(e: &Env, change: TokenChange) -> u32 { + if matches!(change, TokenChange::Upgrade(_)) && upgrade_pending(e) { + panic_with_error!(e, ShareTokenError::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)); + ChangeProposed { + id, + change, + eta: schedule.eta(&vault_clock(e)), + } + .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: TokenChange = + 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, ShareTokenError::AdminRequired)); + match &change { + TokenChange::Upgrade(hash) => upgradeable::upgrade(e, hash), + TokenChange::GrantRole(role, account) => { + access_control::grant_role_no_auth(e, account, role, &admin) + } + TokenChange::RevokeRole(role, account) => { + access_control::revoke_role_no_auth(e, account, role, &admin) + } + TokenChange::Admin(next) => offer_admin(e, &admin, next), + TokenChange::Compliance(rules) => RWA::set_compliance(e, rules), + TokenChange::IdentityVerifier(verifier) => RWA::set_identity_verifier(e, verifier), + } + 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(TokenPendingChange { + id, + change, + eta: schedule.eta(&clock), + }); + } + } + out +} diff --git a/contracts/share-token/src/lib.rs b/contracts/share-token/src/lib.rs index d4b250f9..5d530e06 100644 --- a/contracts/share-token/src/lib.rs +++ b/contracts/share-token/src/lib.rs @@ -1,9 +1,15 @@ #![no_std] mod contract; mod error; +mod event; +mod governance; mod roles; pub use contract::*; pub use error::ShareTokenError; +pub use event::{ChangeApplied, ChangeCancelled, ChangeProposed, SetupFinished, VaultBound}; +pub use governance::{TokenChange, TokenPendingChange, ADMIN_ACCEPT_LEDGERS}; #[cfg(test)] mod test; +#[cfg(test)] +mod test_governance; diff --git a/contracts/share-token/src/roles.rs b/contracts/share-token/src/roles.rs index 16a31009..af118c87 100644 --- a/contracts/share-token/src/roles.rs +++ b/contracts/share-token/src/roles.rs @@ -1,4 +1,9 @@ +use soroban_sdk::{symbol_short, Symbol}; + /// Role for allowlist-driven interventions: freeze, forced transfer, recovery. /// Held by the compliance authority. Minting, burning and escrow use the /// `manager` role, which governance grants to the vault. pub const COMPLIANCE_ROLE: &str = "compliance"; + +/// The vault's role. Without it the vault cannot mint or burn, so no claim pays. +pub const MANAGER_ROLE: Symbol = symbol_short!("manager"); diff --git a/contracts/share-token/src/test_governance.rs b/contracts/share-token/src/test_governance.rs new file mode 100644 index 00000000..d9538bdc --- /dev/null +++ b/contracts/share-token/src/test_governance.rs @@ -0,0 +1,606 @@ +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, String, Symbol, +}; +use storage::INSTANCE_EXTEND_AMOUNT; +use timelock::{Clock, DAY_DURATION, MIN_DELAY}; + +use crate::{ + ChangeApplied, ChangeProposed, ShareToken, ShareTokenClient, ShareTokenError, TokenChange, + ADMIN_ACCEPT_LEDGERS, +}; + +const TWO_DAYS_IN_LEDGERS: u32 = 2 * 17_280; + +/// Plays the vault: reports whatever clock a test sets. +#[contract] +struct ClockStub; + +#[contractimpl] +impl ClockStub { + pub fn set(e: Env, clock: Clock) { + e.storage().instance().set(&symbol_short!("clock"), &clock); + } +} + +#[contractimpl(contracttrait)] +impl timelock::ClockSource for ClockStub { + fn timelock_clock(e: &Env) -> Clock { + e.storage().instance().get(&symbol_short!("clock")).unwrap() + } +} + +struct Fixture<'a> { + e: Env, + token: ShareTokenClient<'a>, + vault: ClockStubClient<'a>, + admin: 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.token.bind_vault(&self.vault.address, &self.admin); + self.token.finish_setup(&self.admin); + } + + fn propose(&self, change: TokenChange) -> u32 { + self.token.propose_change(&change, &self.admin) + } + + fn instance_ttl(&self) -> u32 { + self.e.as_contract(&self.token.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 setup<'a>() -> Fixture<'a> { + let e = Env::default(); + e.mock_all_auths(); + e.ledger().set_timestamp(10_000); + let admin = Address::generate(&e); + let token = e.register( + ShareToken, + ( + String::from_str(&e, "Strata Vault USDC"), + String::from_str(&e, "bvUSDC"), + admin.clone(), + Address::generate(&e), + Address::generate(&e), + Address::generate(&e), + ), + ); + 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 { + token: ShareTokenClient::new(&e, &token), + vault, + admin, + e, + } +} + +fn refused( + result: Result, Result>, + expected: ShareTokenError, +) { + assert_eq!(result, Err(Ok(expected.into()))); +} + +fn manager() -> Symbol { + symbol_short!("manager") +} + +fn compliance(e: &Env) -> Symbol { + Symbol::new(e, "compliance") +} + +#[test] +fn setup_is_open_until_finished_and_never_reopens() { + let f = setup(); + assert!(f.token.setup_open()); + refused( + f.token.try_finish_setup(&f.admin), + ShareTokenError::VaultNotBound, + ); + + f.finish(); + + assert!(!f.token.setup_open()); + assert_eq!(f.token.vault(), Some(f.vault.address.clone())); + refused( + f.token.try_finish_setup(&f.admin), + ShareTokenError::SetupClosed, + ); + refused( + f.token.try_bind_vault(&Address::generate(&f.e), &f.admin), + ShareTokenError::SetupClosed, + ); +} + +#[test] +fn a_second_vault_cannot_be_bound() { + let f = setup(); + f.token.bind_vault(&f.vault.address, &f.admin); + + refused( + f.token.try_bind_vault(&Address::generate(&f.e), &f.admin), + ShareTokenError::SetupClosed, + ); + assert_eq!(f.token.vault(), Some(f.vault.address.clone())); +} + +#[test] +fn an_address_that_reports_no_clock_cannot_be_bound() { + let f = setup(); + + assert!(f + .token + .try_bind_vault(&Address::generate(&f.e), &f.admin) + .is_err()); + assert_eq!(f.token.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.token.try_bind_vault(&f.vault.address, &stranger).is_err()); + assert!(f.token.try_finish_setup(&stranger).is_err()); + assert!(f.token.setup_open()); +} + +fn implements_the_vault_view() {} + +#[test] +fn the_token_answers_the_view_the_vault_reads() { + implements_the_vault_view::(); + let f = setup(); + let view = bindings::VaultBoundClient::new(&f.e, &f.token.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 grants_and_manager_revokes_wait_after_setup() { + let f = setup(); + let early = Address::generate(&f.e); + f.token.grant_role(&early, &manager(), &f.admin); + assert!(f.token.has_role(&early, &manager()).is_some()); + + f.finish(); + + refused( + f.token + .try_grant_role(&Address::generate(&f.e), &manager(), &f.admin), + ShareTokenError::UseTimelock, + ); + refused( + f.token.try_revoke_role(&early, &manager(), &f.admin), + ShareTokenError::UseTimelock, + ); + assert!(f.token.has_role(&early, &manager()).is_some()); +} + +#[test] +fn rule_and_verifier_swaps_wait_after_setup() { + let f = setup(); + f.finish(); + + refused( + f.token + .try_set_compliance(&Address::generate(&f.e), &f.admin), + ShareTokenError::UseTimelock, + ); + refused( + f.token + .try_set_identity_verifier(&Address::generate(&f.e), &f.admin), + ShareTokenError::UseTimelock, + ); +} + +#[test] +fn revoking_compliance_stays_instant_after_setup() { + let f = setup(); + let officer = Address::generate(&f.e); + f.token.grant_role(&officer, &compliance(&f.e), &f.admin); + f.finish(); + + f.token.revoke_role(&officer, &compliance(&f.e), &f.admin); + + assert!(f.token.has_role(&officer, &compliance(&f.e)).is_none()); +} + +#[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.token + .try_transfer_admin_role(&next, &(f.e.ledger().sequence() + 100)), + ShareTokenError::UseTimelock, + ); + + let id = f.propose(TokenChange::Admin(next.clone())); + f.advance(MIN_DELAY); + f.token.apply_change(&id, &f.admin); + f.token.transfer_admin_role(&next, &0); + + assert!(f.token.try_accept_admin_transfer().is_err()); + assert_eq!(f.token.get_admin(), Some(f.admin.clone())); +} + +#[test] +fn setup_cannot_finish_while_a_hand_over_is_pending() { + let f = setup(); + let next = Address::generate(&f.e); + f.token.bind_vault(&f.vault.address, &f.admin); + f.token + .transfer_admin_role(&next, &(f.e.ledger().sequence() + 100)); + + refused( + f.token.try_finish_setup(&f.admin), + ShareTokenError::AdminTransferPending, + ); + + f.token.transfer_admin_role(&next, &0); + f.token.finish_setup(&f.admin); + assert!(f.token.try_accept_admin_transfer().is_err()); + assert_eq!(f.token.get_admin(), Some(f.admin.clone())); +} + +#[test] +fn an_expired_hand_over_does_not_hold_setup_open() { + let f = setup(); + f.token.bind_vault(&f.vault.address, &f.admin); + f.token + .transfer_admin_role(&Address::generate(&f.e), &(f.e.ledger().sequence() + 10)); + f.e.ledger().with_mut(|l| l.sequence_number += 11); + + f.token.finish_setup(&f.admin); + + assert!(!f.token.setup_open()); +} + +#[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(); + + refused( + f.token + .try_set_role_admin(&manager(), &symbol_short!("guardian")), + ShareTokenError::UseTimelock, + ); +} + +#[test] +fn nothing_is_proposed_during_setup() { + let f = setup(); + + refused( + f.token.try_propose_change( + &TokenChange::GrantRole(manager(), f.admin.clone()), + &f.admin, + ), + ShareTokenError::SetupOpen, + ); + assert_eq!(f.token.pending_changes().len(), 0); +} + +#[test] +fn a_grant_lands_one_vault_delay_after_it_was_proposed() { + let f = setup(); + f.finish(); + let next = Address::generate(&f.e); + + let id = f.propose(TokenChange::GrantRole(manager(), next.clone())); + let pending = f.token.pending_changes(); + assert_eq!(pending.len(), 1); + assert_eq!( + pending.get(0).unwrap().eta, + f.e.ledger().timestamp() + MIN_DELAY + ); + + f.advance(MIN_DELAY - 1); + refused( + f.token.try_apply_change(&id, &f.admin), + ShareTokenError::ChangeNotReady, + ); + + f.advance(1); + f.token.apply_change(&id, &f.admin); + assert!(f.token.has_role(&next, &manager()).is_some()); + assert_eq!(f.token.pending_changes().len(), 0); +} + +#[test] +fn a_revoke_lands_through_the_timelock() { + let f = setup(); + let holder = Address::generate(&f.e); + f.token.grant_role(&holder, &manager(), &f.admin); + f.finish(); + + let id = f.propose(TokenChange::RevokeRole(manager(), holder.clone())); + f.advance(MIN_DELAY); + f.token.apply_change(&id, &f.admin); + + assert!(f.token.has_role(&holder, &manager()).is_none()); +} + +#[test] +fn a_vault_pause_moves_the_eta_and_holds_the_change() { + let f = setup(); + f.finish(); + let id = f.propose(TokenChange::Compliance(Address::generate(&f.e))); + let eta = f.token.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); + + refused( + f.token.try_apply_change(&id, &f.admin), + ShareTokenError::VaultPaused, + ); + + f.clock(|c| c.paused = false); + assert_eq!( + f.token.pending_changes().get(0).unwrap().eta, + eta + 3 * DAY_DURATION + ); + f.advance(3 * DAY_DURATION); + f.token.apply_change(&id, &f.admin); +} + +#[test] +fn nothing_is_proposed_or_applied_while_the_vault_winds_down() { + let f = setup(); + f.finish(); + let id = f.propose(TokenChange::Compliance(Address::generate(&f.e))); + f.clock(|c| c.winding_down = true); + f.advance(MIN_DELAY); + + refused( + f.token + .try_propose_change(&TokenChange::Compliance(Address::generate(&f.e)), &f.admin), + ShareTokenError::WindDownActive, + ); + refused( + f.token.try_apply_change(&id, &f.admin), + ShareTokenError::WindDownActive, + ); + f.token.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(TokenChange::Compliance(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.token.try_apply_change(&id, &f.admin), + ShareTokenError::DelayBelowNotice, + ); +} + +#[test] +fn one_upgrade_waits_at_a_time() { + let f = setup(); + f.finish(); + f.propose(TokenChange::Upgrade(BytesN::from_array(&f.e, &[7; 32]))); + + refused( + f.token.try_propose_change( + &TokenChange::Upgrade(BytesN::from_array(&f.e, &[9; 32])), + &f.admin, + ), + ShareTokenError::UpgradeAlreadyPending, + ); +} + +#[test] +fn only_the_admin_proposes_cancels_and_applies() { + let f = setup(); + f.finish(); + let stranger = Address::generate(&f.e); + let change = TokenChange::Compliance(Address::generate(&f.e)); + let id = f.propose(change.clone()); + f.advance(MIN_DELAY); + + f.e.set_auths(&[]); + assert!(f.token.try_propose_change(&change, &stranger).is_err()); + assert!(f.token.try_cancel_change(&id, &stranger).is_err()); + assert!(f.token.try_apply_change(&id, &stranger).is_err()); + assert_eq!(f.token.pending_changes().len(), 1); +} + +#[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(TokenChange::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.token.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.token.apply_change(&id, &f.admin); + + f.e.mock_all_auths(); + assert_eq!(f.token.get_admin(), Some(f.admin.clone())); + f.token.accept_admin_transfer(); + assert_eq!(f.token.get_admin(), Some(next)); +} + +#[test] +fn the_hand_over_offer_lasts_seven_days_of_ledgers() { + let f = setup(); + f.finish(); + let next = Address::generate(&f.e); + let id = f.propose(TokenChange::Admin(next)); + f.advance(MIN_DELAY); + f.token.apply_change(&id, &f.admin); + + f.e.ledger() + .with_mut(|l| l.sequence_number += ADMIN_ACCEPT_LEDGERS + 1); + + assert!(f.token.try_accept_admin_transfer().is_err()); +} + +#[test] +fn applied_rule_and_verifier_changes_take_effect() { + let f = setup(); + f.finish(); + let rules = Address::generate(&f.e); + let verifier = Address::generate(&f.e); + let first = f.propose(TokenChange::Compliance(rules.clone())); + let second = f.propose(TokenChange::IdentityVerifier(verifier.clone())); + f.advance(MIN_DELAY); + + f.token.apply_change(&first, &f.admin); + f.token.apply_change(&second, &f.admin); + + assert_eq!(f.token.compliance(), rules); + assert_eq!(f.token.identity_verifier(), verifier); +} + +#[test] +fn proposing_and_applying_publish_the_change() { + let f = setup(); + f.finish(); + let change = TokenChange::Compliance(Address::generate(&f.e)); + let eta = f.e.ledger().timestamp() + MIN_DELAY; + + let id = f.propose(change.clone()); + assert_eq!( + f.e.events().all().filter_by_contract(&f.token.address), + [ChangeProposed { + id, + change: change.clone(), + eta, + } + .to_xdr(&f.e, &f.token.address)] + ); + + f.advance(MIN_DELAY); + f.token.apply_change(&id, &f.admin); + let events = f.e.events().all().filter_by_contract(&f.token.address); + assert_eq!( + events.events().last(), + Some(&ChangeApplied { id, change }.to_xdr(&f.e, &f.token.address)) + ); +} + +#[test] +fn the_schema_version_is_one_after_construction() { + let f = setup(); + + assert_eq!(f.token.schema_version(), 1); +} + +#[test] +fn every_admin_call_restores_the_instance_ttl() { + let f = setup(); + let holder = Address::generate(&f.e); + let next = Address::generate(&f.e); + + f.restores_the_instance(|| f.token.grant_role(&holder, &manager(), &f.admin)); + f.restores_the_instance(|| f.token.revoke_role(&holder, &manager(), &f.admin)); + f.restores_the_instance(|| { + f.token + .transfer_admin_role(&next, &(f.e.ledger().sequence() + ADMIN_ACCEPT_LEDGERS)) + }); + f.restores_the_instance(|| f.token.transfer_admin_role(&next, &0)); + f.restores_the_instance(|| f.token.bind_vault(&f.vault.address, &f.admin)); + f.restores_the_instance(|| f.token.finish_setup(&f.admin)); + + let rules = TokenChange::Compliance(Address::generate(&f.e)); + let mut id = 0; + f.restores_the_instance(|| id = f.propose(rules.clone())); + f.restores_the_instance(|| f.token.cancel_change(&id, &f.admin)); + + let id = f.propose(rules); + f.advance(MIN_DELAY); + f.restores_the_instance(|| f.token.apply_change(&id, &f.admin)); +} diff --git a/crates/bindings/src/lib.rs b/crates/bindings/src/lib.rs index 7452dc1f..ed95d44d 100644 --- a/crates/bindings/src/lib.rs +++ b/crates/bindings/src/lib.rs @@ -1,6 +1,6 @@ #![no_std] -use soroban_sdk::{contracttrait, contracttype, Env}; +use soroban_sdk::{contracttrait, contracttype, Address, Env}; #[contracttype] #[derive(Clone, Copy, PartialEq, Eq, Debug)] @@ -21,3 +21,12 @@ pub trait OracleFeed { /// How long a report stays valid after it is accepted. fn freshness_duration(e: &Env) -> u64; } + +/// What a contract that runs on a vault's timelock reports to that vault. +#[contracttrait] +pub trait VaultBound { + /// The vault whose clock this contract reads, once bound. + fn vault(e: &Env) -> Option
; + /// Whether this contract's admin changes are still instant. + fn setup_open(e: &Env) -> bool; +} From 71fa5cc1ae106b61639a74967cb8e9d068466e98 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 06:34:34 -0300 Subject: [PATCH 036/147] test(token): upgrade wasm under live balances --- contracts/async-vault/src/test/mod.rs | 16 ++++++- contracts/async-vault/src/test/upgrade.rs | 54 +++++++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/contracts/async-vault/src/test/mod.rs b/contracts/async-vault/src/test/mod.rs index b807f916..56604860 100644 --- a/contracts/async-vault/src/test/mod.rs +++ b/contracts/async-vault/src/test/mod.rs @@ -199,6 +199,18 @@ fn setup<'a>() -> Fixture<'a> { /// Builds the vault against a caller-chosen feed, so cooldown, freshness and the /// deviation cap can be exercised from a vault test rather than only an oracle one. fn setup_with_feed<'a>(feed: OracleConfig) -> Fixture<'a> { + setup_with(feed, |e, args| e.register(ShareToken, args)) +} + +/// The share token's constructor arguments, in order. +type TokenArgs = (String, String, Address, Address, Address, Address); + +/// Builds the vault around a token the caller registers, so a test can run the +/// token's built Wasm instead of its native code. +fn setup_with<'a>( + feed: OracleConfig, + register_token: impl FnOnce(&Env, TokenArgs) -> Address, +) -> Fixture<'a> { let e = Env::default(); e.mock_all_auths(); e.ledger().set_timestamp(10_000); @@ -221,8 +233,8 @@ fn setup_with_feed<'a>(feed: OracleConfig) -> Fixture<'a> { let share = ShareTokenClient::new( &e, - &e.register( - ShareToken, + ®ister_token( + &e, ( String::from_str(&e, "Strata Vault USDC"), String::from_str(&e, "bvUSDC"), diff --git a/contracts/async-vault/src/test/upgrade.rs b/contracts/async-vault/src/test/upgrade.rs index 1ab20f74..4f4fbe12 100644 --- a/contracts/async-vault/src/test/upgrade.rs +++ b/contracts/async-vault/src/test/upgrade.rs @@ -411,12 +411,66 @@ fn the_vault_reports_its_clock_to_the_token_and_the_oracle() { #[cfg(feature = "upgrade_wasm")] mod with_wasm { + use share_token::TokenChange; + use super::*; soroban_sdk::contractimport!( file = "../../fixtures/upgrade-target/target/wasm32v1-none/release/upgrade_target.wasm" ); + mod token_v1 { + soroban_sdk::contractimport!(file = "../../target/wasm32v1-none/release/share_token.wasm"); + } + + mod token_probe { + soroban_sdk::contractimport!( + file = "../../target/upgrade-probe/wasm32v1-none/release/share_token.wasm" + ); + } + + #[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)); + let holder = f.holder(100); + let other = f.investor(1); + let epoch = f.vault.request_redeem(&holder, &40); + let depositor = f.investor(500); + assert_eq!(f.vault.request_deposit(&depositor, &500), epoch); + + f.share.bind_vault(&f.vault.address, &f.admin); + f.share.finish_setup(&f.admin); + let probe_hash = f.e.deployer().upload_contract_wasm(token_probe::WASM); + let id = f + .share + .propose_change(&TokenChange::Upgrade(probe_hash), &f.admin); + f.advance(MIN_UPGRADE_DELAY); + f.share.apply_change(&id, &f.admin); + + let probe = token_probe::Client::new(&f.e, &f.share.address); + assert!(probe.probe()); + assert_eq!(probe.schema_version(), 1); + probe.migrate(&f.admin); + assert_eq!(probe.schema_version(), 2); + assert!(probe.try_migrate(&f.admin).is_err()); + + assert_eq!(f.shares(&holder), 60); + assert_eq!(f.shares(&f.vault.address), 40); + assert_eq!( + f.vault.get_redeem_request(&epoch, &holder).unwrap().shares, + 40 + ); + + f.fulfill_epoch_at(f.close_epoch(), wad(2)); + assert_eq!(f.vault.claim_redeem(&holder, &epoch), 80); + assert_eq!(f.vault.claim_deposit(&depositor, &epoch), 250); + assert_eq!(f.shares(&depositor), 250); + assert_eq!(f.shares(&f.vault.address), 0); + + f.share.transfer(&holder, &other, &10); + assert_eq!(f.shares(&other), 10); + } + #[test] fn immediate_upgrade_with_valid_wasm_does_not_replace_code() { let f = setup(); From 363edc8b774e17cb108659f80b66c0efc3653724 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 06:43:28 -0300 Subject: [PATCH 037/147] feat(harness): finish the token's setup on deploy --- scripts/harness/deploy.ts | 7 ++++++ scripts/harness/extend.ts | 4 +-- scripts/harness/guards.test.ts | 45 +++++++++++++++++++++++++++++++--- 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/scripts/harness/deploy.ts b/scripts/harness/deploy.ts index 8b230e0f..ed4e0599 100644 --- a/scripts/harness/deploy.ts +++ b/scripts/harness/deploy.ts @@ -156,6 +156,13 @@ async function grantAndVerify( throw new Error("the vault did not receive the share token's manager role") console.log("vault holds share_token manager role") + // Grants are instant only during setup, so the token's setup closes after them. + await (await token.bind_vault({ vault, caller })).signAndSend() + await (await token.finish_setup({ caller })).signAndSend() + if ((await token.setup_open()).result) + throw new Error("the share token's setup did not finish") + console.log("share_token bound to the vault, setup finished") + const { Client: IdentityVerifier } = (await import( clientEntry("identity_verifier") )) as typeof IdentityVerifierModule diff --git a/scripts/harness/extend.ts b/scripts/harness/extend.ts index b8e27b73..9550319b 100644 --- a/scripts/harness/extend.ts +++ b/scripts/harness/extend.ts @@ -30,8 +30,8 @@ interface DeploymentRecord { } // Contracts whose storage holds a timelock change queue, by the name they -// deploy under. Only the vault keeps one today. -const CONTRACTS_WITH_QUEUES = new Set(["async_vault"]) +// deploy under. +const CONTRACTS_WITH_QUEUES = new Set(["async_vault", "share_token"]) /** * 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 8106e152..8579ccd9 100644 --- a/scripts/harness/guards.test.ts +++ b/scripts/harness/guards.test.ts @@ -201,18 +201,55 @@ describe("the compliance authority's reach", () => { ) }) - it("compliance cannot swap the identity verifier", async () => { + it("compliance cannot propose a new identity verifier", async () => { await expect( send( - shareToken(c, compliance).set_identity_verifier({ - identity_verifier: c.deployment.contracts.compliance, - operator: compliance.publicKey(), + shareToken(c, compliance).propose_change({ + change: { + tag: "IdentityVerifier", + values: [c.deployment.contracts.compliance], + }, + caller: compliance.publicKey(), }), ), ).rejects.toThrow(/signatures/) }) }) +describe("the share token after setup", () => { + const caller = () => governance.publicKey() + const verifier = () => c.deployment.contracts.identity_verifier + + it("refuses an instant verifier swap, even from governance", async () => { + const token = shareToken(c, governance) + expectRefused( + await token.set_identity_verifier({ + identity_verifier: verifier(), + operator: caller(), + }), + token, + "UseTimelock", + ) + }) + + it("queues the swap behind the vault's delay", async () => { + const token = shareToken(c, governance) + const id = await send( + token.propose_change({ + change: { tag: "IdentityVerifier", values: [verifier()] }, + caller: caller(), + }), + ) + expectRefused( + await token.apply_change({ id, caller: caller() }), + token, + "ChangeNotReady", + ) + await send(token.cancel_change({ id, caller: caller() })) + expect((await token.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 2bf3944a56637afd3afd9f89d83d8a3a10bfa0d0 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 06:48:23 -0300 Subject: [PATCH 038/147] docs: put the token's admin behind the timelock --- docs/AUTHORIZATION_MATRIX.md | 20 ++++++++++--- docs/TESTING.md | 22 ++++++++++---- docs/kit-spec.md | 40 ++++++++++++++++++------- docs/strata-product-and-architecture.md | 8 ++--- 4 files changed, 66 insertions(+), 24 deletions(-) diff --git a/docs/AUTHORIZATION_MATRIX.md b/docs/AUTHORIZATION_MATRIX.md index 20198a02..c06f77cf 100644 --- a/docs/AUTHORIZATION_MATRIX.md +++ b/docs/AUTHORIZATION_MATRIX.md @@ -36,6 +36,7 @@ In `scripts/harness/deploy.ts` and automated deployment scripts, the separation - The share token's constructor grants `compliance` to the compliance key and grants `manager` to no one; the identity verifier is constructed with the compliance key as admin. - 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). --- @@ -94,9 +95,9 @@ Tests: [`controls.rs`](../contracts/async-vault/src/test/controls.rs), [`epochs. ### 5.2 ShareToken (`contracts/share-token`) -The token's `admin` is governance. `pause`, `unpause`, `set_compliance` and `set_identity_verifier` need the admin; the guardian holds no role on the token. The `manager` role is held by the vault and the `compliance` role by the compliance authority. +The token's `admin` is governance; the guardian holds no role on the token. Until `finish_setup`, admin changes are instant, so the deploy can grant the vault the `manager` role. 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, revoking `manager`, admin hand-over, `set_compliance`, `set_identity_verifier` and upgrades. Revoking `compliance` and `pause`/`unpause` stay instant. The `manager` role is held by the vault and the `compliance` role by the compliance authority. -Tests: [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.rs). +Tests: [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.rs) and [`contracts/share-token/src/test_governance.rs`](../contracts/share-token/src/test_governance.rs). | Entrypoint | Access Control / Caller Auth | State & Precondition Guards | Tested Refusal (Test Name) | |---|---|---|---| @@ -109,9 +110,20 @@ Tests: [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.r | `freeze_partial_tokens` | `#[only_role(operator, "compliance")]` | Balance sufficient | `test::unauthorized_caller_cannot_freeze_or_recover`, `test::the_manager_escrows_but_does_not_intervene` | | `unfreeze_partial_tokens`| `#[only_role(operator, "compliance")]` | Frozen amount sufficient | `test::unauthorized_caller_cannot_freeze_or_recover`, `test::the_manager_escrows_but_does_not_intervene` | | `recover_balance` | `#[only_role(operator, "compliance")]` | Target allowlisted and named by the verifier's `recovery_target` (the testnet stub names none, so recovery always fails there) | `test::unauthorized_caller_cannot_freeze_or_recover`, `test::the_compliance_authority_reaches_recovery` | -| `set_compliance` | `#[only_admin]`, `operator.require_auth()` | None | `test::unauthorized_caller_cannot_set_compliance_or_verifier`, `test::the_compliance_authority_cannot_mint_burn_or_swap_the_rules` | -| `set_identity_verifier` | `#[only_admin]`, `operator.require_auth()` | None | `test::unauthorized_caller_cannot_set_compliance_or_verifier`, `test::the_compliance_authority_cannot_mint_burn_or_swap_the_rules` | +| `set_compliance` | `#[only_admin]`, `operator.require_auth()` | Instant during setup; `UseTimelock` after | `test::unauthorized_caller_cannot_set_compliance_or_verifier`, `test::the_compliance_authority_cannot_mint_burn_or_swap_the_rules`, `test_governance::rule_and_verifier_swaps_wait_after_setup` | +| `set_identity_verifier` | `#[only_admin]`, `operator.require_auth()` | Instant during setup; `UseTimelock` after | `test::unauthorized_caller_cannot_set_compliance_or_verifier`, `test::the_compliance_authority_cannot_mint_burn_or_swap_the_rules`, `test_governance::rule_and_verifier_swaps_wait_after_setup` | | `renounce_admin` | Refused always (`ShareTokenError::AdminRequired`) | None | `test::the_admin_cannot_renounce_itself_out_of_the_token`; `test::admin_handover_in_two_steps_still_works`. A token without an admin could never be unpaused or rotate its manager; hand over through `transfer_admin_role` and `accept_admin_transfer`. | +| `bind_vault` | `#[only_admin]`, `caller.require_auth()` | During setup, once; the vault must answer `timelock_clock` | `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 | `test_governance::nothing_is_proposed_during_setup`, `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 | `test_governance::a_vault_pause_moves_the_eta_and_holds_the_change`, `test_governance::a_change_shorter_than_a_raised_notice_never_lands` | +| `pending_changes` | Open view | None | None (view) | +| `grant_role` | Admin, `caller.require_auth()` | Instant during setup; `UseTimelock` after | `test_governance::grants_and_manager_revokes_wait_after_setup` | +| `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` | ### 5.3 NavOracle (`contracts/nav-oracle`) diff --git a/docs/TESTING.md b/docs/TESTING.md index 4d700632..c9778141 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -11,9 +11,18 @@ 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 to a real WASM -build run only with `--features upgrade_wasm`, after the fixture in -[`fixtures/upgrade-target`](../fixtures/upgrade-target) is built. +[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`: + +```sh +stellar contract build --manifest-path fixtures/upgrade-target/Cargo.toml +stellar contract build --package share-token +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 +``` ## Storage and TTL @@ -29,7 +38,7 @@ contract extends, who pays, and how to restore an archived one. | Persistent entries round-trip and get the full TTL on write | [`crates/storage/src/test.rs`](../crates/storage/src/test.rs) | `persistent_round_trips`, `writing_sets_the_full_ttl` | | A read restores a decayed entry; a missing entry is not created | [`crates/storage/src/test.rs`](../crates/storage/src/test.rs) | `reading_a_decayed_entry_restores_the_full_ttl`, `reading_a_missing_entry_creates_nothing` | | Instance writes and reads extend the contract instance | [`crates/storage/src/test.rs`](../crates/storage/src/test.rs) | `instance_writes_bump_the_instance`, `reading_the_instance_restores_its_ttl` | -| The share token, identity verifier and compliance contract extend their own instance on every call that does work | [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.rs), [`contracts/identity-verifier/src/test.rs`](../contracts/identity-verifier/src/test.rs), [`contracts/compliance/src/test.rs`](../contracts/compliance/src/test.rs) | `a_transfer_restores_the_instance_ttl`, `the_vaults_mint_burn_and_escrow_restore_the_instance_ttl`, `allowing_and_checking_restore_the_instance_ttl`, `the_hooks_a_token_calls_after_moving_shares_restore_the_instance_ttl` | +| The share token, identity verifier and compliance contract extend their own instance on every call that does work | [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.rs), [`contracts/share-token/src/test_governance.rs`](../contracts/share-token/src/test_governance.rs), [`contracts/identity-verifier/src/test.rs`](../contracts/identity-verifier/src/test.rs), [`contracts/compliance/src/test.rs`](../contracts/compliance/src/test.rs) | `a_transfer_restores_the_instance_ttl`, `the_vaults_mint_burn_and_escrow_restore_the_instance_ttl`, `every_admin_call_restores_the_instance_ttl`, `allowing_and_checking_restore_the_instance_ttl`, `the_hooks_a_token_calls_after_moving_shares_restore_the_instance_ttl` | | Vault requests and epochs are written with the full TTL | [`contracts/async-vault/src/test/ttl.rs`](../contracts/async-vault/src/test/ttl.rs) | `a_request_and_its_epoch_are_written_with_the_full_ttl`, `a_redemption_request_is_written_with_the_full_ttl` | | 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` | @@ -59,6 +68,7 @@ nothing. | Guardian, governance and upgrade powers stay with their role | [`contracts/async-vault/src/test/controls.rs`](../contracts/async-vault/src/test/controls.rs), [`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) | `the_guardian_pauses_but_only_governance_unpauses`, `only_governance_proposes_and_cancels`, `only_governance_applies`, `only_governance_sets_the_delay` | | Roles that must stay separate are refused at deployment | [`contracts/async-vault/src/test/constructor.rs`](../contracts/async-vault/src/test/constructor.rs) | `the_treasury_may_not_also_be_the_guardian`, `compliance_may_not_also_be_governance` | | 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` | | Identity verifier: only the admin edits the allowlist | [`contracts/identity-verifier/src/test.rs`](../contracts/identity-verifier/src/test.rs) | `only_admin_can_allow` | @@ -74,6 +84,7 @@ so time rules and TTL can be reached in one test. | A valuation must be newer than the epoch close | [`contracts/async-vault/src/test/oracle_pricing.rs`](../contracts/async-vault/src/test/oracle_pricing.rs) | `an_epoch_is_not_priced_against_a_valuation_older_than_its_close`, `a_valuation_taken_in_the_closing_ledger_prices_the_epoch` | | 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` | | 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 @@ -96,4 +107,5 @@ contract and oracle, plus a Stellar Asset Contract for the deposit asset. | The vault mints and burns shares through its role on the share token | [`contracts/async-vault/src/test/constructor.rs`](../contracts/async-vault/src/test/constructor.rs), [`contracts/async-vault/src/test/redeem.rs`](../contracts/async-vault/src/test/redeem.rs) | `the_vault_holds_the_manager_role_on_the_share_token`, `claim_redeem_pays_assets_and_burns_the_shares` | | 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_proposal_replaces_the_code`, `a_migration_refuses_a_second_run` | +| 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` | diff --git a/docs/kit-spec.md b/docs/kit-spec.md index a65727d3..3a704085 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -91,6 +91,7 @@ The contracts enforce a set of mathematical and state-machine invariants across - Nothing is proposed or applied during an active wind-down (`WindDownActive`). - 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. ### 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. @@ -220,7 +221,16 @@ Strata distributes control across five native Stellar multisig authorities and p | **`ShareToken`** | `burn` | Manager (Vault) | `#[only_role(operator, "manager")]` | Bypasses token pause | | **`ShareToken`** | `forced_transfer` | Manager (Vault) or Compliance | `#[only_any_role(operator, ["manager", "compliance"])]` | Bypasses token pause | | **`ShareToken`** | `set_address_frozen`, `freeze_partial_tokens`, `unfreeze_partial_tokens`, `recover_balance` | Compliance | `#[only_role(operator, "compliance")]` | No | -| **`ShareToken`** | `set_compliance`, `set_identity_verifier` | Governance | `#[only_admin]` | No | +| **`ShareToken`** | `set_compliance`, `set_identity_verifier` | Governance | `#[only_admin]`; instant during setup, then `propose_change` | No | +| **`ShareToken`** | `bind_vault` | Governance (token admin) | `#[only_admin]`; during setup, once | No | +| **`ShareToken`** | `finish_setup` | Governance | `#[only_admin]`; needs a bound vault and no pending admin hand-over | No | +| **`ShareToken`** | `propose_change` | Governance | `#[only_admin]`; after setup | No | +| **`ShareToken`** | `cancel_change` | Governance | `#[only_admin]` | No | +| **`ShareToken`** | `apply_change` | Governance | `#[only_admin]`; after the vault's delay | Refused while the vault is paused or winding down | +| **`ShareToken`** | `pending_changes` | Permissionless | None (view) | No | +| **`ShareToken`** | `grant_role`, `transfer_admin_role` | Governance | Instant during setup, then `propose_change` (a zero `live_until_ledger` cancels at once) | No | +| **`ShareToken`** | `revoke_role` | Governance | `compliance`: instant. `manager`: instant during setup, then `propose_change` | No | +| **`ShareToken`** | `set_role_admin` | None | Always refused (`UseTimelock`) | N/A | | **`IdentityVerifier`** | `allow` | Admin (Compliance) | `#[only_admin]` | No | | **`IdentityVerifier`** | `set_claim_topics_and_issuers`| Admin (Compliance) | `#[only_admin]` | No | @@ -234,6 +244,7 @@ A clean deployment executes in three sequenced phases: - Deploy `compliance`, `identity_verifier`, `share_token`, `nav_oracle`, and `async_vault` with constructor arguments matching `environments.toml`. 2. **Phase 2: Post-Deployment Role Wiring** - Call `ShareToken::grant_role("manager", vault_address)` to authorize lazy share minting and burning. + - 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. 3. **Phase 3: Operational Parameterization** @@ -253,7 +264,7 @@ instance is archived refuses every call. |---|---|---|---| | `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 | -| `share_token` | Its instance | 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` | 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 | @@ -262,15 +273,16 @@ The lengths are the constants in `crates/storage` shorter). They are fixed in code: a governance setting would add an admin power for little gain. -**Pending governance changes.** A change waiting in `async_vault`'s timelock -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. `npm run extend` also keeps it alive: it reads the -vault's open ids from its `pending_changes()` view, builds each one's storage -key the way the timelock crate does, and extends it the same 30 days as every -instance and Wasm. +**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 +(`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. +`npm run extend` also keeps it alive: it reads each contract's open ids from +its `pending_changes()` view, builds each one's storage key the way the +timelock crate does, and extends it the same 30 days as every instance and +Wasm. **What OpenZeppelin extends.** `stellar-tokens` 0.7.2 stores the share token's balances and frozen amounts, and the compliance contract's modules and bound @@ -321,3 +333,9 @@ implementation against what the vault calls. 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 +`VaultBound` (`crates/bindings`): `vault` names the vault it 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 29664a39..5ba6595b 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -285,9 +285,9 @@ 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. 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 | -| **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) | +| **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`) | +| **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 | | **Authorities** | governance (parameters, roles, timelocked upgrades), compliance (allowlist, token interventions), attestation (valuation only), treasury (reserve movements only), guardian (pause; never payable claims) | Each contract grants its own roles; the oracle's, share token's and verifier's admins and the oracle's attester and guardian are constructor parameters independent of the vault's roles, equal to governance, compliance and guardian by deploy only; treasury and guardian distinct, treasury and governance distinct, compliance distinct from governance and treasury; governance and guardian may coincide; the vault's manager role is unconstrained | | **Custodian** | Off-chain party holding the real-world structure; a genesis-configured slot rotatable only by governance | Not an on-chain authority | @@ -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. | +| 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. | | 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 5c79f2212568fd208b3fcf5b0dcb3b4872b5d831 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 07:35:06 -0300 Subject: [PATCH 039/147] fix(token): address timelock review round 1 --- contracts/async-vault/src/test/upgrade.rs | 5 +- contracts/share-token/src/contract.rs | 9 ++- contracts/share-token/src/error.rs | 8 +- contracts/share-token/src/governance.rs | 3 +- contracts/share-token/src/test_governance.rs | 84 +++++++++++++++++--- crates/bindings/src/lib.rs | 2 +- docs/AUTHORIZATION_MATRIX.md | 2 +- docs/kit-spec.md | 2 +- 8 files changed, 94 insertions(+), 21 deletions(-) diff --git a/contracts/async-vault/src/test/upgrade.rs b/contracts/async-vault/src/test/upgrade.rs index 4f4fbe12..9c30f5d1 100644 --- a/contracts/async-vault/src/test/upgrade.rs +++ b/contracts/async-vault/src/test/upgrade.rs @@ -452,7 +452,10 @@ mod with_wasm { assert_eq!(probe.schema_version(), 1); probe.migrate(&f.admin); assert_eq!(probe.schema_version(), 2); - assert!(probe.try_migrate(&f.admin).is_err()); + assert_eq!( + probe.try_migrate(&f.admin), + Err(Ok(token_probe::ProbeError::AlreadyMigrated.into())) + ); assert_eq!(f.shares(&holder), 60); assert_eq!(f.shares(&f.vault.address), 40); diff --git a/contracts/share-token/src/contract.rs b/contracts/share-token/src/contract.rs index 7734d20a..29394bd6 100644 --- a/contracts/share-token/src/contract.rs +++ b/contracts/share-token/src/contract.rs @@ -2,7 +2,7 @@ use crate::error::ShareTokenError; use crate::governance::{self, TokenChange, TokenPendingChange}; use crate::roles::COMPLIANCE_ROLE; -use bindings::VaultBound; +use bindings::VaultBinding; use soroban_sdk::{ contract, contractimpl, panic_with_error, Address, Env, MuxedAddress, String, Symbol, Vec, }; @@ -94,7 +94,7 @@ impl ShareToken { } #[contractimpl(contracttrait)] -impl VaultBound for ShareToken { +impl VaultBinding for ShareToken { fn vault(e: &Env) -> Option
{ timelock::setup::source(e) } @@ -181,8 +181,9 @@ impl AccessControl for ShareToken { } /// Refused. A token without an admin could never be unpaused, never rotate - /// the manager role and never replace the vault that holds it. - /// `transfer_admin_role` and `accept_admin_transfer` are the way out. + /// the manager role and never replace the vault that holds it. 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, ShareTokenError::AdminRequired); } diff --git a/contracts/share-token/src/error.rs b/contracts/share-token/src/error.rs index 37ef98d0..b3b950be 100644 --- a/contracts/share-token/src/error.rs +++ b/contracts/share-token/src/error.rs @@ -7,8 +7,9 @@ use soroban_sdk::contracterror; #[repr(u32)] pub enum ShareTokenError { /// The token must always have an admin: `pause`, `unpause` and role - /// management need it. The admin hands over through `transfer_admin_role` - /// and `accept_admin_transfer` instead. + /// management need it. Hand it over instead: propose and apply an `Admin` + /// change (`transfer_admin_role` during setup), then the nominee calls + /// `accept_admin_transfer`. AdminRequired = 7000, /// No pending change has this id. ChangeNotFound = 7001, @@ -18,7 +19,8 @@ pub enum ShareTokenError { TooManyPendingChanges = 7003, /// Changes are instant until setup finishes; there is nothing to propose yet. SetupOpen = 7004, - /// Setup has finished, so this change waits for the timelock. Propose it. + /// A direct call cannot make this change. After setup it goes through + /// `propose_change`; setting a role admin is never allowed. UseTimelock = 7005, /// Only possible while setup is open, and only once. SetupClosed = 7006, diff --git a/contracts/share-token/src/governance.rs b/contracts/share-token/src/governance.rs index e90b1203..b22f960b 100644 --- a/contracts/share-token/src/governance.rs +++ b/contracts/share-token/src/governance.rs @@ -112,10 +112,11 @@ pub(crate) fn propose(e: &Env, change: TokenChange) -> u32 { 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.eta(&vault_clock(e)), + eta: schedule.proposed_at.saturating_add(schedule.delay), } .publish(e); id diff --git a/contracts/share-token/src/test_governance.rs b/contracts/share-token/src/test_governance.rs index d9538bdc..522a27a0 100644 --- a/contracts/share-token/src/test_governance.rs +++ b/contracts/share-token/src/test_governance.rs @@ -5,6 +5,7 @@ use soroban_sdk::{ }, Address, BytesN, Env, Event as _, IntoVal, String, Symbol, }; +use stellar_access::role_transfer::RoleTransferError; use storage::INSTANCE_EXTEND_AMOUNT; use timelock::{Clock, DAY_DURATION, MIN_DELAY}; @@ -15,7 +16,7 @@ use crate::{ const TWO_DAYS_IN_LEDGERS: u32 = 2 * 17_280; -/// Plays the vault: reports whatever clock a test sets. +/// Plays the vault: reports whatever clock a test sets, and counts the reads. #[contract] struct ClockStub; @@ -24,11 +25,20 @@ 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() } } @@ -64,6 +74,14 @@ impl Fixture<'_> { self.token.propose_change(&change, &self.admin) } + /// Lands an `Admin` change for `next` and returns the ledger of the offer. + fn offer_admin(&self, next: &Address) -> u32 { + let id = self.propose(TokenChange::Admin(next.clone())); + self.advance(MIN_DELAY); + self.token.apply_change(&id, &self.admin); + self.e.ledger().sequence() + } + fn instance_ttl(&self) -> u32 { self.e.as_contract(&self.token.address, || { self.e.storage().instance().get_ttl() @@ -186,13 +204,13 @@ fn only_the_admin_binds_and_finishes() { assert!(f.token.setup_open()); } -fn implements_the_vault_view() {} +fn implements_the_vault_view() {} #[test] fn the_token_answers_the_view_the_vault_reads() { implements_the_vault_view::(); let f = setup(); - let view = bindings::VaultBoundClient::new(&f.e, &f.token.address); + let view = bindings::VaultBindingClient::new(&f.e, &f.token.address); assert_eq!(view.vault(), None); assert!(view.setup_open()); @@ -303,6 +321,11 @@ fn an_expired_hand_over_does_not_hold_setup_open() { f.token.finish_setup(&f.admin); assert!(!f.token.setup_open()); + assert_eq!( + f.token.try_accept_admin_transfer(), + Err(Ok(RoleTransferError::NoPendingTransfer.into())) + ); + assert_eq!(f.token.get_admin(), Some(f.admin.clone())); } #[test] @@ -514,18 +537,33 @@ fn an_applied_admin_change_starts_a_hand_over_the_next_admin_accepts() { } #[test] -fn the_hand_over_offer_lasts_seven_days_of_ledgers() { +fn the_hand_over_offer_is_open_on_its_last_ledger() { let f = setup(); f.finish(); let next = Address::generate(&f.e); - let id = f.propose(TokenChange::Admin(next)); - f.advance(MIN_DELAY); - f.token.apply_change(&id, &f.admin); + let offered_at = f.offer_admin(&next); f.e.ledger() - .with_mut(|l| l.sequence_number += ADMIN_ACCEPT_LEDGERS + 1); + .with_mut(|l| l.sequence_number = offered_at + ADMIN_ACCEPT_LEDGERS); + f.token.accept_admin_transfer(); - assert!(f.token.try_accept_admin_transfer().is_err()); + assert_eq!(f.token.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.token.try_accept_admin_transfer(), + Err(Ok(RoleTransferError::NoPendingTransfer.into())) + ); + assert_eq!(f.token.get_admin(), Some(f.admin.clone())); } #[test] @@ -572,6 +610,34 @@ fn proposing_and_applying_publish_the_change() { ); } +#[test] +fn proposing_reads_the_vault_clock_once() { + let f = setup(); + f.finish(); + let before = f.vault.reads(); + + f.propose(TokenChange::Compliance(Address::generate(&f.e))); + + 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 = TokenChange::Compliance(Address::generate(&f.e)); + let eta = f.e.ledger().timestamp() + MIN_DELAY; + + let id = f.propose(change.clone()); + + assert_eq!( + f.e.events().all().filter_by_contract(&f.token.address), + [ChangeProposed { id, change, eta }.to_xdr(&f.e, &f.token.address)] + ); + assert_eq!(f.token.pending_changes().get(0).unwrap().eta, eta); +} + #[test] fn the_schema_version_is_one_after_construction() { let f = setup(); diff --git a/crates/bindings/src/lib.rs b/crates/bindings/src/lib.rs index ed95d44d..eee072f1 100644 --- a/crates/bindings/src/lib.rs +++ b/crates/bindings/src/lib.rs @@ -24,7 +24,7 @@ pub trait OracleFeed { /// What a contract that runs on a vault's timelock reports to that vault. #[contracttrait] -pub trait VaultBound { +pub trait VaultBinding { /// The vault whose clock this contract reads, once bound. fn vault(e: &Env) -> Option
; /// Whether this contract's admin changes are still instant. diff --git a/docs/AUTHORIZATION_MATRIX.md b/docs/AUTHORIZATION_MATRIX.md index c06f77cf..8d24c7ac 100644 --- a/docs/AUTHORIZATION_MATRIX.md +++ b/docs/AUTHORIZATION_MATRIX.md @@ -112,7 +112,7 @@ Tests: [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.r | `recover_balance` | `#[only_role(operator, "compliance")]` | Target allowlisted and named by the verifier's `recovery_target` (the testnet stub names none, so recovery always fails there) | `test::unauthorized_caller_cannot_freeze_or_recover`, `test::the_compliance_authority_reaches_recovery` | | `set_compliance` | `#[only_admin]`, `operator.require_auth()` | Instant during setup; `UseTimelock` after | `test::unauthorized_caller_cannot_set_compliance_or_verifier`, `test::the_compliance_authority_cannot_mint_burn_or_swap_the_rules`, `test_governance::rule_and_verifier_swaps_wait_after_setup` | | `set_identity_verifier` | `#[only_admin]`, `operator.require_auth()` | Instant during setup; `UseTimelock` after | `test::unauthorized_caller_cannot_set_compliance_or_verifier`, `test::the_compliance_authority_cannot_mint_burn_or_swap_the_rules`, `test_governance::rule_and_verifier_swaps_wait_after_setup` | -| `renounce_admin` | Refused always (`ShareTokenError::AdminRequired`) | None | `test::the_admin_cannot_renounce_itself_out_of_the_token`; `test::admin_handover_in_two_steps_still_works`. A token without an admin could never be unpaused or rotate its manager; hand over through `transfer_admin_role` and `accept_admin_transfer`. | +| `renounce_admin` | Refused always (`ShareTokenError::AdminRequired`) | None | `test::the_admin_cannot_renounce_itself_out_of_the_token`; `test::admin_handover_in_two_steps_still_works`; `test_governance::an_applied_admin_change_starts_a_hand_over_the_next_admin_accepts`. A token without an admin could never be unpaused or rotate its manager; 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::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 | `test_governance::nothing_is_proposed_during_setup`, `test_governance::only_the_admin_proposes_cancels_and_applies` | diff --git a/docs/kit-spec.md b/docs/kit-spec.md index 3a704085..62b3e863 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -337,5 +337,5 @@ 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 -`VaultBound` (`crates/bindings`): `vault` names the vault it is bound to, and +`VaultBinding` (`crates/bindings`): `vault` names the vault it is bound to, and `setup_open` says whether its admin changes are still instant. 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 040/147] 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 8b6319a8..22a9197a 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 23e0f66b..5061ab97 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 00000000..553af765 --- /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 8c6f6f0a..bf10ba75 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 917f6d88..59af24e2 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 00000000..ebb1b589 --- /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 041/147] 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 ed4e0599..5d6bc496 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 9550319b..78cc4d1f 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 8579ccd9..3f51e76a 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 042/147] 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 8d24c7ac..adfe563d 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 c9778141..76612a31 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 62b3e863..01852277 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 5ba6595b..035e4eb3 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 043/147] 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 18bc4054..1353f7bc 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 9c30f5d1..40065197 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 ebb1b589..3cdbcb5e 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 522a27a0..2e5e07e6 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 adfe563d..937ba7bd 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 76612a31..4cc71dc0 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 01852277..2a379128 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. | --- From fbeb012bfc9c7109ce813eaae5221b2089269729 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:13:21 -0300 Subject: [PATCH 044/147] feat(vault): timelock governance after setup --- contracts/async-vault/Cargo.toml | 2 + contracts/async-vault/src/deposit.rs | 2 + contracts/async-vault/src/error.rs | 18 + contracts/async-vault/src/event.rs | 3 + contracts/async-vault/src/governance.rs | 192 ++++++++- contracts/async-vault/src/lib.rs | 80 +++- contracts/async-vault/src/test/cancel.rs | 2 - contracts/async-vault/src/test/compliance.rs | 1 + contracts/async-vault/src/test/controls.rs | 21 +- contracts/async-vault/src/test/delisted.rs | 6 + contracts/async-vault/src/test/deposit_cap.rs | 1 - contracts/async-vault/src/test/governance.rs | 392 ++++++++++++++++++ contracts/async-vault/src/test/mod.rs | 101 ++++- contracts/async-vault/src/test/multi_epoch.rs | 1 - contracts/async-vault/src/test/setup.rs | 163 ++++++++ contracts/async-vault/src/test/treasury.rs | 19 +- contracts/async-vault/src/test/upgrade.rs | 4 +- contracts/async-vault/src/test/wind_down.rs | 37 +- contracts/async-vault/src/wind_down.rs | 20 +- environments.toml | 10 +- scripts/harness/deploy.ts | 29 ++ scripts/harness/guards.test.ts | 70 +++- scripts/harness/happy-path.test.ts | 19 +- scripts/harness/seed.ts | 14 +- 24 files changed, 1088 insertions(+), 119 deletions(-) create mode 100644 contracts/async-vault/src/test/governance.rs create mode 100644 contracts/async-vault/src/test/setup.rs diff --git a/contracts/async-vault/Cargo.toml b/contracts/async-vault/Cargo.toml index da34cabb..ecd0b862 100644 --- a/contracts/async-vault/Cargo.toml +++ b/contracts/async-vault/Cargo.toml @@ -30,3 +30,5 @@ proptest = "1" [features] upgrade_wasm = [] +# Drops the wind-down minimum to zero for the local harness. Never deployed. +short_wind_down = [] diff --git a/contracts/async-vault/src/deposit.rs b/contracts/async-vault/src/deposit.rs index d6421414..bc9da0a5 100644 --- a/contracts/async-vault/src/deposit.rs +++ b/contracts/async-vault/src/deposit.rs @@ -4,6 +4,7 @@ use stellar_tokens::rwa::RWATokenClient; use crate::epoch; use crate::error::VaultError; use crate::event::{DepositCancelled, DepositClaimed, DepositConvertedToExit, DepositRequested}; +use crate::governance; use crate::keys::DataKey; use crate::lock_policy::{self, Decision, Exit, Standing}; use crate::pricing::{Pricing, PricingScheme}; @@ -37,6 +38,7 @@ fn refuse_if_exceeds_cap(e: &Env, amount: i128) { pub(crate) fn request(e: &Env, from: &Address, amount: i128) -> u64 { wind_down::refuse_if_active(e); + governance::refuse_if_setup_open(e); from.require_auth(); if amount <= 0 { diff --git a/contracts/async-vault/src/error.rs b/contracts/async-vault/src/error.rs index bedafb71..cb2c1e23 100644 --- a/contracts/async-vault/src/error.rs +++ b/contracts/async-vault/src/error.rs @@ -115,9 +115,23 @@ pub enum VaultError { /// As many changes as the timelock holds already wait. Apply or cancel one /// first. TooManyPendingChanges = 6063, + /// Setup is still open, so the timelock protects nothing yet: the vault + /// takes no deposit and queues no change. Governance finishes setup first. + SetupOpen = 6064, + /// A direct call cannot make this change. After setup it goes through + /// `propose_change`; setting a role admin is never allowed. + UseTimelock = 6065, + /// Setup has finished, and it never reopens. + SetupClosed = 6066, + /// The token, the oracle, the manager role, the allowlist or the + /// compliance binding is not wired to this vault yet, so setup cannot + /// finish. + SetupIncomplete = 6067, /// The holder fails the share token's identity check, so the vault takes no /// deposit request from them and returns no escrowed shares to them. NotAllowlisted = 6068, + /// The wind-down delay is below the shortest the vault accepts. + WindDownDelayTooShort = 6069, /// A running total would go below zero, which means the vault's books /// disagree with themselves. Fails loudly instead of clamping to zero. AccountingMismatch = 6070, @@ -125,4 +139,8 @@ pub enum VaultError { /// returned or moved, until compliance lifts the freeze. A partial freeze /// refuses only what would touch frozen shares. Frozen = 6071, + /// 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 = 6074, } diff --git a/contracts/async-vault/src/event.rs b/contracts/async-vault/src/event.rs index 33c55f68..d825af74 100644 --- a/contracts/async-vault/src/event.rs +++ b/contracts/async-vault/src/event.rs @@ -116,6 +116,9 @@ pub struct ChangeApplied { pub change: VaultChange, } +#[contractevent] +pub struct SetupFinished {} + #[contractevent] pub struct WindDownDelaySet { pub secs: u64, diff --git a/contracts/async-vault/src/governance.rs b/contracts/async-vault/src/governance.rs index bf993c51..a4c5f196 100644 --- a/contracts/async-vault/src/governance.rs +++ b/contracts/async-vault/src/governance.rs @@ -1,16 +1,32 @@ -use soroban_sdk::{contracttype, panic_with_error, BytesN, Env, Vec}; +use bindings::VaultBindingClient; +use soroban_sdk::{contracttype, panic_with_error, Address, BytesN, Env, Symbol, Vec}; +use stellar_access::{ + access_control::{self, AccessControlClient, AccessControlStorageKey}, + role_transfer, +}; use stellar_contract_utils::{ pausable::{self, PausableError}, upgradeable, }; -use timelock::{queue, Clock, Refusal}; +use stellar_tokens::rwa::{ + identity_verification::IdentityVerifierClient, utils::token_binder::TokenBinderClient, + RWATokenClient, +}; +use timelock::{queue, setup, Clock, Refusal}; use crate::error::VaultError; -use crate::event::{ChangeApplied, ChangeCancelled, ChangeProposed}; +use crate::event::{ChangeApplied, ChangeCancelled, ChangeProposed, NoticeSet, SetupFinished}; +use crate::keys::DataKey; +use crate::roles::MANAGER_ROLE; use crate::state; +use crate::treasury; use crate::upgrade; use crate::wind_down; -use crate::AsyncVault; +use crate::{AsyncVault, MAX_NOTICE_SECS}; + +/// 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 timelock before it lands. #[contracttype] @@ -18,6 +34,12 @@ use crate::AsyncVault; pub enum VaultChange { Upgrade(BytesN<32>), UpgradeDelay(u64), + Custodian(Address), + Notice(u64), + WindDownDelay(u64), + GrantRole(Symbol, Address), + RevokeRole(Symbol, Address), + Admin(Address), } #[contracttype] @@ -28,6 +50,25 @@ pub struct VaultPendingChange { pub eta: u64, } +/// What `finish_setup` needs to see before deposits open. +pub(crate) struct Wiring { + pub token_bound: bool, + pub oracle_bound: bool, + pub sole_token_manager: bool, + pub allowlisted: bool, + pub compliance_bound: bool, +} + +impl Wiring { + fn complete(&self) -> bool { + self.token_bound + && self.oracle_bound + && self.sole_token_manager + && self.allowlisted + && self.compliance_bound + } +} + /// The clock this vault's queue runs on, and that it reports to the contracts /// bound to it. pub(crate) fn clock(e: &Env) -> Clock { @@ -40,7 +81,7 @@ pub(crate) fn clock(e: &Env) -> Clock { } } -pub(crate) fn refuse(e: &Env, refusal: Refusal) -> ! { +fn refuse(e: &Env, refusal: Refusal) -> ! { match refusal { Refusal::WindingDown => panic_with_error!(e, VaultError::WindDownActive), Refusal::Paused => panic_with_error!(e, PausableError::EnforcedPause), @@ -48,13 +89,98 @@ pub(crate) fn refuse(e: &Env, refusal: Refusal) -> ! { Refusal::BelowExitWindow => panic_with_error!(e, VaultError::UpgradeDelayBelowNotice), Refusal::NotFound => panic_with_error!(e, VaultError::ChangeNotFound), Refusal::TooMany => panic_with_error!(e, VaultError::TooManyPendingChanges), - // The constructor closes setup and binds nothing, so these mean a broken instance. - Refusal::SetupOpen | Refusal::SetupClosed | Refusal::Unbound | Refusal::AlreadyBound => { - panic_with_error!(e, VaultError::NotInitialized) - } + Refusal::SetupOpen => panic_with_error!(e, VaultError::SetupOpen), + Refusal::SetupClosed => panic_with_error!(e, VaultError::SetupClosed), + // The vault owns the clock and binds to nothing, so these two never arise. + Refusal::AlreadyBound => panic_with_error!(e, VaultError::SetupClosed), + Refusal::Unbound => panic_with_error!(e, VaultError::SetupIncomplete), + } +} + +/// 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, VaultError::UseTimelock); + } +} + +/// Nothing is taken in or queued until every admin change on the kit waits for +/// the timelock. +pub(crate) fn refuse_if_setup_open(e: &Env) { + if setup::is_open(e) { + panic_with_error!(e, VaultError::SetupOpen); } } +/// After setup, revoking `manager` waits like a grant; any other revoke stays +/// instant. +pub(crate) fn require_revoke_allowed(e: &Env, role: &Symbol) { + if *role == MANAGER_ROLE { + require_setup_open(e); + } +} + +/// True when `contract` reads this vault's clock and its own setup has +/// finished, so its admin changes wait for this timelock. +pub(crate) fn bound_here(e: &Env, contract: &Address) -> bool { + let binding = VaultBindingClient::new(e, contract); + matches!(binding.try_vault(), Ok(Ok(Some(vault))) if vault == e.current_contract_address()) + && matches!(binding.try_setup_open(), Ok(Ok(false))) +} + +pub(crate) fn wiring(e: &Env) -> Wiring { + let me = e.current_contract_address(); + let token = state::get_addr(e, &DataKey::ShareToken); + let oracle = state::get_addr(e, &DataKey::Oracle); + let roles = AccessControlClient::new(e, &token); + let rwa = RWATokenClient::new(e, &token); + Wiring { + token_bound: bound_here(e, &token), + oracle_bound: bound_here(e, &oracle), + sole_token_manager: roles.get_role_member_count(&MANAGER_ROLE) == 1 + && roles.get_role_member(&MANAGER_ROLE, &0) == me, + allowlisted: matches!( + IdentityVerifierClient::new(e, &rwa.identity_verifier()).try_verify_identity(&me), + Ok(Ok(())) + ), + compliance_bound: matches!( + TokenBinderClient::new(e, &rwa.compliance()).try_linked_tokens(), + Ok(Ok(linked)) if linked.contains(&token) + ), + } +} + +/// 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) { + panic_with_error!(e, VaultError::SetupClosed); + } + if role_transfer::has_active_pending_transfer(e, &AccessControlStorageKey::PendingAdmin) { + panic_with_error!(e, VaultError::AdminTransferPending); + } + if !wiring(e).complete() { + panic_with_error!(e, VaultError::SetupIncomplete); + } + setup::finish::(e).unwrap_or_else(|r| refuse(e, r)); + SetupFinished {}.publish(e); +} + +fn check_notice(e: &Env, secs: u64) { + if secs > MAX_NOTICE_SECS { + panic_with_error!(e, VaultError::NoticeTooLong); + } + if secs > upgrade::delay(e) { + panic_with_error!(e, VaultError::NoticeAboveUpgradeDelay); + } +} + +pub(crate) fn set_notice(e: &Env, secs: u64) { + check_notice(e, secs); + state::set_notice(e, secs); + NoticeSet { secs }.publish(e); +} + fn changes_code(change: &VaultChange) -> bool { matches!( change, @@ -69,11 +195,25 @@ fn code_change_pending(e: &Env) -> bool { }) } +/// The bounds a change must meet when it is proposed and again when it +/// applies. +fn check(e: &Env, change: &VaultChange) { + match change { + VaultChange::UpgradeDelay(secs) => upgrade::check_delay(e, *secs), + VaultChange::Notice(secs) => check_notice(e, *secs), + VaultChange::WindDownDelay(secs) => wind_down::check_delay(e, *secs), + VaultChange::Upgrade(_) + | VaultChange::Custodian(_) + | VaultChange::GrantRole(..) + | VaultChange::RevokeRole(..) + | VaultChange::Admin(_) => {} + } +} + pub(crate) fn propose(e: &Env, change: VaultChange) -> u32 { wind_down::refuse_if_active(e); - if let VaultChange::UpgradeDelay(secs) = change { - upgrade::check_delay(e, secs); - } + refuse_if_setup_open(e); + check(e, &change); if changes_code(&change) && code_change_pending(e) { panic_with_error!(e, VaultError::UpgradeProposalExists); } @@ -95,17 +235,35 @@ pub(crate) fn cancel(e: &Env, id: u32) { pub(crate) fn apply(e: &Env, id: u32) { let change: VaultChange = queue::take_ready::(e, id).unwrap_or_else(|r| refuse(e, r)); + // The notice or the delay may have moved since the proposal; this check decides. + check(e, &change); + let admin = access_control::get_admin(e) + .unwrap_or_else(|| panic_with_error!(e, VaultError::AdminRequired)); match &change { - VaultChange::UpgradeDelay(secs) => { - // The notice may have moved since the proposal; this check decides. - upgrade::check_delay(e, *secs); - upgrade::set_delay(e, *secs); - } VaultChange::Upgrade(hash) => upgradeable::upgrade(e, hash), + VaultChange::UpgradeDelay(secs) => upgrade::set_delay(e, *secs), + VaultChange::Custodian(custodian) => treasury::set_custodian(e, custodian), + VaultChange::Notice(secs) => set_notice(e, *secs), + VaultChange::WindDownDelay(secs) => wind_down::set_delay(e, *secs), + VaultChange::GrantRole(role, account) => { + access_control::grant_role_no_auth(e, account, role, &admin) + } + VaultChange::RevokeRole(role, account) => { + access_control::revoke_role_no_auth(e, account, role, &admin) + } + VaultChange::Admin(next) => offer_admin(e, &admin, next), } 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); +} + pub(crate) fn pending(e: &Env) -> Vec { let clock = clock(e); let mut out = Vec::new(e); diff --git a/contracts/async-vault/src/lib.rs b/contracts/async-vault/src/lib.rs index bbccd4aa..d8f29593 100644 --- a/contracts/async-vault/src/lib.rs +++ b/contracts/async-vault/src/lib.rs @@ -34,21 +34,26 @@ pub use event::{ ChangeApplied, ChangeCancelled, ChangeProposed, CustodianSet, Deployed, DepositCapUpdated, DepositClaimed, DepositConvertedToExit, DepositRequested, EpochClosed, EpochFulfilled, EpochReconciled, Funded, NoticeSet, RedeemClaimed, RedeemRequested, RedemptionSurrendered, - WindDownActivated, WindDownClaimed, WindDownDelaySet, WindDownProposalCancelled, + SetupFinished, WindDownActivated, WindDownClaimed, WindDownDelaySet, WindDownProposalCancelled, WindDownProposed, WindDownRoundFinalized, }; -pub use governance::{VaultChange, VaultPendingChange}; +pub use governance::{VaultChange, VaultPendingChange, ADMIN_ACCEPT_LEDGERS}; pub use pricing::{DirectUnitPricing, PricingScheme}; pub use roles::VaultRoles; pub use settlement::EpochSettlement; pub use state::{DepositRequest, EpochInfo, EpochStatus, RedeemRequest}; pub use upgrade::{MAX_UPGRADE_DELAY, MIN_UPGRADE_DELAY}; -pub use wind_down::{WindDownInfo, WindDownPosition, WindDownStatus, MAX_WIND_DOWN_DELAY}; +pub use wind_down::{ + WindDownInfo, WindDownPosition, WindDownStatus, MAX_WIND_DOWN_DELAY, MIN_WIND_DOWN_DELAY, +}; /// Thirty days. Longer than any notice a real structure uses, short enough that /// setting it by mistake is survivable. pub const MAX_NOTICE_SECS: u64 = 30 * DAY_DURATION; +#[cfg(feature = "short_wind_down")] +soroban_sdk::contractmeta!(key = "build", val = "short wind-down; local tests only"); + fn role_holder(e: &Env, role: &Symbol) -> Option
{ if access_control::get_role_member_count(e, role) == 0 { return None; @@ -94,7 +99,6 @@ impl AsyncVault { state::set_epoch(e, FIRST_EPOCH, &epoch::open(0)); state::set_current_epoch(e, FIRST_EPOCH); upgradeable::set_schema_version(e, 1); - timelock::setup::finish::(e).unwrap_or_else(|r| governance::refuse(e, r)); } pub fn asset(e: &Env) -> Address { @@ -193,15 +197,23 @@ impl AsyncVault { .publish(e); } + /// Instant during setup; afterwards the custodian changes through the + /// timelock. #[only_admin] pub fn set_custodian(e: &Env, custodian: Address, caller: Address) { caller.require_auth(); + governance::require_setup_open(e); treasury::set_custodian(e, &custodian); } + /// Raising is instant. Lowering shortens the warning holders get before a + /// wind-down starts, so after setup it waits for the timelock. #[only_admin] pub fn set_wind_down_delay(e: &Env, secs: u64, caller: Address) { caller.require_auth(); + if secs < wind_down::delay(e) { + governance::require_setup_open(e); + } wind_down::set_delay(e, secs); } @@ -260,17 +272,29 @@ impl AsyncVault { wind_down::claimable(e, &holder) } + /// Raising is instant. Lowering shortens the wait every closed epoch gives + /// before it prices, so after setup it waits for the timelock. #[only_admin] pub fn set_notice(e: &Env, secs: u64, caller: Address) { caller.require_auth(); - if secs > MAX_NOTICE_SECS { - panic_with_error!(e, VaultError::NoticeTooLong); + if secs < state::notice(e) { + governance::require_setup_open(e); } - if secs > upgrade::delay(e) { - panic_with_error!(e, VaultError::NoticeAboveUpgradeDelay); - } - state::set_notice(e, secs); - NoticeSet { secs }.publish(e); + governance::set_notice(e, secs); + } + + /// Ends the window in which admin changes are instant, once the token, + /// the oracle, the manager role, the allowlist and the compliance binding + /// are wired to this vault. Deposits open from here on, and it never + /// reopens. + #[only_admin] + pub fn finish_setup(e: &Env, caller: Address) { + caller.require_auth(); + governance::finish_setup(e); + } + + pub fn setup_open(e: &Env) -> bool { + timelock::setup::is_open(e) } /// Queues a change behind the timelock and returns its id. @@ -393,9 +417,39 @@ impl AsyncVault { #[contractimpl(contracttrait)] impl AccessControl for AsyncVault { + /// Instant during setup; after it, propose the grant through the timelock. + fn grant_role(e: &Env, account: Address, role: Symbol, caller: Address) { + governance::require_setup_open(e); + access_control::grant_role(e, &account, &role, &caller); + } + + /// Instant for every role but `manager`: taking `treasury` or `guardian` + /// away moves no value. After setup, revoking `manager` waits like a grant, + /// because without it no epoch closes and no exit is priced. + fn revoke_role(e: &Env, account: Address, role: Symbol, caller: Address) { + governance::require_revoke_allowed(e, &role); + access_control::revoke_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); + } + 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, VaultError::UseTimelock); + } + /// Refused. A vault without an admin could never be unpaused, never set a - /// custodian, never rotate a role and never be upgraded again. - /// `transfer_admin_role` and `accept_admin_transfer` are the way out. + /// custodian, never rotate a role and never be upgraded 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, VaultError::AdminRequired); } diff --git a/contracts/async-vault/src/test/cancel.rs b/contracts/async-vault/src/test/cancel.rs index 6b31797d..b2ae39fa 100644 --- a/contracts/async-vault/src/test/cancel.rs +++ b/contracts/async-vault/src/test/cancel.rs @@ -100,7 +100,6 @@ fn the_same_investor_can_request_again_after_cancelling() { fn refundable_escrow_is_not_free_reserve() { let f = setup(); let inv = f.investor(1_000); - f.vault.set_custodian(&f.custodian, &f.admin); let before = f.vault.free_reserve(); f.vault.request_deposit(&inv, &400); @@ -187,7 +186,6 @@ fn a_dust_refund_never_takes_committed_money() { f.vault.fulfill_epoch(&epoch); // Move every free unit out, leaving only what the redeemer is owed. - f.vault.set_custodian(&f.custodian, &f.admin); let free = f.vault.free_reserve(); if free > 0 { f.vault.deploy_to_custodian(&f.treasury, &free); diff --git a/contracts/async-vault/src/test/compliance.rs b/contracts/async-vault/src/test/compliance.rs index f908835f..5d7b3fbe 100644 --- a/contracts/async-vault/src/test/compliance.rs +++ b/contracts/async-vault/src/test/compliance.rs @@ -73,6 +73,7 @@ fn a_holder_delisted_after_pricing_is_paid_through_a_wind_down() { ); f.vault.propose_wind_down(&f.admin); + f.advance(MIN_WIND_DOWN_DELAY); f.vault.activate_wind_down(); assert_eq!(f.vault.claim_deposit(&investor, &epoch), 0); assert_eq!(f.vault.finalize_wind_down_round(), 400); diff --git a/contracts/async-vault/src/test/controls.rs b/contracts/async-vault/src/test/controls.rs index 12104a76..6799488f 100644 --- a/contracts/async-vault/src/test/controls.rs +++ b/contracts/async-vault/src/test/controls.rs @@ -108,10 +108,17 @@ fn governance_can_replace_the_guardian() { let f = setup(); let next = Address::generate(&f.e); - f.vault - .grant_role(&next, &symbol_short!("guardian"), &f.admin); - f.vault - .revoke_role(&f.guardian, &symbol_short!("guardian"), &f.admin); + let grant = f.vault.propose_change( + &VaultChange::GrantRole(symbol_short!("guardian"), next.clone()), + &f.admin, + ); + let revoke = f.vault.propose_change( + &VaultChange::RevokeRole(symbol_short!("guardian"), f.guardian.clone()), + &f.admin, + ); + f.advance(MIN_UPGRADE_DELAY); + f.vault.apply_change(&grant, &f.admin); + f.vault.apply_change(&revoke, &f.admin); assert_eq!(f.vault.guardian(), Some(next.clone())); assert!(f.vault.try_pause(&f.guardian).is_err()); @@ -235,7 +242,11 @@ fn governance_hands_over_in_two_steps_instead() { let f = setup(); let next = Address::generate(&f.e); - f.vault.transfer_admin_role(&next, &1_000); + let id = f + .vault + .propose_change(&VaultChange::Admin(next.clone()), &f.admin); + f.advance(MIN_UPGRADE_DELAY); + f.vault.apply_change(&id, &f.admin); f.vault.accept_admin_transfer(); assert_eq!(f.vault.governance(), Some(next.clone())); diff --git a/contracts/async-vault/src/test/delisted.rs b/contracts/async-vault/src/test/delisted.rs index 0814b8bc..bb7430a2 100644 --- a/contracts/async-vault/src/test/delisted.rs +++ b/contracts/async-vault/src/test/delisted.rs @@ -108,6 +108,7 @@ fn in_a_wind_down_the_shares_are_credited_before_the_first_round() { let f = setup(); let (investor, epoch) = delisted_with_a_priced_deposit(&f); f.vault.propose_wind_down(&f.admin); + f.advance(MIN_WIND_DOWN_DELAY); f.vault.activate_wind_down(); assert_eq!(f.vault.claim_deposit(&investor, &epoch), 0); @@ -140,6 +141,7 @@ fn in_a_wind_down_the_shares_are_credited_after_the_first_round() { let f = setup(); let (investor, epoch) = delisted_with_a_priced_deposit(&f); f.vault.propose_wind_down(&f.admin); + f.advance(MIN_WIND_DOWN_DELAY); f.vault.activate_wind_down(); assert_eq!(f.vault.finalize_wind_down_round(), 400); @@ -156,6 +158,7 @@ fn a_credit_before_the_first_round_leaves_every_holder_their_share() { let other = f.holder(200); let (investor, epoch) = delisted_with_a_priced_deposit(&f); f.vault.propose_wind_down(&f.admin); + f.advance(MIN_WIND_DOWN_DELAY); f.vault.activate_wind_down(); f.vault.claim_deposit(&investor, &epoch); @@ -193,6 +196,7 @@ fn a_conversion_caught_by_a_wind_down_is_paid_from_the_rounds() { f.vault.propose_wind_down(&f.admin); f.vault.claim_deposit(&investor, &epoch); let open = f.vault.current_epoch(); + f.advance(MIN_WIND_DOWN_DELAY); f.vault.activate_wind_down(); assert_eq!(f.vault.cancel_redeem(&investor, &open), 200); @@ -222,6 +226,7 @@ fn a_redemption_surrendered_after_the_first_round_keeps_the_rounds_solvent() { f.vault.propose_wind_down(&f.admin); f.vault.claim_deposit(&investor, &epoch); let open = f.vault.current_epoch(); + f.advance(MIN_WIND_DOWN_DELAY); f.vault.activate_wind_down(); assert_eq!(f.vault.finalize_wind_down_round(), 800); @@ -240,6 +245,7 @@ fn a_delisted_holders_unpriced_redemption_joins_the_wind_down_when_cancelled() { let epoch = f.vault.request_redeem(&investor, &200); f.identity.allow(&investor, &false, &f.compliance); f.vault.propose_wind_down(&f.admin); + f.advance(MIN_WIND_DOWN_DELAY); f.vault.activate_wind_down(); assert_eq!(f.vault.cancel_redeem(&investor, &epoch), 200); diff --git a/contracts/async-vault/src/test/deposit_cap.rs b/contracts/async-vault/src/test/deposit_cap.rs index a0332b54..28ac415e 100644 --- a/contracts/async-vault/src/test/deposit_cap.rs +++ b/contracts/async-vault/src/test/deposit_cap.rs @@ -122,7 +122,6 @@ fn deployed_capital_and_multi_epoch_accounting() { f.vault.fulfill_epoch(&1); // Deploy 800 to custodian - f.vault.set_custodian(&f.custodian, &f.admin); f.vault.deploy_to_custodian(&f.treasury, &800); assert_eq!(f.vault.net_deployed(), 800); diff --git a/contracts/async-vault/src/test/governance.rs b/contracts/async-vault/src/test/governance.rs new file mode 100644 index 00000000..d3713d75 --- /dev/null +++ b/contracts/async-vault/src/test/governance.rs @@ -0,0 +1,392 @@ +use soroban_sdk::{ + testutils::{Events as _, MockAuth, MockAuthInvoke}, + Event as _, IntoVal, +}; +use stellar_access::role_transfer::RoleTransferError; + +use super::*; +use crate::{ChangeApplied, CustodianSet, ADMIN_ACCEPT_LEDGERS}; + +/// Proposes a change, waits one delay and applies it. +fn land(f: &Fixture, change: VaultChange) { + let id = f.vault.propose_change(&change, &f.admin); + f.advance(f.vault.upgrade_delay()); + f.vault.apply_change(&id, &f.admin); +} + +#[test] +fn the_custodian_changes_through_the_timelock() { + let f = setup(); + let next = Address::generate(&f.e); + + refused( + f.vault.try_set_custodian(&next, &f.admin), + VaultError::UseTimelock, + ); + + let id = f + .vault + .propose_change(&VaultChange::Custodian(next.clone()), &f.admin); + f.advance(MIN_UPGRADE_DELAY - 1); + refused( + f.vault.try_apply_change(&id, &f.admin), + VaultError::ChangeNotReady, + ); + f.advance(1); + f.vault.apply_change(&id, &f.admin); + + assert_eq!(f.vault.custodian(), Some(next)); +} + +#[test] +fn applying_a_custodian_change_publishes_both_events() { + let f = setup(); + let next = Address::generate(&f.e); + let change = VaultChange::Custodian(next.clone()); + let id = f.vault.propose_change(&change, &f.admin); + f.advance(MIN_UPGRADE_DELAY); + + f.vault.apply_change(&id, &f.admin); + + assert_eq!( + f.e.events().all().filter_by_contract(&f.vault.address), + [ + CustodianSet { custodian: next }.to_xdr(&f.e, &f.vault.address), + ChangeApplied { id, change }.to_xdr(&f.e, &f.vault.address), + ] + ); +} + +#[test] +fn raising_the_notice_is_instant_and_lowering_waits() { + let f = setup(); + + f.vault.set_notice(&DAY_DURATION, &f.admin); + assert_eq!(f.vault.notice(), DAY_DURATION); + + refused( + f.vault.try_set_notice(&3_600, &f.admin), + VaultError::UseTimelock, + ); + land(&f, VaultChange::Notice(3_600)); + + assert_eq!(f.vault.notice(), 3_600); +} + +#[test] +fn a_notice_change_still_respects_its_bounds() { + let f = setup(); + + refused( + f.vault + .try_propose_change(&VaultChange::Notice(MIN_UPGRADE_DELAY + 1), &f.admin), + VaultError::NoticeAboveUpgradeDelay, + ); +} + +#[test] +fn a_notice_change_checks_the_upgrade_delay_again_when_it_applies() { + let f = setup(); + land(&f, VaultChange::UpgradeDelay(30 * DAY_DURATION)); + let notice = f + .vault + .propose_change(&VaultChange::Notice(20 * DAY_DURATION), &f.admin); + let delay = f + .vault + .propose_change(&VaultChange::UpgradeDelay(MIN_UPGRADE_DELAY), &f.admin); + f.advance(30 * DAY_DURATION); + + // The delay falls below the notice the first change would set. + f.vault.apply_change(&delay, &f.admin); + + refused( + f.vault.try_apply_change(¬ice, &f.admin), + VaultError::NoticeAboveUpgradeDelay, + ); + assert_eq!(f.vault.notice(), 0); +} + +#[test] +fn raising_the_wind_down_delay_is_instant_and_lowering_waits() { + let f = setup(); + + f.vault + .set_wind_down_delay(&(2 * MIN_WIND_DOWN_DELAY), &f.admin); + refused( + f.vault + .try_set_wind_down_delay(&MIN_WIND_DOWN_DELAY, &f.admin), + VaultError::UseTimelock, + ); + land(&f, VaultChange::WindDownDelay(MIN_WIND_DOWN_DELAY)); + + assert_eq!(f.vault.wind_down_delay(), MIN_WIND_DOWN_DELAY); +} + +#[test] +fn a_wind_down_delay_below_the_minimum_cannot_be_proposed() { + let f = setup(); + + refused( + f.vault.try_propose_change( + &VaultChange::WindDownDelay(MIN_WIND_DOWN_DELAY - 1), + &f.admin, + ), + VaultError::WindDownDelayTooShort, + ); +} + +#[test] +fn grants_and_manager_revokes_wait_for_the_timelock() { + let f = setup(); + let next = Address::generate(&f.e); + + refused( + f.vault + .try_grant_role(&next, &symbol_short!("treasury"), &f.admin), + VaultError::UseTimelock, + ); + refused( + f.vault + .try_revoke_role(&f.manager, &symbol_short!("manager"), &f.admin), + VaultError::UseTimelock, + ); + + land( + &f, + VaultChange::GrantRole(symbol_short!("treasury"), next.clone()), + ); + assert!(f + .vault + .has_role(&next, &symbol_short!("treasury")) + .is_some()); +} + +#[test] +fn a_manager_revoke_lands_through_the_timelock() { + let f = setup(); + + land( + &f, + VaultChange::RevokeRole(symbol_short!("manager"), f.manager.clone()), + ); + + assert_eq!(f.vault.manager(), None); + assert!(f.vault.try_close_epoch(&f.manager).is_err()); +} + +#[test] +fn revoking_treasury_or_guardian_stays_instant() { + let f = setup(); + + f.vault + .revoke_role(&f.treasury, &symbol_short!("treasury"), &f.admin); + f.vault + .revoke_role(&f.guardian, &symbol_short!("guardian"), &f.admin); + + assert_eq!(f.vault.treasury(), None); + assert_eq!(f.vault.guardian(), None); +} + +#[test] +fn only_the_admin_revokes_a_role() { + let f = setup(); + let stranger = Address::generate(&f.e); + + assert!(f + .vault + .try_revoke_role(&f.treasury, &symbol_short!("treasury"), &stranger) + .is_err()); + assert!(f + .vault + .try_revoke_role(&f.treasury, &symbol_short!("treasury"), &f.guardian) + .is_err()); + assert_eq!(f.vault.treasury(), Some(f.treasury.clone())); +} + +#[test] +fn no_role_ever_gets_a_role_admin() { + for f in [setup_unfinished(), setup()] { + refused( + f.vault + .try_set_role_admin(&symbol_short!("treasury"), &symbol_short!("guardian")), + VaultError::UseTimelock, + ); + } +} + +#[test] +fn an_admin_hand_over_waits_but_cancelling_one_does_not() { + let f = setup(); + let next = Address::generate(&f.e); + + refused( + f.vault + .try_transfer_admin_role(&next, &(f.e.ledger().sequence() + 100)), + VaultError::UseTimelock, + ); + + land(&f, VaultChange::Admin(next.clone())); + f.vault.transfer_admin_role(&next, &0); + + assert!(f.vault.try_accept_admin_transfer().is_err()); + assert_eq!(f.vault.governance(), Some(f.admin.clone())); +} + +#[test] +fn an_applied_admin_change_starts_a_hand_over_the_next_admin_accepts() { + let f = setup(); + let next = Address::generate(&f.e); + let id = f + .vault + .propose_change(&VaultChange::Admin(next.clone()), &f.admin); + f.advance(MIN_UPGRADE_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.vault.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.vault.apply_change(&id, &f.admin); + + f.e.mock_all_auths(); + assert_eq!(f.vault.governance(), Some(f.admin.clone())); + f.vault.accept_admin_transfer(); + assert_eq!(f.vault.governance(), Some(next)); +} + +#[test] +fn the_hand_over_offer_is_open_on_its_last_ledger() { + let f = setup(); + let next = Address::generate(&f.e); + land(&f, VaultChange::Admin(next.clone())); + let offered_at = f.e.ledger().sequence(); + + f.e.ledger() + .with_mut(|l| l.sequence_number = offered_at + ADMIN_ACCEPT_LEDGERS); + f.vault.accept_admin_transfer(); + + assert_eq!(f.vault.governance(), Some(next)); +} + +#[test] +fn the_hand_over_offer_lasts_seven_days_of_ledgers() { + let f = setup(); + land(&f, VaultChange::Admin(Address::generate(&f.e))); + let offered_at = f.e.ledger().sequence(); + + f.e.ledger() + .with_mut(|l| l.sequence_number = offered_at + ADMIN_ACCEPT_LEDGERS + 1); + + assert_eq!( + f.vault.try_accept_admin_transfer(), + Err(Ok(RoleTransferError::NoPendingTransfer.into())) + ); + assert_eq!(f.vault.governance(), Some(f.admin.clone())); +} + +#[test] +fn a_change_shorter_than_a_raised_notice_never_lands() { + let f = setup(); + let next = Address::generate(&f.e); + let custodian = f + .vault + .propose_change(&VaultChange::Custodian(next), &f.admin); + let delay = f + .vault + .propose_change(&VaultChange::UpgradeDelay(30 * DAY_DURATION), &f.admin); + f.advance(MIN_UPGRADE_DELAY); + f.vault.apply_change(&delay, &f.admin); + + // The notice rises past the seven days the custodian change was given. + f.vault.set_notice(&(20 * DAY_DURATION), &f.admin); + f.advance(30 * DAY_DURATION); + + refused( + f.vault.try_apply_change(&custodian, &f.admin), + VaultError::UpgradeDelayBelowNotice, + ); + assert_eq!(f.vault.custodian(), Some(f.custodian.clone())); +} + +#[test] +fn the_deposit_cap_stays_instant_both_ways() { + let f = setup(); + + f.vault.set_deposit_cap(&Some(1_000), &f.admin); + assert_eq!(f.vault.deposit_cap(), Some(1_000)); + f.vault.set_deposit_cap(&Some(500), &f.admin); + assert_eq!(f.vault.deposit_cap(), Some(500)); + f.vault.set_deposit_cap(&None, &f.admin); + assert_eq!(f.vault.deposit_cap(), None); +} + +#[test] +fn a_pending_role_change_lands_after_a_pause() { + let f = setup(); + let next = Address::generate(&f.e); + let id = f.vault.propose_change( + &VaultChange::GrantRole(symbol_short!("guardian"), next.clone()), + &f.admin, + ); + f.vault.pause(&f.guardian); + f.advance(MIN_UPGRADE_DELAY); + + assert!(f.vault.try_apply_change(&id, &f.admin).is_err()); + + f.vault.unpause(&f.admin); + f.advance(MIN_UPGRADE_DELAY); + f.vault.apply_change(&id, &f.admin); + assert!(f + .vault + .has_role(&next, &symbol_short!("guardian")) + .is_some()); +} + +#[test] +fn nothing_timelocked_moves_during_a_wind_down_a_manager_revoke_included() { + let f = setup(); + let revoke = f.vault.propose_change( + &VaultChange::RevokeRole(symbol_short!("manager"), f.manager.clone()), + &f.admin, + ); + f.vault.propose_wind_down(&f.admin); + f.advance(MIN_WIND_DOWN_DELAY); + f.vault.activate_wind_down(); + + refused( + f.vault.try_apply_change(&revoke, &f.admin), + VaultError::WindDownActive, + ); + refused( + f.vault.try_propose_change( + &VaultChange::RevokeRole(symbol_short!("manager"), f.manager.clone()), + &f.admin, + ), + VaultError::WindDownActive, + ); + refused( + f.vault + .try_revoke_role(&f.manager, &symbol_short!("manager"), &f.admin), + VaultError::UseTimelock, + ); + assert_eq!(f.vault.manager(), Some(f.manager.clone())); + + // Taking an operational role away moves no value, so it stays instant. + f.vault + .revoke_role(&f.treasury, &symbol_short!("treasury"), &f.admin); + assert_eq!(f.vault.treasury(), None); +} diff --git a/contracts/async-vault/src/test/mod.rs b/contracts/async-vault/src/test/mod.rs index 56604860..b69d48d8 100644 --- a/contracts/async-vault/src/test/mod.rs +++ b/contracts/async-vault/src/test/mod.rs @@ -8,6 +8,7 @@ mod deposit; mod deposit_cap; mod epochs; mod events; +mod governance; mod lock_policy; mod multi_epoch; mod notice; @@ -15,6 +16,7 @@ mod oracle_pricing; mod pricing; mod redeem; mod settlement; +mod setup; mod supply; mod timing; mod treasury; @@ -42,7 +44,7 @@ pub(crate) use timelock::DAY_DURATION; pub(crate) use crate::{ AsyncVault, AsyncVaultClient, DirectUnitPricing, EpochStatus, PricingScheme, VaultChange, VaultError, VaultPendingChange, VaultRoles, WindDownStatus, MAX_NOTICE_SECS, MAX_UPGRADE_DELAY, - MAX_WIND_DOWN_DELAY, MIN_UPGRADE_DELAY, + MAX_WIND_DOWN_DELAY, MIN_UPGRADE_DELAY, MIN_WIND_DOWN_DELAY, }; fn wad(whole: i128) -> i128 { @@ -145,7 +147,6 @@ impl Fixture<'_> { } fn empty_reserve(&self) { - self.vault.set_custodian(&self.custodian, &self.admin); let free = self.vault.free_reserve(); if free > 0 { self.vault.deploy_to_custodian(&self.treasury, &free); @@ -196,20 +197,64 @@ fn setup<'a>() -> Fixture<'a> { setup_with_feed(open_feed()) } +/// A vault whose wiring is complete but whose own setup is still open: admin +/// changes are instant and deposits are refused. +fn setup_unfinished<'a>() -> Fixture<'a> { + miswired(Miswire::Nothing) +} + +/// A wiring step a test leaves out or gets wrong, to see `finish_setup` refuse. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Miswire { + Nothing, + TokenUnbound, + TokenBoundElsewhere, + TokenSetupOpen, + OracleUnbound, + OracleBoundElsewhere, + OracleSetupOpen, + NoManagerRole, + SecondManager, + NotAllowlisted, + ComplianceUnbound, +} + +/// A vault left in setup, wired except for `miswire`. +fn miswired<'a>(miswire: Miswire) -> Fixture<'a> { + build( + open_feed(), + |e, args| e.register(ShareToken, args), + miswire, + false, + ) +} + /// Builds the vault against a caller-chosen feed, so cooldown, freshness and the /// deviation cap can be exercised from a vault test rather than only an oracle one. fn setup_with_feed<'a>(feed: OracleConfig) -> Fixture<'a> { setup_with(feed, |e, args| e.register(ShareToken, args)) } +/// Builds a vault set whose setup has finished, around a token the caller +/// registers, so a test can run the token's built Wasm instead of its native +/// code. +fn setup_with<'a>( + feed: OracleConfig, + register_token: impl FnOnce(&Env, TokenArgs) -> Address, +) -> Fixture<'a> { + build(feed, register_token, Miswire::Nothing, true) +} + /// The share token's constructor arguments, in order. type TokenArgs = (String, String, Address, Address, Address, Address); -/// Builds the vault around a token the caller registers, so a test can run the -/// token's built Wasm instead of its native code. -fn setup_with<'a>( +/// Wires the set the way the deploy does, apart from `miswire`, then sets the +/// custodian and finishes the vault's setup when `finish` is set. +fn build<'a>( feed: OracleConfig, register_token: impl FnOnce(&Env, TokenArgs) -> Address, + miswire: Miswire, + finish: bool, ) -> Fixture<'a> { let e = Env::default(); e.mock_all_auths(); @@ -245,7 +290,9 @@ fn setup_with<'a>( ), ), ); - compliance.bind_token(&share.address, &admin); + if miswire != Miswire::ComplianceUnbound { + compliance.bind_token(&share.address, &admin); + } let oracle = NavOracleContractClient::new( &e, @@ -271,14 +318,50 @@ fn setup_with<'a>( }, ), ); - share.grant_role(&contract_id, &symbol_short!("manager"), &admin); + if miswire != Miswire::NoManagerRole { + share.grant_role(&contract_id, &symbol_short!("manager"), &admin); + } + if miswire == Miswire::SecondManager { + share.grant_role(&Address::generate(&e), &symbol_short!("manager"), &admin); + } // The vault holds escrowed shares, so returning them on cancellation goes // through the token's checked transfer. That verifies both sides, so the // vault has to be a recognised holder itself. - identity.allow(&contract_id, &true, &compliance_authority); + if miswire != Miswire::NotAllowlisted { + identity.allow(&contract_id, &true, &compliance_authority); + } + match miswire { + Miswire::TokenUnbound => {} + Miswire::TokenBoundElsewhere => { + share.bind_vault(®ister_with(&e, distinct_roles(&e)), &admin); + share.finish_setup(&admin); + } + Miswire::TokenSetupOpen => share.bind_vault(&contract_id, &admin), + _ => { + share.bind_vault(&contract_id, &admin); + share.finish_setup(&admin); + } + } + match miswire { + Miswire::OracleUnbound => {} + Miswire::OracleBoundElsewhere => { + oracle.bind_vault(®ister_with(&e, distinct_roles(&e)), &admin); + oracle.finish_setup(&admin); + } + Miswire::OracleSetupOpen => oracle.bind_vault(&contract_id, &admin), + _ => { + oracle.bind_vault(&contract_id, &admin); + oracle.finish_setup(&admin); + } + } + let vault = AsyncVaultClient::new(&e, &contract_id); + if finish { + vault.set_custodian(&custodian, &admin); + vault.finish_setup(&admin); + } Fixture { - vault: AsyncVaultClient::new(&e, &contract_id), + vault, share, identity, oracle, diff --git a/contracts/async-vault/src/test/multi_epoch.rs b/contracts/async-vault/src/test/multi_epoch.rs index a82c70ff..aab0a04e 100644 --- a/contracts/async-vault/src/test/multi_epoch.rs +++ b/contracts/async-vault/src/test/multi_epoch.rs @@ -266,7 +266,6 @@ fn the_treasury_cannot_deploy_while_anything_is_uncovered() { let f = setup(); let a = f.holder(200); - f.vault.set_custodian(&f.custodian, &f.admin); f.vault.request_redeem(&a, &200); let epoch = f.close_epoch(); f.attest(wad(3)); diff --git a/contracts/async-vault/src/test/setup.rs b/contracts/async-vault/src/test/setup.rs new file mode 100644 index 00000000..11500de5 --- /dev/null +++ b/contracts/async-vault/src/test/setup.rs @@ -0,0 +1,163 @@ +use soroban_sdk::{testutils::Events as _, Event as _}; +use stellar_access::role_transfer::RoleTransferError; + +use super::*; +use crate::SetupFinished; + +#[test] +fn a_deposit_before_setup_finishes_is_refused() { + let f = setup_unfinished(); + let investor = f.investor(1_000); + + refused( + f.vault.try_request_deposit(&investor, &100), + VaultError::SetupOpen, + ); + assert!(f.vault.setup_open()); +} + +#[test] +fn setup_finishes_once_the_wiring_is_complete() { + let f = setup_unfinished(); + + f.vault.finish_setup(&f.admin); + + assert!(!f.vault.setup_open()); + let investor = f.investor(1_000); + f.vault.request_deposit(&investor, &100); +} + +#[test] +fn finishing_setup_publishes_it() { + let f = setup_unfinished(); + + f.vault.finish_setup(&f.admin); + + assert_eq!( + f.e.events().all().filter_by_contract(&f.vault.address), + [SetupFinished {}.to_xdr(&f.e, &f.vault.address)] + ); +} + +#[test] +fn setup_never_finishes_twice() { + let f = setup(); + + refused(f.vault.try_finish_setup(&f.admin), VaultError::SetupClosed); +} + +#[test] +fn every_wiring_step_is_checked_before_setup_finishes() { + for miswire in [ + Miswire::TokenUnbound, + Miswire::TokenBoundElsewhere, + Miswire::TokenSetupOpen, + Miswire::OracleUnbound, + Miswire::OracleBoundElsewhere, + Miswire::OracleSetupOpen, + Miswire::NoManagerRole, + Miswire::SecondManager, + Miswire::NotAllowlisted, + Miswire::ComplianceUnbound, + ] { + let f = miswired(miswire); + + assert_eq!( + f.vault.try_finish_setup(&f.admin), + Err(Ok(VaultError::SetupIncomplete.into())), + "{miswire:?}" + ); + assert!(f.vault.setup_open(), "{miswire:?}"); + } +} + +#[test] +fn setup_cannot_finish_while_a_hand_over_is_pending() { + let f = setup_unfinished(); + let next = Address::generate(&f.e); + f.vault + .transfer_admin_role(&next, &(f.e.ledger().sequence() + 100)); + + refused( + f.vault.try_finish_setup(&f.admin), + VaultError::AdminTransferPending, + ); + + f.vault.transfer_admin_role(&next, &0); + f.vault.finish_setup(&f.admin); + assert!(f.vault.try_accept_admin_transfer().is_err()); + assert_eq!(f.vault.governance(), Some(f.admin.clone())); +} + +#[test] +fn an_expired_hand_over_does_not_hold_setup_open() { + let f = setup_unfinished(); + f.vault + .transfer_admin_role(&Address::generate(&f.e), &(f.e.ledger().sequence() + 10)); + f.e.ledger().with_mut(|l| l.sequence_number += 11); + + f.vault.finish_setup(&f.admin); + + assert!(!f.vault.setup_open()); + assert_eq!( + f.vault.try_accept_admin_transfer(), + Err(Ok(RoleTransferError::NoPendingTransfer.into())) + ); + assert_eq!(f.vault.governance(), Some(f.admin.clone())); +} + +#[test] +fn only_governance_finishes_setup() { + let f = setup_unfinished(); + let stranger = Address::generate(&f.e); + + f.e.set_auths(&[]); + assert!(f.vault.try_finish_setup(&stranger).is_err()); + assert!(f.vault.try_finish_setup(&f.manager).is_err()); + assert!(f.vault.setup_open()); +} + +#[test] +fn admin_changes_are_instant_during_setup() { + let f = setup_unfinished(); + let next = Address::generate(&f.e); + + f.vault.set_custodian(&f.custodian, &f.admin); + f.vault.set_notice(&3_600, &f.admin); + f.vault.set_notice(&0, &f.admin); + f.vault + .set_wind_down_delay(&(2 * MIN_WIND_DOWN_DELAY), &f.admin); + f.vault.set_wind_down_delay(&MIN_WIND_DOWN_DELAY, &f.admin); + f.vault + .grant_role(&next, &symbol_short!("guardian"), &f.admin); + f.vault + .revoke_role(&f.manager, &symbol_short!("manager"), &f.admin); + + assert_eq!(f.vault.custodian(), Some(f.custodian.clone())); + assert_eq!(f.vault.notice(), 0); + assert_eq!(f.vault.wind_down_delay(), MIN_WIND_DOWN_DELAY); + assert!(f + .vault + .has_role(&next, &symbol_short!("guardian")) + .is_some()); + assert_eq!(f.vault.manager(), None); +} + +#[test] +fn nothing_is_proposed_during_setup() { + let f = setup_unfinished(); + + refused( + f.vault + .try_propose_change(&VaultChange::Custodian(f.custodian.clone()), &f.admin), + VaultError::SetupOpen, + ); + refused( + f.vault.try_propose_change( + &VaultChange::WindDownDelay(MIN_WIND_DOWN_DELAY - 1), + &f.admin, + ), + VaultError::SetupOpen, + ); + assert_eq!(f.vault.pending_changes().len(), 0); +} diff --git a/contracts/async-vault/src/test/treasury.rs b/contracts/async-vault/src/test/treasury.rs index a6574ea5..4fd928b8 100644 --- a/contracts/async-vault/src/test/treasury.rs +++ b/contracts/async-vault/src/test/treasury.rs @@ -5,7 +5,6 @@ fn capital_makes_the_round_trip_to_the_custodian() { let f = setup(); let investor = f.investor(1_000); let epoch = f.vault.request_deposit(&investor, &1_000); - f.vault.set_custodian(&f.custodian, &f.admin); // Escrow stays refundable until the epoch prices, so none of it is free. assert_eq!(f.vault.free_reserve(), 0); @@ -31,7 +30,6 @@ fn returning_more_than_was_deployed_shows_as_negative_net() { let f = setup(); let investor = f.investor(1_000); let epoch = f.vault.request_deposit(&investor, &1_000); - f.vault.set_custodian(&f.custodian, &f.admin); f.close_epoch(); f.attest(wad(2)); f.vault.fulfill_epoch(&epoch); @@ -48,7 +46,6 @@ fn assets_owed_to_a_priced_redemption_cannot_be_deployed() { let f = setup(); let holder = f.holder(500); let epoch = f.vault.request_redeem(&holder, &500); - f.vault.set_custodian(&f.custodian, &f.admin); assert_eq!(f.fulfill_epoch(wad(2)), epoch); @@ -64,7 +61,6 @@ fn an_unpriced_redemption_does_not_reserve_anything_yet() { let f = setup(); let holder = f.holder(500); f.vault.request_redeem(&holder, &500); - f.vault.set_custodian(&f.custodian, &f.admin); assert_eq!(f.vault.free_reserve(), 1_000); assert_eq!(f.vault.deploy_to_custodian(&f.treasury, &1_000), 1_000); @@ -95,7 +91,6 @@ fn deploying_needs_the_treasury_role() { let f = setup(); let investor = f.investor(1_000); f.vault.request_deposit(&investor, &1_000); - f.vault.set_custodian(&f.custodian, &f.admin); assert!(f.vault.try_deploy_to_custodian(&f.manager, &100).is_err()); assert!(f.vault.try_deploy_to_custodian(&f.admin, &100).is_err()); @@ -104,17 +99,20 @@ fn deploying_needs_the_treasury_role() { #[test] fn deploying_without_a_custodian_is_rejected() { - let f = setup(); - let investor = f.investor(1_000); - f.vault.request_deposit(&investor, &1_000); + let f = setup_unfinished(); + let backer = f.investor(1_000); + f.vault.fund(&backer, &1_000); assert_eq!(f.vault.custodian(), None); - assert!(f.vault.try_deploy_to_custodian(&f.treasury, &100).is_err()); + refused( + f.vault.try_deploy_to_custodian(&f.treasury, &100), + VaultError::CustodianNotSet, + ); } #[test] fn setting_the_custodian_needs_the_admin_signature() { - let f = setup(); + let f = setup_unfinished(); f.e.set_auths(&[]); assert!(f.vault.try_set_custodian(&f.custodian, &f.admin).is_err()); @@ -130,7 +128,6 @@ fn deploying_a_non_positive_amount_is_rejected() { let f = setup(); let investor = f.investor(1_000); f.vault.request_deposit(&investor, &1_000); - f.vault.set_custodian(&f.custodian, &f.admin); assert!(f.vault.try_deploy_to_custodian(&f.treasury, &0).is_err()); assert!(f.vault.try_deploy_to_custodian(&f.treasury, &-1).is_err()); diff --git a/contracts/async-vault/src/test/upgrade.rs b/contracts/async-vault/src/test/upgrade.rs index 40065197..5a106bd6 100644 --- a/contracts/async-vault/src/test/upgrade.rs +++ b/contracts/async-vault/src/test/upgrade.rs @@ -309,6 +309,7 @@ fn the_vault_exposes_no_immediate_upgrade() { fn nothing_is_proposed_during_a_wind_down() { let f = setup(); f.vault.propose_wind_down(&f.admin); + f.advance(MIN_WIND_DOWN_DELAY); f.vault.activate_wind_down(); refused( @@ -323,6 +324,7 @@ fn a_change_standing_when_the_wind_down_starts_never_applies() { let f = setup(); let id = propose(&f, VaultChange::Upgrade(wasm(&f, 7))); f.vault.propose_wind_down(&f.admin); + f.advance(MIN_WIND_DOWN_DELAY); f.vault.activate_wind_down(); f.advance(MAX_UPGRADE_DELAY); @@ -483,8 +485,6 @@ mod with_wasm { let depositor = f.investor(500); assert_eq!(f.vault.request_deposit(&depositor, &500), epoch); - f.share.bind_vault(&f.vault.address, &f.admin); - f.share.finish_setup(&f.admin); let probe_hash = f.e.deployer().upload_contract_wasm(token_probe::WASM); let id = f .share diff --git a/contracts/async-vault/src/test/wind_down.rs b/contracts/async-vault/src/test/wind_down.rs index 2a0c6cfd..6ea7137e 100644 --- a/contracts/async-vault/src/test/wind_down.rs +++ b/contracts/async-vault/src/test/wind_down.rs @@ -6,10 +6,10 @@ use super::*; fn governance_sets_the_delay_within_the_maximum() { let f = setup(); - assert_eq!(f.vault.wind_down_delay(), 0); + assert_eq!(f.vault.wind_down_delay(), MIN_WIND_DOWN_DELAY); - f.vault.set_wind_down_delay(&(7 * 24 * 60 * 60), &f.admin); - assert_eq!(f.vault.wind_down_delay(), 7 * 24 * 60 * 60); + f.vault.set_wind_down_delay(&(14 * DAY_DURATION), &f.admin); + assert_eq!(f.vault.wind_down_delay(), 14 * DAY_DURATION); } #[test] @@ -20,7 +20,7 @@ fn the_delay_cannot_exceed_the_maximum() { .vault .try_set_wind_down_delay(&(MAX_WIND_DOWN_DELAY + 1), &f.admin) .is_err()); - assert_eq!(f.vault.wind_down_delay(), 0); + assert_eq!(f.vault.wind_down_delay(), MIN_WIND_DOWN_DELAY); } #[test] @@ -33,7 +33,7 @@ fn only_governance_sets_the_delay() { assert!(f.vault.try_set_wind_down_delay(&60, &f.manager).is_err()); assert!(f.vault.try_set_wind_down_delay(&60, &f.treasury).is_err()); assert!(f.vault.try_set_wind_down_delay(&60, &f.guardian).is_err()); - assert_eq!(f.vault.wind_down_delay(), 0); + assert_eq!(f.vault.wind_down_delay(), MIN_WIND_DOWN_DELAY); } #[test] @@ -146,18 +146,36 @@ fn activating_without_a_proposal_is_refused() { } #[test] -fn a_zero_delay_activates_immediately() { +fn an_unset_delay_still_waits_the_minimum() { let f = setup(); f.vault.propose_wind_down(&f.admin); - f.vault.activate_wind_down(); + f.advance(MIN_WIND_DOWN_DELAY - 1); + refused( + f.vault.try_activate_wind_down(), + VaultError::WindDownDelayNotElapsed, + ); + f.advance(1); + f.vault.activate_wind_down(); assert_eq!(f.vault.wind_down().unwrap().status, WindDownStatus::Active); } +#[test] +fn a_delay_below_the_minimum_is_refused() { + let f = setup_unfinished(); + + refused( + f.vault + .try_set_wind_down_delay(&(MIN_WIND_DOWN_DELAY - 1), &f.admin), + VaultError::WindDownDelayTooShort, + ); +} + /// Proposes, waits and activates. Returns nothing; the vault is active after. fn wind_down_now(f: &Fixture) { f.vault.propose_wind_down(&f.admin); + f.advance(MIN_WIND_DOWN_DELAY); f.vault.activate_wind_down(); } @@ -188,7 +206,6 @@ fn an_active_wind_down_prices_nothing_and_closes_no_epoch() { #[test] fn an_active_wind_down_sends_nothing_to_the_custodian() { let f = setup(); - f.vault.set_custodian(&f.custodian, &f.admin); let investor = f.investor(1_000); f.vault.request_deposit(&investor, &400); f.fulfill_epoch(wad(2)); @@ -200,7 +217,6 @@ fn an_active_wind_down_sends_nothing_to_the_custodian() { #[test] fn funding_stays_open_during_a_wind_down() { let f = setup(); - f.vault.set_custodian(&f.custodian, &f.admin); let backer = f.investor(1_000); wind_down_now(&f); @@ -685,6 +701,7 @@ fn a_later_round_needs_the_units_an_earlier_round_left_in_owed() { let b = f.holder(1); f.empty_reserve(); f.vault.propose_wind_down(&f.admin); + f.advance(MIN_WIND_DOWN_DELAY); f.vault.activate_wind_down(); f.vault.fund(&f.custodian, &1); @@ -708,6 +725,7 @@ fn after_every_holder_claims_owed_keeps_at_most_one_unit_per_holder() { let holders = [f.holder(1), f.holder(2), f.holder(4)]; f.empty_reserve(); f.vault.propose_wind_down(&f.admin); + f.advance(MIN_WIND_DOWN_DELAY); f.vault.activate_wind_down(); f.vault.fund(&f.custodian, &5); f.vault.finalize_wind_down_round(); @@ -739,6 +757,7 @@ proptest! { let backer = f.investor(total_shares + 10_000); f.empty_reserve(); f.vault.propose_wind_down(&f.admin); + f.advance(MIN_WIND_DOWN_DELAY); f.vault.activate_wind_down(); // Funds exactly the snapshot, so the first round always credits diff --git a/contracts/async-vault/src/wind_down.rs b/contracts/async-vault/src/wind_down.rs index 1427cddf..651ab517 100644 --- a/contracts/async-vault/src/wind_down.rs +++ b/contracts/async-vault/src/wind_down.rs @@ -16,6 +16,14 @@ use crate::treasury; /// governance cannot set a delay the vault would never outlive. pub const MAX_WIND_DOWN_DELAY: u64 = 90 * timelock::DAY_DURATION; +/// Seven days: holders always get a week's warning before a wind-down starts. +/// It is also the delay when none was set. +#[cfg(not(feature = "short_wind_down"))] +pub const MIN_WIND_DOWN_DELAY: u64 = 7 * timelock::DAY_DURATION; +/// The local harness build activates a wind-down at once. +#[cfg(feature = "short_wind_down")] +pub const MIN_WIND_DOWN_DELAY: u64 = 0; + #[contracttype] #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum WindDownStatus { @@ -42,13 +50,21 @@ pub struct WindDownPosition { } pub(crate) fn delay(e: &Env) -> u64 { - storage::get_instance(e, &DataKey::WindDownDelay).unwrap_or(0) + storage::get_instance(e, &DataKey::WindDownDelay).unwrap_or(MIN_WIND_DOWN_DELAY) } -pub(crate) fn set_delay(e: &Env, secs: u64) { +#[cfg_attr(feature = "short_wind_down", allow(clippy::absurd_extreme_comparisons))] +pub(crate) fn check_delay(e: &Env, secs: u64) { + if secs < MIN_WIND_DOWN_DELAY { + panic_with_error!(e, VaultError::WindDownDelayTooShort); + } if secs > MAX_WIND_DOWN_DELAY { panic_with_error!(e, VaultError::WindDownDelayTooLong); } +} + +pub(crate) fn set_delay(e: &Env, secs: u64) { + check_delay(e, secs); storage::set_instance(e, &DataKey::WindDownDelay, &secs); WindDownDelaySet { secs }.publish(e); } diff --git a/environments.toml b/environments.toml index 514b39a6..41acc24a 100644 --- a/environments.toml +++ b/environments.toml @@ -51,8 +51,14 @@ constructor_args = '--asset share_token --share_token share_token --oracle nav_o # The vault needs the share token's manager role and needs allowlisting on the # identity verifier, and both target other contracts while requiring the vault's # own address, which only exists after it deploys last. The deploy in -# scripts/harness performs and verifies both. A deployment made only from -# this file can take deposits but can neither deliver shares nor return them. +# scripts/harness performs and verifies both. +# +# Setup cannot finish from this file either. The vault's `finish_setup` checks +# the token and oracle bindings, the manager role, the allowlist and the +# compliance binding, which only the harness deploy wires. A deploy made only +# from this file leaves the token's, the oracle's and the vault's setup open, so +# its vault refuses deposits with `SetupOpen`. The harness deploy closes all +# three. ### Staging environment configuration diff --git a/scripts/harness/deploy.ts b/scripts/harness/deploy.ts index 5d6bc496..a948bc4b 100644 --- a/scripts/harness/deploy.ts +++ b/scripts/harness/deploy.ts @@ -5,6 +5,7 @@ import { pathToFileURL } from "node:url" import { Asset, rpc, type Keypair } from "@stellar/stellar-sdk" import { basicNodeSigner } from "@stellar/stellar-sdk/contract" +import type * as AsyncVaultModule from "../../app-lib/clients/async_vault/dist/index.js" 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" @@ -212,6 +213,33 @@ async function grantAndVerify( console.log("nav_oracle bound to the vault, setup finished") } +// The custodian is set while setup is open; afterwards it changes only through +// the timelock. finish_setup refuses unless every wiring step above landed. +async function finishVaultSetup( + net: Network, + accounts: Accounts, + contracts: Record, +): Promise { + const governance = accounts.governance + const caller = governance.publicKey() + const { Client: AsyncVault } = (await import( + clientEntry("async_vault") + )) as typeof AsyncVaultModule + const vault = new AsyncVault( + clientOptions(net, contracts.async_vault, governance), + ) + await ( + await vault.set_custodian({ + custodian: accounts.custodian.publicKey(), + caller, + }) + ).signAndSend() + await (await vault.finish_setup({ caller })).signAndSend() + if ((await vault.setup_open()).result) + throw new Error("the vault's setup did not finish") + console.log("vault setup finished") +} + async function main(): Promise { requireWasm() const net = network() @@ -234,6 +262,7 @@ async function main(): Promise { console.log("clients generated") await grantAndVerify(net, accounts, contracts) + await finishVaultSetup(net, accounts, contracts) const record: Deployment = { network: net.name, diff --git a/scripts/harness/guards.test.ts b/scripts/harness/guards.test.ts index 3f51e76a..36dbeef3 100644 --- a/scripts/harness/guards.test.ts +++ b/scripts/harness/guards.test.ts @@ -1,5 +1,5 @@ import { setTimeout as sleep } from "node:timers/promises" -import { afterAll, beforeAll, describe, expect, it } from "vitest" +import { beforeAll, describe, expect, it } from "vitest" import { pay, trustline } from "./lib/accounts.js" import { assetsFor, price, sharesFor, units } from "./lib/amounts.js" @@ -65,12 +65,6 @@ beforeAll(async () => { }), ) } - await send( - vault(c, governance).set_custodian({ - custodian: custodian.publicKey(), - caller: governance.publicKey(), - }), - ) }) describe("an account with no role", () => { @@ -90,9 +84,14 @@ describe("an account with no role", () => { ) }) - it("cannot set the custodian", async () => { + it("cannot change the custodian", async () => { await expect( - send(vault(c, outsider).set_custodian({ custodian: who, caller: who })), + send( + vault(c, outsider).propose_change({ + change: { tag: "Custodian", values: [who] }, + caller: who, + }), + ), ).rejects.toThrow(/signatures/) }) @@ -302,6 +301,40 @@ describe("the oracle after setup", () => { }) }) +describe("the vault after setup", () => { + const caller = () => governance.publicKey() + + it("governance cannot change the custodian at once", async () => { + const v = vault(c, governance) + expectRefused( + await v.set_custodian({ + custodian: outsider.publicKey(), + caller: caller(), + }), + v, + "UseTimelock", + ) + expect((await reader.custodian()).result).toBe(custodian.publicKey()) + }) + + it("queues a custodian change behind its delay", async () => { + const v = vault(c, governance) + const id = await send( + v.propose_change({ + change: { tag: "Custodian", values: [outsider.publicKey()] }, + caller: caller(), + }), + ) + expectRefused( + await v.apply_change({ id, caller: caller() }), + v, + "ChangeNotReady", + ) + await send(v.cancel_change({ id, caller: caller() })) + expect((await v.pending_changes()).result).toEqual([]) + }) +}) + describe("invalid amounts and duplicate requests in an epoch", () => { it("rejects zero deposit amount", async () => { const v = vault(c, first) @@ -690,15 +723,6 @@ describe("the notice period and valuation age rules", () => { ) }) - afterAll(async () => { - await send( - vault(c, governance).set_notice({ - secs: 0n, - caller: governance.publicKey(), - }), - ) - }) - it("records priceable_at at close and holds it through the notice", async () => { await send( vault(c, first).request_deposit({ @@ -766,4 +790,14 @@ describe("the notice period and valuation age rules", () => { } expect(await fulfill(epochB)).toBe(secondPrice) }) + + it("governance cannot lower the notice at once", async () => { + const v = vault(c, governance) + expectRefused( + await v.set_notice({ secs: 0n, caller: governance.publicKey() }), + v, + "UseTimelock", + ) + expect((await reader.notice()).result).toBe(noticeSecs) + }) }) diff --git a/scripts/harness/happy-path.test.ts b/scripts/harness/happy-path.test.ts index 43fe0ec0..415ae3d2 100644 --- a/scripts/harness/happy-path.test.ts +++ b/scripts/harness/happy-path.test.ts @@ -22,15 +22,7 @@ const holders = [ c.keys["holder-3"], ] as const const [first, second, canceller] = holders -const { - governance, - compliance, - custodian, - outsider, - issuer, - manager, - treasury, -} = c.keys +const { compliance, custodian, outsider, issuer, manager, treasury } = c.keys let decimals = 7 const usdc = (whole: number) => units(whole, decimals) @@ -313,14 +305,9 @@ describe("treasury", () => { const deployed = () => usdc(150) let owed = 0n - it("governance sets the custodian", async () => { - await send( - vault(c, governance).set_custodian({ - custodian: custodian.publicKey(), - caller: governance.publicKey(), - }), - ) + it("the deploy set the custodian before setup closed", async () => { expect((await reader.custodian()).result).toBe(custodian.publicKey()) + expect((await reader.setup_open()).result).toBe(false) }) it("the treasury deploys part of the reserve and the free reserve falls by exactly that amount", async () => { diff --git a/scripts/harness/seed.ts b/scripts/harness/seed.ts index a6a546d7..4bb16227 100644 --- a/scripts/harness/seed.ts +++ b/scripts/harness/seed.ts @@ -121,7 +121,6 @@ async function main(): Promise { const c: Connected = connect() const { - governance, manager, treasury, custodian, @@ -162,20 +161,13 @@ async function main(): Promise { await ensureAllowlist(c, key, name) } - // Ensure vault custodian is configured const currentCustodian = (await reader.custodian()).result if (!currentCustodian) { - console.log(" + setting vault custodian...") - await send( - vault(c, governance).set_custodian({ - custodian: custodian.publicKey(), - caller: governance.publicKey(), - }), + throw new Error( + "the vault has no custodian; the deploy sets it before setup closes", ) - console.log(` ✓ custodian configured: ${custodian.publicKey()}`) - } else { - console.log(` • vault custodian already configured: ${currentCustodian}`) } + console.log(` • vault custodian configured: ${currentCustodian}`) // 2. Lifecycle Seeding console.log("\nStep 2: Processing Vault Lifecycle...") From f095d656b27b236470b6b9183a6cd04261392ebd Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:20:49 -0300 Subject: [PATCH 045/147] docs: put the vault's admin behind the timelock --- README.md | 13 ++++---- docs/AUTHORIZATION_MATRIX.md | 28 +++++++++++------ docs/TESTING.md | 14 ++++++++- docs/kit-spec.md | 42 ++++++++++++++++--------- docs/strata-product-and-architecture.md | 11 ++++--- 5 files changed, 72 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 1353f7bc..e648d72e 100644 --- a/README.md +++ b/README.md @@ -112,18 +112,19 @@ 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 CDPQAISI45SS2INROW6YGBUJUZ23IO2IQ4OW4YN3TFCX6RDR4I4DGC45 -compliance CASCZWH44CPGC26FIEBDZKXHWLXEPF2SLCHBTIOMAH33TWA76P3V2QAW -identity_verifier CCGG7IHN4D5P43DA32N2SVQM62ZSPBYY45AUY7AIO6U4TVKWP7X7C4CW -share_token CC3OIIFWYZPRGYWX4GS2NTHLOD3OHMIZY6SAWD3W4LKZNU4ZMLZNSOP2 -nav_oracle CD4MEW2NM4WDXBGA4V53RCQTMXXKXIWXLBAXX6U4XYER343T7AEU52WP -async_vault CBUOZYNWDS3BJ7W4JCZOHPKMWAOQLKPEOB7IY6GTPHJGQU7Q6ZHS2VPU +asset contract CCAE3ZUI4B2LSOF7HBS4F35SOHYDG7FOLSQ4DPKAS7YU3JB7IN2U2WOR +compliance CAFIN446IPMTL7E2RMHSSDFS5BRYKU6BJS7L7DBIDHLUDGPWEHVOSE2D +identity_verifier CCNJIIWUXN76GHMGNJW4WIDWYOLBGL6JPUT2J5FU2F6T74VUYNT6M7PW +share_token CCXMRDX67EUA563K3Z7BYJU5HM2PACTYQRHCGI7AXKZZDKEVZNPBYMDT +nav_oracle CDNW5MROHAB2ULG3ODWEVSMSVBTOAHDATES74HYAEZIPQCJB4HUB7KXD +async_vault CC2GSF7NZYFSUXX6UP7FFSIDXCKJV7SLN2HS7SD4XO42WHPXHQW3SJQM 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 +vault setup finished wrote deployed.local.json ``` diff --git a/docs/AUTHORIZATION_MATRIX.md b/docs/AUTHORIZATION_MATRIX.md index 937ba7bd..fa70b0ff 100644 --- a/docs/AUTHORIZATION_MATRIX.md +++ b/docs/AUTHORIZATION_MATRIX.md @@ -35,9 +35,10 @@ In `scripts/harness/deploy.ts` and automated deployment scripts, the separation - The vault's constructor receives `roles.manager = accounts.manager.publicKey()`. - The share token's constructor grants `compliance` to the compliance key and grants `manager` to no one; the identity verifier is constructed with the compliance key as admin. - 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. +- After granting, the script checks `token.has_role({ account: vault, role: "manager" })` and stops if the vault does not hold the role. That no other account holds it is checked by the vault's `finish_setup` below. - 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). +- 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). +- Last, it calls `vault.set_custodian({ custodian, caller: governance })` and `vault.finish_setup({ caller: governance })`, and stops if `vault.setup_open()` still reads true. `finish_setup` refuses with `SetupIncomplete` unless the token and the oracle report this vault with their setup closed, the vault is the token's only `manager`, the vault passes the identity check and the token is bound to its compliance contract. From then on the vault takes deposits and its admin changes wait for its timelock (§5.1). --- @@ -62,7 +63,7 @@ The following entrypoints are open to any caller on purpose: | **`AsyncVault`** | `activate_wind_down` | Open (Any caller) | Once governance announces wind-down and the timelock expires, anyone (including investors) can trigger activation. This ensures an operator cannot propose wind-down to block deposits and then stall activation indefinitely. | | **`AsyncVault`** | `finalize_wind_down_round` | Open (Any caller) | Distributing returned capital pro-rata across the supply snapshot is deterministic. Making finalisation open ensures investors or keepers can trigger payout rounds immediately when funds arrive. | | **`AsyncVault`** | `claim_wind_down` | `holder.require_auth()` | The holder signs their own claim; a keeper cannot claim for them. The payout goes to `holder`. | -| **`AsyncVault`** | `request_deposit` | `from.require_auth()` | Investor entrypoint. Deposits escrowed cash; guarded by `when_not_paused` and not winding down. | +| **`AsyncVault`** | `request_deposit` | `from.require_auth()` | Investor entrypoint. Deposits escrowed cash; guarded by `when_not_paused`, not winding down, and refused with `SetupOpen` until the vault's setup finishes, so every deposit is covered by the timelock. | | **`AsyncVault`** | `claim_deposit` | `caller.require_auth()` | Investor entrypoint. Claims shares once epoch fulfilled; gated by SEP-57 receiver check on `mint`. | | **`AsyncVault`** | `cancel_deposit` | `from.require_auth()` | Investor exit before epoch pricing. Single-step refund. | | **`AsyncVault`** | `request_redeem` | `from.require_auth()` | Investor exit. Always open, even during pause. | @@ -75,7 +76,9 @@ The following entrypoints are open to any caller on purpose: ### 5.1 AsyncVault (`contracts/async-vault`) -Tests: [`controls.rs`](../contracts/async-vault/src/test/controls.rs), [`epochs.rs`](../contracts/async-vault/src/test/epochs.rs), [`notice.rs`](../contracts/async-vault/src/test/notice.rs), [`treasury.rs`](../contracts/async-vault/src/test/treasury.rs), [`upgrade.rs`](../contracts/async-vault/src/test/upgrade.rs) and [`wind_down.rs`](../contracts/async-vault/src/test/wind_down.rs) in [`contracts/async-vault/src/test/`](../contracts/async-vault/src/test/). +The vault's `admin` is governance. Until `finish_setup`, admin changes are instant and the vault takes no deposit (`SetupOpen`). Setup finishes once the wiring is complete and no admin hand-over offered during setup is pending, and it never reopens. After setup, the following wait for the timelock through `propose_change`/`apply_change`: the custodian, grants, revoking `manager`, admin hand-over, lowering the notice or the wind-down delay, and upgrades. Raising the notice or the wind-down delay, the deposit cap, `pause`/`unpause` and revoking `treasury` or `guardian` stay instant. During an active wind-down nothing timelocked is proposed or applied, a `manager` revoke included. + +Tests: [`controls.rs`](../contracts/async-vault/src/test/controls.rs), [`epochs.rs`](../contracts/async-vault/src/test/epochs.rs), [`governance.rs`](../contracts/async-vault/src/test/governance.rs), [`notice.rs`](../contracts/async-vault/src/test/notice.rs), [`setup.rs`](../contracts/async-vault/src/test/setup.rs), [`treasury.rs`](../contracts/async-vault/src/test/treasury.rs), [`upgrade.rs`](../contracts/async-vault/src/test/upgrade.rs) and [`wind_down.rs`](../contracts/async-vault/src/test/wind_down.rs) in [`contracts/async-vault/src/test/`](../contracts/async-vault/src/test/). | Entrypoint | Access Control / Caller Auth | State & Precondition Guards | Tested Refusal (Test Name) | |---|---|---|---| @@ -83,16 +86,21 @@ Tests: [`controls.rs`](../contracts/async-vault/src/test/controls.rs), [`epochs. | `deploy_to_custodian` | `#[only_role(caller, "treasury")]` | `assets <= free_reserve`, not winding down | `test::treasury::deploying_needs_the_treasury_role` | | `pause` | `#[only_role(caller, "guardian")]` | Contract not already paused | `test::controls::only_the_guardian_pauses` | | `unpause` | `#[only_admin]`, `caller.require_auth()` | Contract must be paused | `test::controls::the_guardian_pauses_but_only_governance_unpauses` | -| `set_custodian` | `#[only_admin]`, `caller.require_auth()` | None | `test::treasury::setting_the_custodian_needs_the_admin_signature` | -| `set_notice` | `#[only_admin]`, `caller.require_auth()` | `secs <= MAX_NOTICE_SECS`, `secs <= upgrade_delay` | `test::notice::setting_the_notice_needs_governance_authorisation` | -| `set_wind_down_delay` | `#[only_admin]`, `caller.require_auth()` | `secs <= MAX_WIND_DOWN_DELAY`, not active | `test::wind_down::only_governance_sets_the_delay` | +| `set_custodian` | `#[only_admin]`, `caller.require_auth()` | Instant during setup; `UseTimelock` after | `test::governance::the_custodian_changes_through_the_timelock`, `test::treasury::setting_the_custodian_needs_the_admin_signature` | +| `set_notice` | `#[only_admin]`, `caller.require_auth()` | `secs <= MAX_NOTICE_SECS`, `secs <= upgrade_delay`. Raising is instant; lowering is `UseTimelock` after setup | `test::governance::raising_the_notice_is_instant_and_lowering_waits`, `test::notice::setting_the_notice_needs_governance_authorisation` | +| `set_wind_down_delay` | `#[only_admin]`, `caller.require_auth()` | `MIN_WIND_DOWN_DELAY <= secs <= MAX_WIND_DOWN_DELAY`. Raising is instant; lowering is `UseTimelock` after setup | `test::governance::raising_the_wind_down_delay_is_instant_and_lowering_waits`, `test::governance::a_wind_down_delay_below_the_minimum_cannot_be_proposed`, `test::wind_down::only_governance_sets_the_delay` | +| `set_deposit_cap` | `#[only_admin]`, `caller.require_auth()` | Not negative. Instant both ways, also after setup | `test::governance::the_deposit_cap_stays_instant_both_ways` | +| `finish_setup` | `#[only_admin]`, `caller.require_auth()` | Once. The token and the oracle report this vault with their setup closed, the vault is the token's only `manager`, passes `verify_identity`, and the token is in its compliance contract's `linked_tokens`; no admin hand-over offered during setup is pending | `test::setup::every_wiring_step_is_checked_before_setup_finishes`, `test::setup::setup_cannot_finish_while_a_hand_over_is_pending`, `test::setup::setup_never_finishes_twice`, `test::setup::only_governance_finishes_setup` | +| `grant_role` / `transfer_admin_role` | Admin | Instant during setup, `UseTimelock` after. A zero `live_until_ledger` cancels instantly | `test::governance::grants_and_manager_revokes_wait_for_the_timelock`, `test::governance::an_admin_hand_over_waits_but_cancelling_one_does_not` | +| `revoke_role` | Admin | `treasury`, `guardian`: instant. `manager`: instant during setup, `UseTimelock` after | `test::governance::grants_and_manager_revokes_wait_for_the_timelock` (instant case: `test::governance::revoking_treasury_or_guardian_stays_instant`; caller check: `test::governance::only_the_admin_revokes_a_role`) | +| `set_role_admin` | — | Always refused (`UseTimelock`) | `test::governance::no_role_ever_gets_a_role_admin` | | `propose_wind_down` | `#[only_admin]`, `caller.require_auth()` | No proposal standing, not active | `test::wind_down::only_governance_proposes` | | `cancel_wind_down_proposal` | `#[only_admin]`, `caller.require_auth()` | Must be proposed, not active | `test::wind_down::governance_cancels_a_proposal_before_activation` | -| `propose_change` | `#[only_admin]`, `caller.require_auth()` | Not winding down, one code change at a time, an `UpgradeDelay` in `[MIN, MAX]` and `>= notice` | `test::upgrade::only_governance_proposes_and_cancels`, `test::upgrade::nothing_is_proposed_during_a_wind_down` | +| `propose_change` | `#[only_admin]`, `caller.require_auth()` | Not winding down, setup finished, one code change at a time, an `UpgradeDelay` in `[MIN, MAX]` and `>= notice`, a `Notice` within its bounds, a `WindDownDelay` in `[MIN_WIND_DOWN_DELAY, MAX_WIND_DOWN_DELAY]` | `test::upgrade::only_governance_proposes_and_cancels`, `test::upgrade::nothing_is_proposed_during_a_wind_down`, `test::setup::nothing_is_proposed_during_setup` | | `cancel_change` | `#[only_admin]`, `caller.require_auth()` | The change must be pending | `test::upgrade::only_governance_proposes_and_cancels` | -| `apply_change` | `#[only_admin]`, `caller.require_auth()` | Eta passed, not paused, not winding down, delay `>= notice` | `test::upgrade::only_governance_applies` | +| `apply_change` | `#[only_admin]`, `caller.require_auth()` | Eta passed, not paused, not winding down, delay `>= notice`; an `UpgradeDelay`, `Notice` or `WindDownDelay` change is checked against its bounds again | `test::upgrade::only_governance_applies`, `test::governance::a_change_shorter_than_a_raised_notice_never_lands`, `test::governance::nothing_timelocked_moves_during_a_wind_down_a_manager_revoke_included` | | `pending_changes` | Open view | None | None (view) | -| `renounce_admin` | Refused always (`VaultError::AdminRequired`) | None | `test::controls::governance_cannot_renounce_itself_out_of_the_vault` | +| `renounce_admin` | Refused always (`VaultError::AdminRequired`) | None | `test::controls::governance_cannot_renounce_itself_out_of_the_vault`; `test::controls::governance_hands_over_in_two_steps_instead`; `test::governance::an_applied_admin_change_starts_a_hand_over_the_next_admin_accepts`. Hand over by proposing and applying an `Admin` change (`transfer_admin_role` during setup), then the nominee calls `accept_admin_transfer`. | ### 5.2 ShareToken (`contracts/share-token`) diff --git a/docs/TESTING.md b/docs/TESTING.md index 4cc71dc0..81191b58 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -27,6 +27,12 @@ CARGO_TARGET_DIR=target/upgrade-probe stellar contract build --package nav-oracl SOROBAN_SDK_BUILD_SYSTEM_SUPPORTS_SPEC_SHAKING_V2=1 cargo test -p async-vault -p nav-oracle --features upgrade_wasm with_wasm ``` +The `short_wind_down` feature builds a vault whose wind-down minimum is zero, +for a local harness only; its WASM carries the meta entry `build = short +wind-down; local tests only`. Check that it compiles with +`cargo check -p async-vault --features short_wind_down`. Do not run the test +suite under it: tests that subtract from the minimum assume seven days. + ## Storage and TTL The contracts read and write through the shared helpers in @@ -75,6 +81,7 @@ nothing. | 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` | +| Vault: only the admin finishes setup; after it, the custodian, grants, `manager` revokes, hand-overs and a lower notice or wind-down delay go through the timelock, while revoking the treasury or the guardian stays instant | [`contracts/async-vault/src/test/setup.rs`](../contracts/async-vault/src/test/setup.rs), [`contracts/async-vault/src/test/governance.rs`](../contracts/async-vault/src/test/governance.rs) | `only_governance_finishes_setup`, `the_custodian_changes_through_the_timelock`, `grants_and_manager_revokes_wait_for_the_timelock`, `revoking_treasury_or_guardian_stays_instant`, `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 @@ -91,6 +98,7 @@ so time rules and TTL can be reached in one test. | 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` | +| The vault's own changes wait on its clock: a raised notice blocks a shorter change, a pause holds one, a wind-down refuses them, and a wind-down waits at least seven days | [`contracts/async-vault/src/test/governance.rs`](../contracts/async-vault/src/test/governance.rs), [`contracts/async-vault/src/test/wind_down.rs`](../contracts/async-vault/src/test/wind_down.rs) | `a_change_shorter_than_a_raised_notice_never_lands`, `a_pending_role_change_lands_after_a_pause`, `nothing_timelocked_moves_during_a_wind_down_a_manager_revoke_included`, `an_unset_delay_still_waits_the_minimum` | | 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 @@ -101,11 +109,14 @@ so time rules and TTL can be reached in one test. | A refused call publishes nothing | [`contracts/async-vault/src/test/events.rs`](../contracts/async-vault/src/test/events.rs) | `a_refused_call_publishes_nothing` | | An epoch's rounding remainder is cleared once its last request settles, a settled epoch leaves nothing in the totals whatever the claim order (property test), and a request that can never settle (a refused redemption or a refused mint) keeps its epoch open | [`contracts/async-vault/src/test/settlement.rs`](../contracts/async-vault/src/test/settlement.rs) | `the_last_redemption_claim_clears_the_cash_remainder_and_says_so_once`, `an_epoch_with_no_requests_settles_when_it_is_priced`, `a_settled_epoch_leaves_no_remainder_in_the_totals`, `a_request_that_cannot_settle_keeps_its_epoch_open`, `a_refused_mint_keeps_a_priced_deposit_epoch_open` | | The oracle publishes the valuation it stored | [`contracts/nav-oracle/src/test.rs`](../contracts/nav-oracle/src/test.rs) | `attesting_publishes_the_stored_valuation` | +| The vault publishes the end of its setup, and an applied change publishes its own event before `ChangeApplied` | [`contracts/async-vault/src/test/setup.rs`](../contracts/async-vault/src/test/setup.rs), [`contracts/async-vault/src/test/governance.rs`](../contracts/async-vault/src/test/governance.rs) | `finishing_setup_publishes_it`, `applying_a_custodian_change_publishes_both_events` | ## Calls between contracts The vault tests deploy the real share token, identity verifier, compliance -contract and oracle, plus a Stellar Asset Contract for the deposit asset. +contract and oracle, plus a Stellar Asset Contract for the deposit asset. The +fixture wires them as the deploy does and finishes the vault's setup; +`setup_unfinished()` and `miswired(…)` leave the setup open. | Behaviour | Tests | Key test names | | --- | --- | --- | @@ -113,6 +124,7 @@ contract and oracle, plus a Stellar Asset Contract for the deposit asset. | The vault mints and burns shares through its role on the share token | [`contracts/async-vault/src/test/constructor.rs`](../contracts/async-vault/src/test/constructor.rs), [`contracts/async-vault/src/test/redeem.rs`](../contracts/async-vault/src/test/redeem.rs) | `the_vault_holds_the_manager_role_on_the_share_token`, `claim_redeem_pays_assets_and_burns_the_shares` | | 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` | +| The vault finishes setup only once the token and the oracle report it with their own setup closed, it is the token's only `manager`, it passes the identity check and the token is bound to compliance; before that it takes no deposit | [`contracts/async-vault/src/test/setup.rs`](../contracts/async-vault/src/test/setup.rs) | `every_wiring_step_is_checked_before_setup_finishes`, `a_deposit_before_setup_finishes_is_refused`, `setup_cannot_finish_while_a_hand_over_is_pending` | | 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; 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 2a379128..25acb850 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -84,7 +84,9 @@ The contracts enforce a set of mathematical and state-machine invariants across - **Definition**: Upgrades and wind-downs cannot execute without advance notice, and delays cannot be set to invalid boundaries. - **Testable Property**: - Upgrade delay $\in [7 \text{ days}, 90 \text{ days}]$ and $\text{delay} \ge \text{notice}$, checked when a delay change is proposed and again when it applies. - - Wind-down delay $\le 90 \text{ days}$. + - Wind-down delay $\in [7 \text{ days}, 90 \text{ days}]$ (`WindDownDelayTooShort`, `WindDownDelayTooLong`); 7 days when none was set. + - The vault takes no deposit until its one-time setup closes (`SetupOpen`). `finish_setup` refuses with `SetupIncomplete` until the share token and the oracle report this vault with their own setup closed, the vault is the token's only `manager`, it passes the token's identity check, and the token is bound to its compliance contract. It refuses with `AdminTransferPending` while an admin hand-over offered during setup is pending. Setup never reopens (`SetupClosed`). + - After setup, the vault's custodian, role grants, revoking `manager`, admin hand-overs and lowering the notice or the wind-down delay go through the queue; a direct call is refused with `UseTimelock`. Raising the notice or the wind-down delay, the deposit cap, `pause`/`unpause` and revoking any other role stay instant. A `Notice` or `WindDownDelay` change is checked against its bounds when proposed and again when it applies. - Every change waits in the queue (`propose_change` → `apply_change`). Applying before its eta reverts with `ChangeNotReady`. - A change whose delay is shorter than the notice standing at apply time reverts with `UpgradeDelayBelowNotice`. - Time spent paused does not count. Applying while paused is refused. @@ -153,10 +155,10 @@ The `async_vault` constructor strictly verifies pairwise separation across autho | Parameter | Method | Authority | Constraints & Defaults | |---|---|---|---| -| **Standing Notice** | `set_notice(secs: u64)` | Governance | Notice wait before pricing. Default: `0`. Must satisfy $\text{secs} \le \text{upgrade\_delay}$ and $\text{secs} \le 30 \text{ days}$ (`MAX_NOTICE_SECS`). | -| **Custodian Address** | `set_custodian(custodian: Address)` | Governance | Recipient address for off-chain capital deployment. Required before `deploy_to_custodian`. | -| **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`). | +| **Standing Notice** | `set_notice(secs: u64)`; after setup, lowering goes through `propose_change(Notice(secs))` → `apply_change(id)` | Governance | Notice wait before pricing. Default: `0`. Must satisfy $\text{secs} \le \text{upgrade\_delay}$ and $\text{secs} \le 30 \text{ days}$ (`MAX_NOTICE_SECS`), checked again when a `Notice` change applies. Raising is instant. | +| **Custodian Address** | `set_custodian(custodian: Address)` during setup; afterwards `propose_change(Custodian(address))` → `apply_change(id)` | Governance | Recipient address for off-chain capital deployment. Required before `deploy_to_custodian`. The deploy sets it before setup closes. | +| **Deposit Cap** | `set_deposit_cap(cap: Option)` | Governance | Maximum capacity for deposited capital. `None` = unbounded. Refuses negative cap. Instant both ways, also after setup. | +| **Wind-Down Delay** | `set_wind_down_delay(secs: u64)`; after setup, lowering goes through `propose_change(WindDownDelay(secs))` → `apply_change(id)` | Governance | Minimum notice before wind-down activation. Default and minimum 7 days (`MIN_WIND_DOWN_DELAY`), maximum 90 days (`MAX_WIND_DOWN_DELAY`). Raising is instant. | | **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. Applying a `Config` change sets every field, freshness included; to keep a cut made after the proposal, cancel the queued change and propose again. | @@ -170,7 +172,7 @@ Strata distributes control across five native Stellar multisig authorities and p | Authority | Quorum / Profile | Authorized Capabilities | Anti-Capabilities (What It Cannot Do) | |---|---|---|---| -| **Governance** | High-quorum deliberative multisig | Set parameters (`notice`, `custodian`, `deposit_cap`, `wind_down_delay`), propose/apply upgrades, propose/cancel wind-down, unpause the vault, pause/unpause the share token, transfer admin. | **Cannot pause the vault directly**; cannot move funds; cannot initiate custodian transfers; cannot attest prices; cannot bypass timelocks. | +| **Governance** | High-quorum deliberative multisig | Set parameters (`notice`, `custodian`, `deposit_cap`, `wind_down_delay`), propose/apply upgrades, propose/cancel wind-down, unpause the vault, pause/unpause the share token, transfer admin, finish each contract's one-time setup. After setup, these wait for the timelock: role grants, revoking the manager, admin hand-over, the custodian, lowering the notice or the wind-down delay, upgrades, the token's compliance and verifier swaps, and oracle config changes other than a shorter freshness window. Revoking the treasury or the guardian stays instant, because taking a power away moves no value. | **Cannot pause the vault directly**; cannot move funds; cannot initiate custodian transfers; cannot attest prices; cannot bypass timelocks. | | **Guardian** | Low-threshold fast-reaction multisig | `pause` (vault); `pause_nav` (oracle emergency price freeze). | **Cannot unpause**; cannot move funds; cannot block redemption requests; cannot block payable claims or cancellations. | | **Treasury** | Operational capital multisig | `deploy_to_custodian` (moves free reserve); `fund` (returns assets from custodian). | **Cannot deploy while anything is uncovered**; cannot touch escrowed subscription assets; cannot alter parameters or pause. | | **Attester** | High-security attestation multisig | `attest` (submits NAV report with proof reference). | **Cannot price outside deviation band**; cannot violate cooldown; cannot operate on the vault directly. | @@ -184,7 +186,7 @@ Strata distributes control across five native Stellar multisig authorities and p | Contract | Entrypoint | Calling Authority | Access Control Guard | Pausable? | |---|---|---|---|---| -| **`AsyncVault`** | `request_deposit` | Investor (Caller) | `from.require_auth()` | Yes (halted by pause) | +| **`AsyncVault`** | `request_deposit` | Investor (Caller) | `from.require_auth()`; refused until setup finishes (`SetupOpen`) | Yes (halted by pause) | | **`AsyncVault`** | `cancel_deposit` | Investor (Caller) | `from.require_auth()` | No (open during pause) | | **`AsyncVault`** | `claim_deposit` | Investor (Caller) | `caller.require_auth()` | No (open during pause) | | **`AsyncVault`** | `request_redeem` | Investor (Caller) | `from.require_auth()` | No (always open) | @@ -194,10 +196,12 @@ Strata distributes control across five native Stellar multisig authorities and p | **`AsyncVault`** | `fulfill_epoch` | Permissionless | None | Yes (halted by pause) | | **`AsyncVault`** | `pause` | Guardian | `#[only_role(caller, "guardian")]` | N/A | | **`AsyncVault`** | `unpause` | Governance | `#[only_admin]` | N/A | -| **`AsyncVault`** | `set_notice` | Governance | `#[only_admin]` | No | -| **`AsyncVault`** | `set_custodian` | Governance | `#[only_admin]` | No | -| **`AsyncVault`** | `set_deposit_cap` | Governance | `#[only_admin]` | No | -| **`AsyncVault`** | `set_wind_down_delay` | Governance | `#[only_admin]` | No | +| **`AsyncVault`** | `set_notice` | Governance | `#[only_admin]`; raising is instant, lowering is instant during setup, then `propose_change` | No | +| **`AsyncVault`** | `set_custodian` | Governance | `#[only_admin]`; instant during setup, then `propose_change` | No | +| **`AsyncVault`** | `set_deposit_cap` | Governance | `#[only_admin]`; instant both ways | No | +| **`AsyncVault`** | `set_wind_down_delay` | Governance | `#[only_admin]`; raising is instant, lowering is instant during setup, then `propose_change`; never below 7 days | No | +| **`AsyncVault`** | `finish_setup` | Governance | `#[only_admin]`; needs the wiring complete and no pending admin hand-over; once | No | +| **`AsyncVault`** | `setup_open` | Permissionless | None (view) | No | | **`AsyncVault`** | `propose_wind_down` | Governance | `#[only_admin]` | No | | **`AsyncVault`** | `cancel_wind_down_proposal` | Governance | `#[only_admin]` | No | | **`AsyncVault`** | `activate_wind_down` | Permissionless | None (requires timelock elapsed) | No | @@ -205,11 +209,13 @@ Strata distributes control across five native Stellar multisig authorities and p | **`AsyncVault`** | `claim_wind_down` | Shareholder (Caller) | `caller.require_auth()` | No | | **`AsyncVault`** | `deploy_to_custodian` | Treasury | `#[only_role(caller, "treasury")]` | No (open during pause) | | **`AsyncVault`** | `fund` | Treasury | None (caller transfers funds) | No | -| **`AsyncVault`** | `propose_change` | Governance | `#[only_admin]` | No | +| **`AsyncVault`** | `propose_change` | Governance | `#[only_admin]`; after setup | No | | **`AsyncVault`** | `cancel_change` | Governance | `#[only_admin]` | No | | **`AsyncVault`** | `apply_change` | Governance | `#[only_admin]` | Refused if paused or winding down | | **`AsyncVault`** | `pending_changes` | Permissionless | None (view) | No | -| **`AsyncVault`** | `transfer_admin_role` | Governance | `#[only_admin]` | No | +| **`AsyncVault`** | `grant_role`, `transfer_admin_role` | Governance | Instant during setup, then `propose_change` (a zero `live_until_ledger` cancels at once) | No | +| **`AsyncVault`** | `revoke_role` | Governance | `treasury`, `guardian`: instant. `manager`: instant during setup, then `propose_change` | No | +| **`AsyncVault`** | `set_role_admin` | None | Always refused (`UseTimelock`) | N/A | | **`AsyncVault`** | `accept_admin_transfer` | Proposed Admin | `caller.require_auth()` | No | | **`NavOracle`** | `attest` | Attester | `#[only_role(caller, "attester")]` | Refused if the NAV is paused | | **`NavOracle`** | `pause_nav` | Guardian | `#[only_role(caller, "guardian")]` | N/A | @@ -258,8 +264,9 @@ A clean deployment executes in three sequenced phases: - 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. + - Call `set_custodian(custodian)` on the vault, then `finish_setup()`. It refuses with `SetupIncomplete` until every wiring step above is in place, and with `AdminTransferPending` while an admin hand-over offered during setup is pending. The vault takes no deposit before it. 3. **Phase 3: Operational Parameterization** - - Governance calls `set_notice(secs)` and `set_custodian(custodian_address)`. + - Governance calls `set_notice(secs)`. - Governance sets `deposit_cap` if initial capital intake is bounded. - Oracle reporter submits the genesis NAV attestation (`attest`). @@ -350,3 +357,10 @@ 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. + +The vault's `finish_setup` reads both through `VaultBinding` and refuses until +each reports this vault with its own setup closed. It also reads the token's +`manager` members (OpenZeppelin `AccessControl`), runs `verify_identity` on its +own address, and reads `linked_tokens` from the token's compliance contract +(OpenZeppelin `TokenBinder`). A replacement for any of these must answer those +calls, or the vault's setup cannot finish. diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index 035e4eb3..c339d71b 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -81,7 +81,7 @@ or legal solution, and not a vault for on-chain RWA tokens. | Epoch sealer | manager (vault role, not an authority) | Seals the open epoch (`close_epoch`), nothing else; a vault role with its own key at deploy, meant to become permissionless (§8.4) | | Compliance | compliance | Maintains the allowlist; token interventions (freeze, forced transfer, recovery) | | Guardian | guardian | Pauses the vault, which stops deposit requests, epoch pricing and upgrade application and freezes the upgrade timelock; can never block redemption requests, payable claims, or custodian deployments | -| Governance | governance | Parameters, roles, custodian rotation, upgrades behind a timelock, wind-down proposal and proposal cancellation | +| Governance | governance | Parameters, roles, custodian rotation, upgrades, wind-down proposal and proposal cancellation; once each contract's one-time setup closes, the changes that can move value or shorten an exit wait for the timelock | | Custodian | Not an authority | Off-chain party holding the real-world structure; a genesis-configured slot rotatable only by governance | The authorities are native Stellar multisig accounts assigned at deploy, and @@ -433,7 +433,8 @@ interface today. - The exposed figures (share price, liquid reserve, committed, uncovered) make reserve coverage legible to investors and integrators. - Closing a vault is a dedicated, announced state. Governance proposes a - wind-down and may cancel the proposal during the delay. Once its delay has run, + wind-down and may cancel the proposal during the delay, which is never shorter + than seven days. Once its delay has run, anyone may activate it, so the operator cannot announce a closure and then stall it. Once activated, wind-down is strictly irreversible: the vault permanently ceases regular intake and distributes capital through pro-rata rounds. While it @@ -495,8 +496,8 @@ visible to the authorities that act in it, and each action only to its own | Cycle | manager, attestation, treasury; anyone prices, funds, activates and finalizes | Continuous | Epoch sealing (manager); attestations; transfers to the custodian (treasury); pricing, funding, wind-down activation and round finalization (permissionless) | | Compliance | compliance; verifier admin (compliance by deploy) | Continuous | Allowlist (verifier admin); freeze/unfreeze of an address or of part of its shares, forced transfer (compliance); recovery is not offered, the kit's verifier names no recovery target | | Emergency | guardian, governance; oracle admin and share-token admin (both governance by deploy) | Rare and urgent | Vault pause (guardian) and unpause (governance); oracle NAV pause (oracle guardian, or oracle admin through `set_nav_paused`), reset and `clear_latest` (oracle admin); share-token pause/unpause (share-token admin) | -| Configuration | governance; oracle admin and share-token admin (both governance by deploy) | Rare and deliberate | Custodian slot, deposit cap, notice and wind-down delay (governance); oracle guardrails (oracle admin); compliance and identity-verifier contracts (share-token admin) | -| Governance | governance; oracle admin and share-token admin for their own roles and handover (both governance by deploy) | Very rare | Roles and admin handover on the vault, oracle and share token; upgrade and its delay, wind-down proposal and cancellation (governance) | +| Configuration | governance; oracle admin and share-token admin (both governance by deploy) | Rare and deliberate | Custodian slot, deposit cap, notice and wind-down delay (governance); oracle guardrails (oracle admin); compliance and identity-verifier contracts (share-token admin). After setup, the vault's custodian and a lower notice or wind-down delay wait for the timelock, as do the oracle and token changes listed in §8.9; raising the notice or the delay and the deposit cap stay instant | +| Governance | governance; oracle admin and share-token admin for their own roles and handover (both governance by deploy) | Very rare | Roles and admin handover on the vault, oracle and share token; upgrade and its delay, wind-down proposal and cancellation (governance). After setup, every grant, `manager` revoke, handover and upgrade waits for the timelock; revoking an operational role (treasury, guardian, attester, compliance) stays instant | ```mermaid flowchart LR @@ -588,7 +589,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. 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. | +| 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 vault's own custodian, role grants, `manager` revocations, admin handovers and lowering of the notice or the wind-down delay wait on that clock once its one-time setup closes, and it takes no deposit before then; revoking its treasury or guardian stays instant. 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 cbf5c3691defc34d65e5cfbe7c36469ad0070349 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:01:51 -0300 Subject: [PATCH 046/147] fix(vault): address setup review round 1 --- README.md | 5 ++++ contracts/async-vault/src/error.rs | 4 +-- contracts/async-vault/src/governance.rs | 24 +++++++++++---- contracts/async-vault/src/lib.rs | 5 ++-- contracts/async-vault/src/test/mod.rs | 33 +++++++++++++++++++-- contracts/async-vault/src/test/setup.rs | 11 ++++--- contracts/async-vault/src/test/wind_down.rs | 31 +++++++++++-------- docs/AUTHORIZATION_MATRIX.md | 4 +-- docs/TESTING.md | 2 +- docs/kit-spec.md | 16 +++++----- docs/strata-product-and-architecture.md | 2 +- environments.toml | 8 ++--- 12 files changed, 100 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index e648d72e..a03b7d40 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,11 @@ npm run test:happy-path -w scripts/harness npm run test:guards -w scripts/harness ``` +Deploy afresh before each run of the suites. The guards leave the vault's +notice at 10 seconds, and lowering it back waits for the timelock, so a second +run against the same deployment can fail wherever it prices an epoch right +after closing it. + ### Keeping a deployment alive Every contract extends its own storage when it is called, but a deployment diff --git a/contracts/async-vault/src/error.rs b/contracts/async-vault/src/error.rs index cb2c1e23..c9d05a05 100644 --- a/contracts/async-vault/src/error.rs +++ b/contracts/async-vault/src/error.rs @@ -124,8 +124,8 @@ pub enum VaultError { /// Setup has finished, and it never reopens. SetupClosed = 6066, /// The token, the oracle, the manager role, the allowlist or the - /// compliance binding is not wired to this vault yet, so setup cannot - /// finish. + /// compliance binding is not wired to this vault yet, or the token already + /// has shares outstanding, so setup cannot finish. SetupIncomplete = 6067, /// The holder fails the share token's identity check, so the vault takes no /// deposit request from them and returns no escrowed shares to them. diff --git a/contracts/async-vault/src/governance.rs b/contracts/async-vault/src/governance.rs index a4c5f196..3fd08499 100644 --- a/contracts/async-vault/src/governance.rs +++ b/contracts/async-vault/src/governance.rs @@ -1,5 +1,7 @@ use bindings::VaultBindingClient; -use soroban_sdk::{contracttype, panic_with_error, Address, BytesN, Env, Symbol, Vec}; +use soroban_sdk::{ + contracttype, panic_with_error, symbol_short, Address, BytesN, Env, Symbol, Vec, +}; use stellar_access::{ access_control::{self, AccessControlClient, AccessControlStorageKey}, role_transfer, @@ -8,9 +10,12 @@ use stellar_contract_utils::{ pausable::{self, PausableError}, upgradeable, }; -use stellar_tokens::rwa::{ - identity_verification::IdentityVerifierClient, utils::token_binder::TokenBinderClient, - RWATokenClient, +use stellar_tokens::{ + fungible::FungibleTokenClient, + rwa::{ + identity_verification::IdentityVerifierClient, utils::token_binder::TokenBinderClient, + RWATokenClient, + }, }; use timelock::{queue, setup, Clock, Refusal}; @@ -50,11 +55,16 @@ pub struct VaultPendingChange { pub eta: u64, } +/// The share token's role that mints and burns. The vault must hold it alone. +const TOKEN_MANAGER_ROLE: Symbol = symbol_short!("manager"); + /// What `finish_setup` needs to see before deposits open. pub(crate) struct Wiring { pub token_bound: bool, pub oracle_bound: bool, pub sole_token_manager: bool, + /// Shares minted before setup would redeem against depositors' money. + pub no_shares_outstanding: bool, pub allowlisted: bool, pub compliance_bound: bool, } @@ -64,6 +74,7 @@ impl Wiring { self.token_bound && self.oracle_bound && self.sole_token_manager + && self.no_shares_outstanding && self.allowlisted && self.compliance_bound } @@ -137,8 +148,9 @@ pub(crate) fn wiring(e: &Env) -> Wiring { Wiring { token_bound: bound_here(e, &token), oracle_bound: bound_here(e, &oracle), - sole_token_manager: roles.get_role_member_count(&MANAGER_ROLE) == 1 - && roles.get_role_member(&MANAGER_ROLE, &0) == me, + sole_token_manager: roles.get_role_member_count(&TOKEN_MANAGER_ROLE) == 1 + && roles.get_role_member(&TOKEN_MANAGER_ROLE, &0) == me, + no_shares_outstanding: FungibleTokenClient::new(e, &token).total_supply() == 0, allowlisted: matches!( IdentityVerifierClient::new(e, &rwa.identity_verifier()).try_verify_identity(&me), Ok(Ok(())) diff --git a/contracts/async-vault/src/lib.rs b/contracts/async-vault/src/lib.rs index d8f29593..b5dfb9a9 100644 --- a/contracts/async-vault/src/lib.rs +++ b/contracts/async-vault/src/lib.rs @@ -211,6 +211,7 @@ impl AsyncVault { #[only_admin] pub fn set_wind_down_delay(e: &Env, secs: u64, caller: Address) { caller.require_auth(); + wind_down::check_delay(e, secs); if secs < wind_down::delay(e) { governance::require_setup_open(e); } @@ -285,8 +286,8 @@ impl AsyncVault { /// Ends the window in which admin changes are instant, once the token, /// the oracle, the manager role, the allowlist and the compliance binding - /// are wired to this vault. Deposits open from here on, and it never - /// reopens. + /// are wired to this vault and the token has no shares outstanding. + /// Deposits open from here on, and it never reopens. #[only_admin] pub fn finish_setup(e: &Env, caller: Address) { caller.require_auth(); diff --git a/contracts/async-vault/src/test/mod.rs b/contracts/async-vault/src/test/mod.rs index b69d48d8..51b42d12 100644 --- a/contracts/async-vault/src/test/mod.rs +++ b/contracts/async-vault/src/test/mod.rs @@ -78,6 +78,21 @@ impl Fixture<'_> { }); } + /// Signs one call to the vault as `who` and nothing else, so the call's own + /// role check is the only thing that can refuse it. + fn sign_only_as(&self, who: &Address, fn_name: &str, args: soroban_sdk::Vec) { + use soroban_sdk::testutils::{MockAuth, MockAuthInvoke}; + self.e.mock_auths(&[MockAuth { + address: who, + invoke: &MockAuthInvoke { + contract: &self.vault.address, + fn_name, + args, + sub_invokes: &[], + }, + }]); + } + fn investor(&self, funded: i128) -> Address { let who = self.unverified_investor(funded); self.identity.allow(&who, &true, &self.compliance); @@ -214,7 +229,9 @@ enum Miswire { OracleBoundElsewhere, OracleSetupOpen, NoManagerRole, + OtherManager, SecondManager, + PreMinted, NotAllowlisted, ComplianceUnbound, } @@ -318,12 +335,24 @@ fn build<'a>( }, ), ); - if miswire != Miswire::NoManagerRole { - share.grant_role(&contract_id, &symbol_short!("manager"), &admin); + match miswire { + Miswire::NoManagerRole => {} + Miswire::OtherManager => { + share.grant_role(&Address::generate(&e), &symbol_short!("manager"), &admin) + } + _ => share.grant_role(&contract_id, &symbol_short!("manager"), &admin), } if miswire == Miswire::SecondManager { share.grant_role(&Address::generate(&e), &symbol_short!("manager"), &admin); } + if miswire == Miswire::PreMinted { + // The token's admin mints during the token's own setup, then steps back. + let holder = Address::generate(&e); + identity.allow(&holder, &true, &compliance_authority); + share.grant_role(&admin, &symbol_short!("manager"), &admin); + share.mint(&holder, &100, &admin); + share.revoke_role(&admin, &symbol_short!("manager"), &admin); + } // The vault holds escrowed shares, so returning them on cancellation goes // through the token's checked transfer. That verifies both sides, so the // vault has to be a recognised holder itself. diff --git a/contracts/async-vault/src/test/setup.rs b/contracts/async-vault/src/test/setup.rs index 11500de5..f2d9a719 100644 --- a/contracts/async-vault/src/test/setup.rs +++ b/contracts/async-vault/src/test/setup.rs @@ -1,4 +1,4 @@ -use soroban_sdk::{testutils::Events as _, Event as _}; +use soroban_sdk::{testutils::Events as _, Event as _, IntoVal}; use stellar_access::role_transfer::RoleTransferError; use super::*; @@ -56,7 +56,9 @@ fn every_wiring_step_is_checked_before_setup_finishes() { Miswire::OracleBoundElsewhere, Miswire::OracleSetupOpen, Miswire::NoManagerRole, + Miswire::OtherManager, Miswire::SecondManager, + Miswire::PreMinted, Miswire::NotAllowlisted, Miswire::ComplianceUnbound, ] { @@ -111,9 +113,10 @@ fn only_governance_finishes_setup() { let f = setup_unfinished(); let stranger = Address::generate(&f.e); - f.e.set_auths(&[]); - assert!(f.vault.try_finish_setup(&stranger).is_err()); - assert!(f.vault.try_finish_setup(&f.manager).is_err()); + for who in [&stranger, &f.manager] { + f.sign_only_as(who, "finish_setup", (who.clone(),).into_val(&f.e)); + assert!(f.vault.try_finish_setup(who).is_err()); + } assert!(f.vault.setup_open()); } diff --git a/contracts/async-vault/src/test/wind_down.rs b/contracts/async-vault/src/test/wind_down.rs index 6ea7137e..87b5499d 100644 --- a/contracts/async-vault/src/test/wind_down.rs +++ b/contracts/async-vault/src/test/wind_down.rs @@ -1,4 +1,5 @@ use proptest::prelude::*; +use soroban_sdk::IntoVal; use super::*; @@ -27,12 +28,16 @@ fn the_delay_cannot_exceed_the_maximum() { fn only_governance_sets_the_delay() { let f = setup(); let stranger = Address::generate(&f.e); - - f.e.set_auths(&[]); - assert!(f.vault.try_set_wind_down_delay(&60, &stranger).is_err()); - assert!(f.vault.try_set_wind_down_delay(&60, &f.manager).is_err()); - assert!(f.vault.try_set_wind_down_delay(&60, &f.treasury).is_err()); - assert!(f.vault.try_set_wind_down_delay(&60, &f.guardian).is_err()); + let longer = 2 * MIN_WIND_DOWN_DELAY; + + for who in [&stranger, &f.manager, &f.treasury, &f.guardian] { + f.sign_only_as( + who, + "set_wind_down_delay", + (longer, who.clone()).into_val(&f.e), + ); + assert!(f.vault.try_set_wind_down_delay(&longer, who).is_err()); + } assert_eq!(f.vault.wind_down_delay(), MIN_WIND_DOWN_DELAY); } @@ -163,13 +168,13 @@ fn an_unset_delay_still_waits_the_minimum() { #[test] fn a_delay_below_the_minimum_is_refused() { - let f = setup_unfinished(); - - refused( - f.vault - .try_set_wind_down_delay(&(MIN_WIND_DOWN_DELAY - 1), &f.admin), - VaultError::WindDownDelayTooShort, - ); + for f in [setup_unfinished(), setup()] { + refused( + f.vault + .try_set_wind_down_delay(&(MIN_WIND_DOWN_DELAY - 1), &f.admin), + VaultError::WindDownDelayTooShort, + ); + } } /// Proposes, waits and activates. Returns nothing; the vault is active after. diff --git a/docs/AUTHORIZATION_MATRIX.md b/docs/AUTHORIZATION_MATRIX.md index fa70b0ff..93d2e679 100644 --- a/docs/AUTHORIZATION_MATRIX.md +++ b/docs/AUTHORIZATION_MATRIX.md @@ -38,7 +38,7 @@ In `scripts/harness/deploy.ts` and automated deployment scripts, the separation - After granting, the script checks `token.has_role({ account: vault, role: "manager" })` and stops if the vault does not hold the role. That no other account holds it is checked by the vault's `finish_setup` below. - 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). - 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). -- Last, it calls `vault.set_custodian({ custodian, caller: governance })` and `vault.finish_setup({ caller: governance })`, and stops if `vault.setup_open()` still reads true. `finish_setup` refuses with `SetupIncomplete` unless the token and the oracle report this vault with their setup closed, the vault is the token's only `manager`, the vault passes the identity check and the token is bound to its compliance contract. From then on the vault takes deposits and its admin changes wait for its timelock (§5.1). +- Last, it calls `vault.set_custodian({ custodian, caller: governance })` and `vault.finish_setup({ caller: governance })`, and stops if `vault.setup_open()` still reads true. `finish_setup` refuses with `SetupIncomplete` unless the token and the oracle report this vault with their setup closed, the vault is the token's only `manager`, the token has no shares outstanding, the vault passes the identity check and the token is bound to its compliance contract. From then on the vault takes deposits and its admin changes wait for its timelock (§5.1). --- @@ -90,7 +90,7 @@ Tests: [`controls.rs`](../contracts/async-vault/src/test/controls.rs), [`epochs. | `set_notice` | `#[only_admin]`, `caller.require_auth()` | `secs <= MAX_NOTICE_SECS`, `secs <= upgrade_delay`. Raising is instant; lowering is `UseTimelock` after setup | `test::governance::raising_the_notice_is_instant_and_lowering_waits`, `test::notice::setting_the_notice_needs_governance_authorisation` | | `set_wind_down_delay` | `#[only_admin]`, `caller.require_auth()` | `MIN_WIND_DOWN_DELAY <= secs <= MAX_WIND_DOWN_DELAY`. Raising is instant; lowering is `UseTimelock` after setup | `test::governance::raising_the_wind_down_delay_is_instant_and_lowering_waits`, `test::governance::a_wind_down_delay_below_the_minimum_cannot_be_proposed`, `test::wind_down::only_governance_sets_the_delay` | | `set_deposit_cap` | `#[only_admin]`, `caller.require_auth()` | Not negative. Instant both ways, also after setup | `test::governance::the_deposit_cap_stays_instant_both_ways` | -| `finish_setup` | `#[only_admin]`, `caller.require_auth()` | Once. The token and the oracle report this vault with their setup closed, the vault is the token's only `manager`, passes `verify_identity`, and the token is in its compliance contract's `linked_tokens`; no admin hand-over offered during setup is pending | `test::setup::every_wiring_step_is_checked_before_setup_finishes`, `test::setup::setup_cannot_finish_while_a_hand_over_is_pending`, `test::setup::setup_never_finishes_twice`, `test::setup::only_governance_finishes_setup` | +| `finish_setup` | `#[only_admin]`, `caller.require_auth()` | Once. The token and the oracle report this vault with their setup closed, the vault is the token's only `manager`, the token's `total_supply` is zero, the vault passes `verify_identity`, and the token is in its compliance contract's `linked_tokens`; no admin hand-over offered during setup is pending | `test::setup::every_wiring_step_is_checked_before_setup_finishes`, `test::setup::setup_cannot_finish_while_a_hand_over_is_pending`, `test::setup::setup_never_finishes_twice`, `test::setup::only_governance_finishes_setup` | | `grant_role` / `transfer_admin_role` | Admin | Instant during setup, `UseTimelock` after. A zero `live_until_ledger` cancels instantly | `test::governance::grants_and_manager_revokes_wait_for_the_timelock`, `test::governance::an_admin_hand_over_waits_but_cancelling_one_does_not` | | `revoke_role` | Admin | `treasury`, `guardian`: instant. `manager`: instant during setup, `UseTimelock` after | `test::governance::grants_and_manager_revokes_wait_for_the_timelock` (instant case: `test::governance::revoking_treasury_or_guardian_stays_instant`; caller check: `test::governance::only_the_admin_revokes_a_role`) | | `set_role_admin` | — | Always refused (`UseTimelock`) | `test::governance::no_role_ever_gets_a_role_admin` | diff --git a/docs/TESTING.md b/docs/TESTING.md index 81191b58..af002604 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -124,7 +124,7 @@ fixture wires them as the deploy does and finishes the vault's setup; | The vault mints and burns shares through its role on the share token | [`contracts/async-vault/src/test/constructor.rs`](../contracts/async-vault/src/test/constructor.rs), [`contracts/async-vault/src/test/redeem.rs`](../contracts/async-vault/src/test/redeem.rs) | `the_vault_holds_the_manager_role_on_the_share_token`, `claim_redeem_pays_assets_and_burns_the_shares` | | 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` | -| The vault finishes setup only once the token and the oracle report it with their own setup closed, it is the token's only `manager`, it passes the identity check and the token is bound to compliance; before that it takes no deposit | [`contracts/async-vault/src/test/setup.rs`](../contracts/async-vault/src/test/setup.rs) | `every_wiring_step_is_checked_before_setup_finishes`, `a_deposit_before_setup_finishes_is_refused`, `setup_cannot_finish_while_a_hand_over_is_pending` | +| The vault finishes setup only once the token and the oracle report it with their own setup closed, it is the token's only `manager`, the token has no shares outstanding, it passes the identity check and the token is bound to compliance; before that it takes no deposit | [`contracts/async-vault/src/test/setup.rs`](../contracts/async-vault/src/test/setup.rs) | `every_wiring_step_is_checked_before_setup_finishes`, `a_deposit_before_setup_finishes_is_refused`, `setup_cannot_finish_while_a_hand_over_is_pending` | | 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; 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 25acb850..91fe5d71 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -85,7 +85,7 @@ The contracts enforce a set of mathematical and state-machine invariants across - **Testable Property**: - Upgrade delay $\in [7 \text{ days}, 90 \text{ days}]$ and $\text{delay} \ge \text{notice}$, checked when a delay change is proposed and again when it applies. - Wind-down delay $\in [7 \text{ days}, 90 \text{ days}]$ (`WindDownDelayTooShort`, `WindDownDelayTooLong`); 7 days when none was set. - - The vault takes no deposit until its one-time setup closes (`SetupOpen`). `finish_setup` refuses with `SetupIncomplete` until the share token and the oracle report this vault with their own setup closed, the vault is the token's only `manager`, it passes the token's identity check, and the token is bound to its compliance contract. It refuses with `AdminTransferPending` while an admin hand-over offered during setup is pending. Setup never reopens (`SetupClosed`). + - The vault takes no deposit until its one-time setup closes (`SetupOpen`). `finish_setup` refuses with `SetupIncomplete` until the share token and the oracle report this vault with their own setup closed, the vault is the token's only `manager`, the token has no shares outstanding, the vault passes the token's identity check, and the token is bound to its compliance contract. Shares minted before setup closes would redeem against depositors' money. It refuses with `AdminTransferPending` while an admin hand-over offered during setup is pending. Setup never reopens (`SetupClosed`). - After setup, the vault's custodian, role grants, revoking `manager`, admin hand-overs and lowering the notice or the wind-down delay go through the queue; a direct call is refused with `UseTimelock`. Raising the notice or the wind-down delay, the deposit cap, `pause`/`unpause` and revoking any other role stay instant. A `Notice` or `WindDownDelay` change is checked against its bounds when proposed and again when it applies. - Every change waits in the queue (`propose_change` → `apply_change`). Applying before its eta reverts with `ChangeNotReady`. - A change whose delay is shorter than the notice standing at apply time reverts with `UpgradeDelayBelowNotice`. @@ -264,10 +264,9 @@ A clean deployment executes in three sequenced phases: - 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. - - Call `set_custodian(custodian)` on the vault, then `finish_setup()`. It refuses with `SetupIncomplete` until every wiring step above is in place, and with `AdminTransferPending` while an admin hand-over offered during setup is pending. The vault takes no deposit before it. + - On the vault, call `set_custodian(custodian)`, `set_notice(secs)` and, if initial capital intake is bounded, `set_deposit_cap(cap)`. All three are instant during setup, so deposits never open without the cap. + - Then call `finish_setup()` on the vault. It refuses with `SetupIncomplete` until every wiring step above is in place and the token has no shares outstanding, and with `AdminTransferPending` while an admin hand-over offered during setup is pending. The vault takes no deposit before it. 3. **Phase 3: Operational Parameterization** - - Governance calls `set_notice(secs)`. - - Governance sets `deposit_cap` if initial capital intake is bounded. - Oracle reporter submits the genesis NAV attestation (`attest`). --- @@ -360,7 +359,8 @@ bound to, and `setup_open` says whether its admin changes are still instant. The vault's `finish_setup` reads both through `VaultBinding` and refuses until each reports this vault with its own setup closed. It also reads the token's -`manager` members (OpenZeppelin `AccessControl`), runs `verify_identity` on its -own address, and reads `linked_tokens` from the token's compliance contract -(OpenZeppelin `TokenBinder`). A replacement for any of these must answer those -calls, or the vault's setup cannot finish. +`manager` members (OpenZeppelin `AccessControl`) and its `total_supply` +(`FungibleToken`), runs `verify_identity` on its own address, and reads +`linked_tokens` from the token's compliance contract (OpenZeppelin +`TokenBinder`). A replacement for any of these must answer those calls, or the +vault's setup cannot finish. diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index c339d71b..04b6bed7 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -589,7 +589,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 vault's own custodian, role grants, `manager` revocations, admin handovers and lowering of the notice or the wind-down delay wait on that clock once its one-time setup closes, and it takes no deposit before then; revoking its treasury or guardian stays instant. 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. | +| 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 vault's own custodian, role grants, `manager` revocations, admin handovers and lowering of the notice or the wind-down delay wait on that clock once its one-time setup closes, and it takes no deposit before then; revoking its treasury or guardian stays instant. Setup closes only once the token and the oracle run on the vault's clock with their own setup closed, the vault is the token's only minter, no shares exist yet (shares minted earlier would redeem against depositors' money), the vault passes the identity check and the token is bound to its compliance contract. 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) | diff --git a/environments.toml b/environments.toml index 41acc24a..72e244f7 100644 --- a/environments.toml +++ b/environments.toml @@ -55,10 +55,10 @@ constructor_args = '--asset share_token --share_token share_token --oracle nav_o # # Setup cannot finish from this file either. The vault's `finish_setup` checks # the token and oracle bindings, the manager role, the allowlist and the -# compliance binding, which only the harness deploy wires. A deploy made only -# from this file leaves the token's, the oracle's and the vault's setup open, so -# its vault refuses deposits with `SetupOpen`. The harness deploy closes all -# three. +# compliance binding, which only the harness deploy wires, and that the token +# has no shares outstanding. A deploy made only from this file leaves the +# token's, the oracle's and the vault's setup open, so its vault refuses +# deposits with `SetupOpen`. The harness deploy closes all three. ### Staging environment configuration From 6357e41e2b6a1e03964f1c74656ca8382362ae63 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:37:59 -0300 Subject: [PATCH 047/147] feat(vault): swap the oracle through the timelock --- contracts/async-vault/src/error.rs | 3 + contracts/async-vault/src/governance.rs | 13 +- contracts/async-vault/src/test/mod.rs | 1 + contracts/async-vault/src/test/oracle_swap.rs | 167 ++++++++++++++++++ docs/AUTHORIZATION_MATRIX.md | 4 +- docs/kit-spec.md | 1 + 6 files changed, 187 insertions(+), 2 deletions(-) create mode 100644 contracts/async-vault/src/test/oracle_swap.rs diff --git a/contracts/async-vault/src/error.rs b/contracts/async-vault/src/error.rs index c9d05a05..a4cf8c44 100644 --- a/contracts/async-vault/src/error.rs +++ b/contracts/async-vault/src/error.rs @@ -139,6 +139,9 @@ pub enum VaultError { /// returned or moved, until compliance lifts the freeze. A partial freeze /// refuses only what would touch frozen shares. Frozen = 6071, + /// The proposed feed does not name this vault as its own, or its setup is + /// still open. Its admin could then change its limits with no delay. + FeedNotBound = 6072, /// 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. diff --git a/contracts/async-vault/src/governance.rs b/contracts/async-vault/src/governance.rs index 3fd08499..4329f795 100644 --- a/contracts/async-vault/src/governance.rs +++ b/contracts/async-vault/src/governance.rs @@ -1,4 +1,4 @@ -use bindings::VaultBindingClient; +use bindings::{OracleFeedClient, VaultBindingClient}; use soroban_sdk::{ contracttype, panic_with_error, symbol_short, Address, BytesN, Env, Symbol, Vec, }; @@ -45,6 +45,7 @@ pub enum VaultChange { GrantRole(Symbol, Address), RevokeRole(Symbol, Address), Admin(Address), + Oracle(Address), } #[contracttype] @@ -214,6 +215,11 @@ fn check(e: &Env, change: &VaultChange) { VaultChange::UpgradeDelay(secs) => upgrade::check_delay(e, *secs), VaultChange::Notice(secs) => check_notice(e, *secs), VaultChange::WindDownDelay(secs) => wind_down::check_delay(e, *secs), + VaultChange::Oracle(feed) => { + if !bound_here(e, feed) { + panic_with_error!(e, VaultError::FeedNotBound); + } + } VaultChange::Upgrade(_) | VaultChange::Custodian(_) | VaultChange::GrantRole(..) @@ -264,6 +270,11 @@ pub(crate) fn apply(e: &Env, id: u32) { access_control::revoke_role_no_auth(e, account, role, &admin) } VaultChange::Admin(next) => offer_admin(e, &admin, next), + VaultChange::Oracle(feed) => { + // A feed that cannot answer would stall every epoch after the swap. + OracleFeedClient::new(e, feed).state(); + state::set_addr(e, &DataKey::Oracle, feed); + } } ChangeApplied { id, change }.publish(e); } diff --git a/contracts/async-vault/src/test/mod.rs b/contracts/async-vault/src/test/mod.rs index 51b42d12..1999e52b 100644 --- a/contracts/async-vault/src/test/mod.rs +++ b/contracts/async-vault/src/test/mod.rs @@ -13,6 +13,7 @@ mod lock_policy; mod multi_epoch; mod notice; mod oracle_pricing; +mod oracle_swap; mod pricing; mod redeem; mod settlement; diff --git a/contracts/async-vault/src/test/oracle_swap.rs b/contracts/async-vault/src/test/oracle_swap.rs new file mode 100644 index 00000000..ef5966f7 --- /dev/null +++ b/contracts/async-vault/src/test/oracle_swap.rs @@ -0,0 +1,167 @@ +use soroban_sdk::{contract, contractimpl}; + +use super::*; + +/// A feed whose answer to "which vault?" a test can change after proposing it. +#[contract] +struct FeedStub; + +#[contractimpl] +impl FeedStub { + pub fn report(e: Env, vault: Option
) { + e.storage().instance().set(&symbol_short!("vault"), &vault); + } + + pub fn state(_e: Env) -> bindings::OracleState { + bindings::OracleState::Valid + } +} + +#[contractimpl(contracttrait)] +impl bindings::VaultBinding for FeedStub { + fn vault(e: &Env) -> Option
{ + e.storage() + .instance() + .get(&symbol_short!("vault")) + .unwrap_or(None) + } + + fn setup_open(_e: &Env) -> bool { + false + } +} + +fn unbound_feed<'a>(f: &Fixture<'a>) -> NavOracleContractClient<'a> { + NavOracleContractClient::new( + &f.e, + &f.e.register( + NavOracleContract, + ( + f.admin.clone(), + f.attester.clone(), + f.guardian.clone(), + open_feed(), + ), + ), + ) +} + +fn second_feed<'a>(f: &Fixture<'a>) -> NavOracleContractClient<'a> { + let feed = unbound_feed(f); + feed.bind_vault(&f.vault.address, &f.admin); + feed.finish_setup(&f.admin); + feed +} + +fn attest_on(f: &Fixture, feed: &NavOracleContractClient, nav_per_share: i128) { + feed.attest( + &NavReport { + nav_per_share, + timestamp: 0, + expires_at: u64::MAX, + }, + &f.attester, + ); +} + +#[test] +fn the_oracle_changes_only_through_the_timelock() { + let f = setup(); + let next = second_feed(&f); + + let id = f + .vault + .propose_change(&VaultChange::Oracle(next.address.clone()), &f.admin); + f.advance(MIN_UPGRADE_DELAY - 1); + refused( + f.vault.try_apply_change(&id, &f.admin), + VaultError::ChangeNotReady, + ); + assert_eq!(f.vault.oracle(), f.oracle.address); + + f.advance(1); + f.vault.apply_change(&id, &f.admin); + assert_eq!(f.vault.oracle(), next.address); +} + +#[test] +fn the_next_epoch_prices_from_the_new_feed() { + let f = setup(); + let next = second_feed(&f); + let id = f + .vault + .propose_change(&VaultChange::Oracle(next.address.clone()), &f.admin); + f.advance(MIN_UPGRADE_DELAY); + f.vault.apply_change(&id, &f.admin); + let investor = f.investor(1_000); + f.vault.request_deposit(&investor, &400); + let epoch = f.close_epoch(); + + f.attest(wad(2)); + attest_on(&f, &next, wad(4)); + f.vault.fulfill_epoch(&epoch); + + assert_eq!(f.vault.get_epoch(&epoch).unwrap().share_price, wad(4)); +} + +#[test] +fn a_sealed_epoch_is_priced_by_the_feed_standing_at_fulfilment() { + let f = setup(); + let next = second_feed(&f); + let investor = f.investor(1_000); + f.vault.request_deposit(&investor, &400); + let epoch = f.close_epoch(); + + let id = f + .vault + .propose_change(&VaultChange::Oracle(next.address.clone()), &f.admin); + f.advance(MIN_UPGRADE_DELAY); + f.vault.apply_change(&id, &f.admin); + attest_on(&f, &next, wad(4)); + f.vault.fulfill_epoch(&epoch); + + assert_eq!(f.vault.claim_deposit(&investor, &epoch), 100); +} + +#[test] +fn a_feed_outside_this_vault_is_never_proposed() { + let f = setup(); + let open = unbound_feed(&f); + open.bind_vault(&f.vault.address, &f.admin); + let elsewhere = unbound_feed(&f); + elsewhere.bind_vault(®ister_with(&f.e, distinct_roles(&f.e)), &f.admin); + elsewhere.finish_setup(&f.admin); + + for feed in [ + Address::generate(&f.e), + unbound_feed(&f).address, + open.address, + elsewhere.address, + ] { + refused( + f.vault + .try_propose_change(&VaultChange::Oracle(feed), &f.admin), + VaultError::FeedNotBound, + ); + } + assert_eq!(f.vault.pending_changes().len(), 0); +} + +#[test] +fn a_feed_that_stops_naming_this_vault_is_never_installed() { + let f = setup(); + let stub = FeedStubClient::new(&f.e, &f.e.register(FeedStub, ())); + stub.report(&Some(f.vault.address.clone())); + let id = f + .vault + .propose_change(&VaultChange::Oracle(stub.address.clone()), &f.admin); + + stub.report(&Some(Address::generate(&f.e))); + f.advance(MIN_UPGRADE_DELAY); + + refused( + f.vault.try_apply_change(&id, &f.admin), + VaultError::FeedNotBound, + ); + assert_eq!(f.vault.oracle(), f.oracle.address); +} diff --git a/docs/AUTHORIZATION_MATRIX.md b/docs/AUTHORIZATION_MATRIX.md index 93d2e679..eff093f8 100644 --- a/docs/AUTHORIZATION_MATRIX.md +++ b/docs/AUTHORIZATION_MATRIX.md @@ -76,7 +76,7 @@ The following entrypoints are open to any caller on purpose: ### 5.1 AsyncVault (`contracts/async-vault`) -The vault's `admin` is governance. Until `finish_setup`, admin changes are instant and the vault takes no deposit (`SetupOpen`). Setup finishes once the wiring is complete and no admin hand-over offered during setup is pending, and it never reopens. After setup, the following wait for the timelock through `propose_change`/`apply_change`: the custodian, grants, revoking `manager`, admin hand-over, lowering the notice or the wind-down delay, and upgrades. Raising the notice or the wind-down delay, the deposit cap, `pause`/`unpause` and revoking `treasury` or `guardian` stay instant. During an active wind-down nothing timelocked is proposed or applied, a `manager` revoke included. +The vault's `admin` is governance. Until `finish_setup`, admin changes are instant and the vault takes no deposit (`SetupOpen`). Setup finishes once the wiring is complete and no admin hand-over offered during setup is pending, and it never reopens. After setup, the following wait for the timelock through `propose_change`/`apply_change`: the custodian, grants, revoking `manager`, admin hand-over, lowering the notice or the wind-down delay, the oracle, and upgrades. Raising the notice or the wind-down delay, the deposit cap, `pause`/`unpause` and revoking `treasury` or `guardian` stay instant. During an active wind-down nothing timelocked is proposed or applied, a `manager` revoke included. Tests: [`controls.rs`](../contracts/async-vault/src/test/controls.rs), [`epochs.rs`](../contracts/async-vault/src/test/epochs.rs), [`governance.rs`](../contracts/async-vault/src/test/governance.rs), [`notice.rs`](../contracts/async-vault/src/test/notice.rs), [`setup.rs`](../contracts/async-vault/src/test/setup.rs), [`treasury.rs`](../contracts/async-vault/src/test/treasury.rs), [`upgrade.rs`](../contracts/async-vault/src/test/upgrade.rs) and [`wind_down.rs`](../contracts/async-vault/src/test/wind_down.rs) in [`contracts/async-vault/src/test/`](../contracts/async-vault/src/test/). @@ -99,6 +99,8 @@ Tests: [`controls.rs`](../contracts/async-vault/src/test/controls.rs), [`epochs. | `propose_change` | `#[only_admin]`, `caller.require_auth()` | Not winding down, setup finished, one code change at a time, an `UpgradeDelay` in `[MIN, MAX]` and `>= notice`, a `Notice` within its bounds, a `WindDownDelay` in `[MIN_WIND_DOWN_DELAY, MAX_WIND_DOWN_DELAY]` | `test::upgrade::only_governance_proposes_and_cancels`, `test::upgrade::nothing_is_proposed_during_a_wind_down`, `test::setup::nothing_is_proposed_during_setup` | | `cancel_change` | `#[only_admin]`, `caller.require_auth()` | The change must be pending | `test::upgrade::only_governance_proposes_and_cancels` | | `apply_change` | `#[only_admin]`, `caller.require_auth()` | Eta passed, not paused, not winding down, delay `>= notice`; an `UpgradeDelay`, `Notice` or `WindDownDelay` change is checked against its bounds again | `test::upgrade::only_governance_applies`, `test::governance::a_change_shorter_than_a_raised_notice_never_lands`, `test::governance::nothing_timelocked_moves_during_a_wind_down_a_manager_revoke_included` | +| `propose_change` (`Oracle(address)`) | `#[only_admin]` | The feed names this vault and its setup is closed | `test::oracle_swap::a_feed_outside_this_vault_is_never_proposed` | +| `apply_change` (`Oracle(address)`) | `#[only_admin]` | Delay run; the feed still names this vault with its setup closed, and answers `state()` | `test::oracle_swap::the_oracle_changes_only_through_the_timelock`, `test::oracle_swap::a_feed_that_stops_naming_this_vault_is_never_installed` | | `pending_changes` | Open view | None | None (view) | | `renounce_admin` | Refused always (`VaultError::AdminRequired`) | None | `test::controls::governance_cannot_renounce_itself_out_of_the_vault`; `test::controls::governance_hands_over_in_two_steps_instead`; `test::governance::an_applied_admin_change_starts_a_hand_over_the_next_admin_accepts`. Hand over by proposing and applying an `Admin` change (`transfer_admin_role` during setup), then the nominee calls `accept_admin_transfer`. | diff --git a/docs/kit-spec.md b/docs/kit-spec.md index 91fe5d71..3d0a64a6 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -161,6 +161,7 @@ The `async_vault` constructor strictly verifies pairwise separation across autho | **Wind-Down Delay** | `set_wind_down_delay(secs: u64)`; after setup, lowering goes through `propose_change(WindDownDelay(secs))` → `apply_change(id)` | Governance | Minimum notice before wind-down activation. Default and minimum 7 days (`MIN_WIND_DOWN_DELAY`), maximum 90 days (`MAX_WIND_DOWN_DELAY`). Raising is instant. | | **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. Applying a `Config` change sets every field, freshness included; to keep a cut made after the proposal, cancel the queued change and propose again. | +| **Oracle Feed** | `propose_change(Oracle(address))` → `apply_change(id)` | Governance | Waits for the timelock. The new feed must name this vault through `vault()` with its own setup finished, at proposal and again at apply, and must answer `state()`. The next fulfilment prices from it, including an epoch sealed before the swap. | --- From cc0f64e89f9559e3e48905674097d157298f45a7 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:08:04 -0300 Subject: [PATCH 048/147] feat(vault): report wiring and feed health --- README.md | 1 + contracts/async-vault/src/governance.rs | 3 +- contracts/async-vault/src/health.rs | 44 +++++++++ contracts/async-vault/src/lib.rs | 8 ++ contracts/async-vault/src/test/health.rs | 118 +++++++++++++++++++++++ contracts/async-vault/src/test/mod.rs | 1 + docs/kit-spec.md | 17 ++++ docs/strata-product-and-architecture.md | 1 + scripts/harness/deploy.ts | 13 +++ 9 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 contracts/async-vault/src/health.rs create mode 100644 contracts/async-vault/src/test/health.rs diff --git a/README.md b/README.md index a03b7d40..a1e8209a 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,7 @@ vault is allowlisted share_token bound to compliance nav_oracle bound to the vault, setup finished vault setup finished +health {"allowlisted":true,"compliance_bound":true,"feed_valid":false,"freshness_covers_notice":true,"oracle_bound":true,"setup_open":false,"sole_token_manager":true,"token_bound":true} wrote deployed.local.json ``` diff --git a/contracts/async-vault/src/governance.rs b/contracts/async-vault/src/governance.rs index 4329f795..8ee135ec 100644 --- a/contracts/async-vault/src/governance.rs +++ b/contracts/async-vault/src/governance.rs @@ -59,7 +59,8 @@ pub struct VaultPendingChange { /// The share token's role that mints and burns. The vault must hold it alone. const TOKEN_MANAGER_ROLE: Symbol = symbol_short!("manager"); -/// What `finish_setup` needs to see before deposits open. +/// What `finish_setup` needs to see before deposits open. `health()` reports +/// the same checks. pub(crate) struct Wiring { pub token_bound: bool, pub oracle_bound: bool, diff --git a/contracts/async-vault/src/health.rs b/contracts/async-vault/src/health.rs new file mode 100644 index 00000000..c0768428 --- /dev/null +++ b/contracts/async-vault/src/health.rs @@ -0,0 +1,44 @@ +use bindings::{OracleFeedClient, OracleState}; +use soroban_sdk::{contracttype, Env}; + +use crate::governance; +use crate::keys::DataKey; +use crate::state; + +/// What a deployment checks before it trusts the vault, and what monitoring +/// keeps checking after. Every field is true on a healthy vault. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Health { + /// False once `finish_setup` has run. While true, every admin change is + /// instant and deposits are refused. + pub setup_open: bool, + pub token_bound: bool, + pub oracle_bound: bool, + pub sole_token_manager: bool, + pub allowlisted: bool, + pub compliance_bound: bool, + pub feed_valid: bool, + /// A report outlives the notice, so an epoch's cancellation window does + /// not reopen on its own while the notice runs. + pub freshness_covers_notice: bool, +} + +pub(crate) fn health(e: &Env) -> Health { + let wiring = governance::wiring(e); + let feed = OracleFeedClient::new(e, &state::get_addr(e, &DataKey::Oracle)); + let notice = state::notice(e); + Health { + setup_open: timelock::setup::is_open(e), + token_bound: wiring.token_bound, + oracle_bound: wiring.oracle_bound, + sole_token_manager: wiring.sole_token_manager, + allowlisted: wiring.allowlisted, + compliance_bound: wiring.compliance_bound, + feed_valid: matches!(feed.try_state(), Ok(Ok(OracleState::Valid))), + freshness_covers_notice: matches!( + feed.try_freshness_duration(), + Ok(Ok(freshness)) if freshness >= notice + ), + } +} diff --git a/contracts/async-vault/src/lib.rs b/contracts/async-vault/src/lib.rs index b5dfb9a9..2e87cd24 100644 --- a/contracts/async-vault/src/lib.rs +++ b/contracts/async-vault/src/lib.rs @@ -5,6 +5,7 @@ mod epoch; mod error; mod event; mod governance; +mod health; mod keys; mod lock_policy; mod pricing; @@ -38,6 +39,7 @@ pub use event::{ WindDownProposed, WindDownRoundFinalized, }; pub use governance::{VaultChange, VaultPendingChange, ADMIN_ACCEPT_LEDGERS}; +pub use health::Health; pub use pricing::{DirectUnitPricing, PricingScheme}; pub use roles::VaultRoles; pub use settlement::EpochSettlement; @@ -298,6 +300,12 @@ impl AsyncVault { timelock::setup::is_open(e) } + /// The wiring `finish_setup` checks, plus the feed: whether it can price + /// now, and whether its reports outlive the notice. + pub fn health(e: &Env) -> Health { + health::health(e) + } + /// Queues a change behind the timelock and returns its id. #[only_admin] pub fn propose_change(e: &Env, change: VaultChange, caller: Address) -> u32 { diff --git a/contracts/async-vault/src/test/health.rs b/contracts/async-vault/src/test/health.rs new file mode 100644 index 00000000..07bf6a4e --- /dev/null +++ b/contracts/async-vault/src/test/health.rs @@ -0,0 +1,118 @@ +use super::*; +use crate::Health; + +fn all_healthy() -> Health { + Health { + setup_open: false, + token_bound: true, + oracle_bound: true, + sole_token_manager: true, + allowlisted: true, + compliance_bound: true, + feed_valid: true, + freshness_covers_notice: true, + } +} + +#[test] +fn a_finished_vault_with_a_fresh_report_is_healthy() { + let f = setup(); + f.attest(wad(1)); + + assert_eq!(f.vault.health(), all_healthy()); +} + +#[test] +fn an_open_setup_is_reported() { + let f = setup_unfinished(); + f.attest(wad(1)); + + assert_eq!( + f.vault.health(), + Health { + setup_open: true, + ..all_healthy() + } + ); +} + +#[test] +fn each_missing_wiring_step_shows_in_its_own_flag() { + let cases = [ + ( + Miswire::TokenUnbound, + Health { + token_bound: false, + ..all_healthy() + }, + ), + ( + Miswire::OracleUnbound, + Health { + oracle_bound: false, + ..all_healthy() + }, + ), + ( + Miswire::NoManagerRole, + Health { + sole_token_manager: false, + ..all_healthy() + }, + ), + ( + Miswire::SecondManager, + Health { + sole_token_manager: false, + ..all_healthy() + }, + ), + ( + Miswire::NotAllowlisted, + Health { + allowlisted: false, + ..all_healthy() + }, + ), + ( + Miswire::ComplianceUnbound, + Health { + compliance_bound: false, + ..all_healthy() + }, + ), + ]; + for (miswire, expected) in cases { + let f = miswired(miswire); + f.attest(wad(1)); + + assert_eq!( + f.vault.health(), + Health { + setup_open: true, + ..expected + } + ); + } +} + +#[test] +fn a_feed_with_no_report_or_the_nav_paused_is_not_valid() { + let f = setup(); + assert!(!f.vault.health().feed_valid); + + f.attest(wad(1)); + f.oracle.pause_nav(&f.guardian); + assert!(!f.vault.health().feed_valid); +} + +#[test] +fn a_notice_longer_than_a_report_lives_is_reported() { + let f = setup(); + f.attest(wad(1)); + let freshness = f.oracle.config().freshness_duration; + + f.vault.set_notice(&(freshness + 1), &f.admin); + + assert!(!f.vault.health().freshness_covers_notice); +} diff --git a/contracts/async-vault/src/test/mod.rs b/contracts/async-vault/src/test/mod.rs index 1999e52b..e2978d91 100644 --- a/contracts/async-vault/src/test/mod.rs +++ b/contracts/async-vault/src/test/mod.rs @@ -9,6 +9,7 @@ mod deposit_cap; mod epochs; mod events; mod governance; +mod health; mod lock_policy; mod multi_epoch; mod notice; diff --git a/docs/kit-spec.md b/docs/kit-spec.md index 3d0a64a6..61ddd19a 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -163,6 +163,23 @@ The `async_vault` constructor strictly verifies pairwise separation across autho | **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. | | **Oracle Feed** | `propose_change(Oracle(address))` → `apply_change(id)` | Governance | Waits for the timelock. The new feed must name this vault through `vault()` with its own setup finished, at proposal and again at apply, and must answer `state()`. The next fulfilment prices from it, including an epoch sealed before the swap. | +### Health View + +`health()` returns one flag per check. A deployment is ready when `setup_open` is false and every other flag is true. + +| Field | True when | +|---|---| +| `setup_open` | `finish_setup` has not run yet. While true, admin changes are instant and deposits are refused. | +| `token_bound` | The share token names this vault through `vault()` and its own setup is finished. | +| `oracle_bound` | The oracle names this vault through `vault()` and its own setup is finished. | +| `sole_token_manager` | This vault is the share token's only `manager`. | +| `allowlisted` | This vault passes the share token's identity verifier, so it can hold escrowed shares. | +| `compliance_bound` | The share token is in its compliance contract's `linked_tokens`. | +| `feed_valid` | The oracle reports `Valid`: a report exists, it has not expired and the NAV is not paused. | +| `freshness_covers_notice` | The oracle's `freshness_duration` is at least the vault's notice. | + +`freshness_covers_notice` false means a report can expire before the notice ends. A sealed epoch's cancellation window can then reopen on its own, so set `freshness_duration` above the notice. `feed_valid` false right after a deploy is normal until the first attestation. + --- ## 3. Role & Authority Matrix diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index 04b6bed7..014ff84f 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -590,6 +590,7 @@ public surface given the vault address. | ---------------------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 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 vault's own custodian, role grants, `manager` revocations, admin handovers and lowering of the notice or the wind-down delay wait on that clock once its one-time setup closes, and it takes no deposit before then; revoking its treasury or guardian stays instant. Setup closes only once the token and the oracle run on the vault's clock with their own setup closed, the vault is the token's only minter, no shares exist yet (shares minted earlier would redeem against depositors' money), the vault passes the identity check and the token is bound to its compliance contract. 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. | +| Mis-wired deployment | A vault goes live without every binding wired | `health()` reports each wiring step; `finish_setup` refuses until all hold, and the vault takes no deposit before that | | 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) | diff --git a/scripts/harness/deploy.ts b/scripts/harness/deploy.ts index a948bc4b..4f7c4c79 100644 --- a/scripts/harness/deploy.ts +++ b/scripts/harness/deploy.ts @@ -238,6 +238,19 @@ async function finishVaultSetup( if ((await vault.setup_open()).result) throw new Error("the vault's setup did not finish") console.log("vault setup finished") + + const health = (await vault.health()).result + console.log("health", JSON.stringify(health)) + const wiring = [ + "token_bound", + "oracle_bound", + "sole_token_manager", + "allowlisted", + "compliance_bound", + ] as const + const broken = wiring.filter((flag) => !health[flag]) + if (health.setup_open || broken.length > 0) + throw new Error(`the vault is not wired: ${broken.join(", ")}`) } async function main(): Promise { From 91e11575cafd3ad2347c4c5f0f9b4fb45160d460 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:35:32 -0300 Subject: [PATCH 049/147] fix(vault): never trap on a misbehaving token in wiring checks --- contracts/async-vault/src/governance.rs | 32 +++++++++---- contracts/async-vault/src/test/setup.rs | 63 +++++++++++++++++++++++++ docs/kit-spec.md | 2 +- 3 files changed, 87 insertions(+), 10 deletions(-) diff --git a/contracts/async-vault/src/governance.rs b/contracts/async-vault/src/governance.rs index 8ee135ec..df168453 100644 --- a/contracts/async-vault/src/governance.rs +++ b/contracts/async-vault/src/governance.rs @@ -150,17 +150,31 @@ pub(crate) fn wiring(e: &Env) -> Wiring { Wiring { token_bound: bound_here(e, &token), oracle_bound: bound_here(e, &oracle), - sole_token_manager: roles.get_role_member_count(&TOKEN_MANAGER_ROLE) == 1 - && roles.get_role_member(&TOKEN_MANAGER_ROLE, &0) == me, - no_shares_outstanding: FungibleTokenClient::new(e, &token).total_supply() == 0, - allowlisted: matches!( - IdentityVerifierClient::new(e, &rwa.identity_verifier()).try_verify_identity(&me), - Ok(Ok(())) + sole_token_manager: matches!( + roles.try_get_role_member_count(&TOKEN_MANAGER_ROLE), + Ok(Ok(1)) + ) && matches!( + roles.try_get_role_member(&TOKEN_MANAGER_ROLE, &0), + Ok(Ok(holder)) if holder == me ), - compliance_bound: matches!( - TokenBinderClient::new(e, &rwa.compliance()).try_linked_tokens(), - Ok(Ok(linked)) if linked.contains(&token) + no_shares_outstanding: matches!( + FungibleTokenClient::new(e, &token).try_total_supply(), + Ok(Ok(0)) ), + allowlisted: match rwa.try_identity_verifier() { + Ok(Ok(verifier)) => matches!( + IdentityVerifierClient::new(e, &verifier).try_verify_identity(&me), + Ok(Ok(())) + ), + _ => false, + }, + compliance_bound: match rwa.try_compliance() { + Ok(Ok(compliance)) => matches!( + TokenBinderClient::new(e, &compliance).try_linked_tokens(), + Ok(Ok(linked)) if linked.contains(&token) + ), + _ => false, + }, } } diff --git a/contracts/async-vault/src/test/setup.rs b/contracts/async-vault/src/test/setup.rs index f2d9a719..2e1c1617 100644 --- a/contracts/async-vault/src/test/setup.rs +++ b/contracts/async-vault/src/test/setup.rs @@ -73,6 +73,69 @@ fn every_wiring_step_is_checked_before_setup_finishes() { } } +/// A token that cannot answer `wiring`'s calls must not trap the vault: every +/// check it needs the token for reads false, and `finish_setup` refuses +/// cleanly instead of aborting the transaction. +#[test] +fn a_token_that_cannot_answer_fails_its_checks_instead_of_trapping() { + let e = Env::default(); + e.mock_all_auths(); + e.ledger().set_timestamp(10_000); + + let issuer = Address::generate(&e); + let asset = e.register_stellar_asset_contract_v2(issuer).address(); + let admin = Address::generate(&e); + let manager = Address::generate(&e); + let treasury = Address::generate(&e); + let guardian = Address::generate(&e); + let compliance_authority = Address::generate(&e); + let attester = Address::generate(&e); + let broken_token = Address::generate(&e); + + let oracle = NavOracleContractClient::new( + &e, + &e.register( + NavOracleContract, + ( + admin.clone(), + attester.clone(), + guardian.clone(), + open_feed(), + ), + ), + ); + + let contract_id = e.register( + AsyncVault, + ( + &asset, + &broken_token, + &oracle.address, + VaultRoles { + governance: admin.clone(), + manager, + treasury, + guardian: guardian.clone(), + compliance: compliance_authority, + attester, + }, + ), + ); + oracle.bind_vault(&contract_id, &admin); + oracle.finish_setup(&admin); + + let vault = AsyncVaultClient::new(&e, &contract_id); + + let health = vault.health(); + assert!(health.oracle_bound); + assert!(!health.token_bound); + assert!(!health.sole_token_manager); + assert!(!health.allowlisted); + assert!(!health.compliance_bound); + + refused(vault.try_finish_setup(&admin), VaultError::SetupIncomplete); +} + #[test] fn setup_cannot_finish_while_a_hand_over_is_pending() { let f = setup_unfinished(); diff --git a/docs/kit-spec.md b/docs/kit-spec.md index 61ddd19a..b7e46b91 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -173,7 +173,7 @@ The `async_vault` constructor strictly verifies pairwise separation across autho | `token_bound` | The share token names this vault through `vault()` and its own setup is finished. | | `oracle_bound` | The oracle names this vault through `vault()` and its own setup is finished. | | `sole_token_manager` | This vault is the share token's only `manager`. | -| `allowlisted` | This vault passes the share token's identity verifier, so it can hold escrowed shares. | +| `allowlisted` | This vault passes the share token's identity verifier, so it can hold escrowed shares. A delisted holder's deposit converts to an exit by minting the shares to the vault's own address, so that conversion needs this flag true. | | `compliance_bound` | The share token is in its compliance contract's `linked_tokens`. | | `feed_valid` | The oracle reports `Valid`: a report exists, it has not expired and the NAV is not paused. | | `freshness_covers_notice` | The oracle's `freshness_duration` is at least the vault's notice. | From 49f2c011c19c464f63fecfd46b30efddbfd90163 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:15:21 -0300 Subject: [PATCH 050/147] test(harness): wind down a throwaway vault --- README.md | 6 +- docs/TESTING.md | 11 ++ scripts/harness/deploy.ts | 154 ++++++++++------- scripts/harness/lib/clients.ts | 5 +- scripts/harness/package.json | 1 + scripts/harness/wind-down.test.ts | 264 ++++++++++++++++++++++++++++++ 6 files changed, 383 insertions(+), 58 deletions(-) create mode 100644 scripts/harness/wind-down.test.ts diff --git a/README.md b/README.md index a1e8209a..bbb36ca1 100644 --- a/README.md +++ b/README.md @@ -135,12 +135,16 @@ integration suites against that deployment: ```sh npm run test:happy-path -w scripts/harness npm run test:guards -w scripts/harness +CARGO_TARGET_DIR=target/short-wind-down stellar contract build --package async-vault --features short_wind_down +npm run test:wind-down -w scripts/harness ``` Deploy afresh before each run of the suites. The guards leave the vault's notice at 10 seconds, and lowering it back waits for the timelock, so a second run against the same deployment can fail wherever it prices an epoch right -after closing it. +after closing it. `test:wind-down` ignores `deployed.local.json`: it deploys +its own throwaway vault set, built from the `short_wind_down` wasm above, so +it can reach an active wind-down without the real seven-day minimum. ### Keeping a deployment alive diff --git a/docs/TESTING.md b/docs/TESTING.md index af002604..8c0709ed 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -128,3 +128,14 @@ fixture wires them as the deploy does and finishes the vault's setup; | 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; 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` | + +## Wind-down on the network + +`scripts/harness/wind-down.test.ts` deploys a throwaway vault set on the local +network from a vault built with `--features short_wind_down`. That build drops +the seven-day minimum and carries a `build` meta entry, and is never deployed +elsewhere. The test runs a deposit, a claim, a redeem request, a wind-down, a +round and a claim, then checks that every function in the vault's spec was +called through the generated client. The full wind-down logic is tested in +Rust (`contracts/async-vault/src/test/wind_down.rs`). The testnet run is +spread over seven days in the Tranche 2 release. diff --git a/scripts/harness/deploy.ts b/scripts/harness/deploy.ts index 4f7c4c79..1f17fcde 100644 --- a/scripts/harness/deploy.ts +++ b/scripts/harness/deploy.ts @@ -2,7 +2,7 @@ import { existsSync } from "node:fs" import { join } from "node:path" import { pathToFileURL } from "node:url" -import { Asset, rpc, type Keypair } from "@stellar/stellar-sdk" +import { Asset, Keypair, rpc } from "@stellar/stellar-sdk" import { basicNodeSigner } from "@stellar/stellar-sdk/contract" import type * as AsyncVaultModule from "../../app-lib/clients/async_vault/dist/index.js" @@ -26,8 +26,17 @@ import { } from "./lib/deployment.js" import { network, type Network } from "./lib/network.js" -const wasmOf = (name: ContractName): string => - join(REPO_ROOT, "target", "wasm32v1-none", "release", `${name}.wasm`) +const RELEASE_DIR = join(REPO_ROOT, "target", "wasm32v1-none", "release") +export const SHORT_WIND_DOWN_DIR = join( + REPO_ROOT, + "target", + "short-wind-down", + "wasm32v1-none", + "release", +) + +export const wasmOf = (name: ContractName, dir = RELEASE_DIR): string => + join(dir, `${name}.wasm`) const clientDir = (name: ContractName): string => join(REPO_ROOT, "app-lib", "clients", name) @@ -59,40 +68,36 @@ async function deployContracts( net: Network, accounts: Accounts, assetContract: string, + wasms: Record, ): Promise> { const pk = (name: keyof Accounts): string => accounts[name].publicKey() const deployer = accounts.deployer const governance = pk("governance") - const compliance = await deployContract(net, wasmOf("compliance"), deployer, { + const compliance = await deployContract(net, wasms.compliance, deployer, { admin: governance, }) console.log("compliance", compliance) const identityVerifier = await deployContract( net, - wasmOf("identity_verifier"), + wasms.identity_verifier, deployer, { admin: pk("compliance") }, ) console.log("identity_verifier", identityVerifier) - const shareToken = await deployContract( - net, - wasmOf("share_token"), - deployer, - { - name: "Strata Vault USDC", - symbol: "bvUSDC", - admin: governance, - compliance_authority: pk("compliance"), - compliance, - identity_verifier: identityVerifier, - }, - ) + const shareToken = await deployContract(net, wasms.share_token, deployer, { + name: "Strata Vault USDC", + symbol: "bvUSDC", + admin: governance, + compliance_authority: pk("compliance"), + compliance, + identity_verifier: identityVerifier, + }) console.log("share_token", shareToken) - const navOracle = await deployContract(net, wasmOf("nav_oracle"), deployer, { + const navOracle = await deployContract(net, wasms.nav_oracle, deployer, { admin: governance, attester: pk("attester"), guardian: pk("guardian"), @@ -100,24 +105,19 @@ async function deployContracts( }) console.log("nav_oracle", navOracle) - const asyncVault = await deployContract( - net, - wasmOf("async_vault"), - deployer, - { - asset: assetContract, - share_token: shareToken, - oracle: navOracle, - roles: JSON.stringify({ - governance, - manager: pk("manager"), - treasury: pk("treasury"), - guardian: pk("guardian"), - compliance: pk("compliance"), - attester: pk("attester"), - }), - }, - ) + const asyncVault = await deployContract(net, wasms.async_vault, deployer, { + asset: assetContract, + share_token: shareToken, + oracle: navOracle, + roles: JSON.stringify({ + governance, + manager: pk("manager"), + treasury: pk("treasury"), + guardian: pk("guardian"), + compliance: pk("compliance"), + attester: pk("attester"), + }), + }) console.log("async_vault", asyncVault) return { @@ -253,12 +253,11 @@ async function finishVaultSetup( throw new Error(`the vault is not wired: ${broken.join(", ")}`) } -async function main(): Promise { - requireWasm() - const net = network() +async function freshSet( + net: Network, + wasms: Record, +): Promise { const server = new rpc.Server(net.rpcUrl, { allowHttp: net.name === "local" }) - console.log(`deploying to ${net.name} (${net.rpcUrl})`) - const accounts = freshAccounts() await fund(net, server, Object.values(accounts)) console.log(`funded ${Object.keys(accounts).length} accounts`) @@ -267,17 +266,9 @@ async function main(): Promise { const assetContract = await deployAssetContract(net, asset, accounts.issuer) console.log("asset contract", assetContract) - const contracts = await deployContracts(net, accounts, assetContract) + const contracts = await deployContracts(net, accounts, assetContract, wasms) - for (const name of CONTRACT_NAMES) { - await generateClient(wasmOf(name), clientDir(name)) - } - console.log("clients generated") - - await grantAndVerify(net, accounts, contracts) - await finishVaultSetup(net, accounts, contracts) - - const record: Deployment = { + return { network: net.name, deployedAt: new Date().toISOString(), ledger: (await server.getLatestLedger()).sequence, @@ -294,6 +285,51 @@ async function main(): Promise { ]), ) as Deployment["accounts"], } +} + +const keysOf = (record: Deployment): Accounts => + Object.fromEntries( + Object.entries(record.accounts).map(([name, key]) => [ + name, + Keypair.fromSecret(key.secret), + ]), + ) as Accounts + +// A full, wired vault set built from a different vault Wasm. It reuses the +// clients the main deploy generated, and nothing is written to disk. +export async function deployThrowaway( + net: Network, + vaultWasm: string, +): Promise { + const wasms = Object.fromEntries( + CONTRACT_NAMES.map((name) => [name, wasmOf(name)]), + ) as Record + wasms.async_vault = vaultWasm + const record = await freshSet(net, wasms) + const accounts = keysOf(record) + await grantAndVerify(net, accounts, record.contracts) + await finishVaultSetup(net, accounts, record.contracts) + return record +} + +async function main(): Promise { + requireWasm() + const net = network() + console.log(`deploying to ${net.name} (${net.rpcUrl})`) + const wasms = Object.fromEntries( + CONTRACT_NAMES.map((name) => [name, wasmOf(name)]), + ) as Record + const record = await freshSet(net, wasms) + + for (const name of CONTRACT_NAMES) { + await generateClient(wasmOf(name), clientDir(name)) + } + console.log("clients generated") + + const accounts = keysOf(record) + await grantAndVerify(net, accounts, record.contracts) + await finishVaultSetup(net, accounts, record.contracts) + saveDeployment(record) console.log(`wrote deployed.${net.name}.json`) if (net.name === "testnet") { @@ -301,7 +337,13 @@ async function main(): Promise { } } -main().catch((error: unknown) => { - console.error(error instanceof Error ? error.message : error) - process.exitCode = 1 -}) +const invokedDirectly = + process.argv[1] !== undefined && + import.meta.url === pathToFileURL(process.argv[1]).href + +if (invokedDirectly) { + main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 + }) +} diff --git a/scripts/harness/lib/clients.ts b/scripts/harness/lib/clients.ts index 203db0b0..d7e0963b 100644 --- a/scripts/harness/lib/clients.ts +++ b/scripts/harness/lib/clients.ts @@ -23,7 +23,10 @@ export interface Connected { export function connect(): Connected { const net = network() - const deployment = loadDeployment(net.name) + return connectTo(net, loadDeployment(net.name)) +} + +export function connectTo(net: Network, deployment: Deployment): Connected { const keys = Object.fromEntries( ACCOUNT_NAMES.map((name) => [ name, diff --git a/scripts/harness/package.json b/scripts/harness/package.json index 84ed1aa9..9ad6f363 100644 --- a/scripts/harness/package.json +++ b/scripts/harness/package.json @@ -12,6 +12,7 @@ "typecheck": "tsc --noEmit", "test:happy-path": "vitest run happy-path.test.ts", "test:guards": "vitest run guards.test.ts", + "test:wind-down": "vitest run wind-down.test.ts", "seed": "tsx seed.ts" }, "dependencies": { diff --git a/scripts/harness/wind-down.test.ts b/scripts/harness/wind-down.test.ts new file mode 100644 index 00000000..90dc3119 --- /dev/null +++ b/scripts/harness/wind-down.test.ts @@ -0,0 +1,264 @@ +import { beforeAll, describe, expect, it } from "vitest" + +import { SHORT_WIND_DOWN_DIR, deployThrowaway, wasmOf } from "./deploy.js" +import { pay, trustline } from "./lib/accounts.js" +import { units } from "./lib/amounts.js" +import { + connectTo, + identityVerifier, + send, + shareToken, + vault, + type Connected, +} from "./lib/clients.js" +import { network } from "./lib/network.js" +import { attest } from "./lib/oracle.js" +import { outcome } from "./lib/refused.js" + +const net = network() +if (net.name !== "local") { + throw new Error( + "the wind-down run needs the local network: it deploys a vault built without the wind-down minimum", + ) +} + +let c: Connected +let decimals = 7 +const usdc = (whole: number) => units(whole, decimals) +const called = new Set() + +type Signer = + "governance" | "manager" | "guardian" | "treasury" | "holder-1" | "outsider" + +type Call = (args: Record) => Promise<{ + simulation?: unknown + result: unknown + signAndSend(): Promise<{ result: unknown }> +}> + +// Simulates one vault call, and sends it when it would succeed and `submit` is +// set. Either way the binding has encoded the arguments and reached the +// contract. +async function call( + name: string, + as: Signer, + args: Record = {}, + submit = false, +): Promise { + const client = vault(c, c.keys[as]) + const method = (client as unknown as Record)[name] + if (method === undefined) throw new Error(`the vault client has no ${name}`) + const tx = await method.call(client, args) + called.add(name) + const result = outcome(tx, client) + if (submit && result === "accepted") await send(Promise.resolve(tx)) + return result +} + +beforeAll(async () => { + const deployment = await deployThrowaway( + net, + wasmOf("async_vault", SHORT_WIND_DOWN_DIR), + ) + c = connectTo(net, deployment) + decimals = (await shareToken(c, c.keys.outsider).decimals()).result + const holder = c.keys["holder-1"] + await trustline(c.net, c.server, holder, c.asset) + await trustline(c.net, c.server, c.keys.custodian, c.asset) + await pay(c.net, c.server, c.keys.issuer, holder.publicKey(), c.asset, "1000") + await send( + identityVerifier(c, c.keys.compliance).allow({ + account: holder.publicKey(), + allowed: true, + caller: c.keys.compliance.publicKey(), + }), + ) +}) + +describe("a wind-down on a throwaway vault", () => { + const holder = () => c.keys["holder-1"].publicKey() + + it("a holder subscribes, is priced and claims shares", async () => { + expect( + await call( + "request_deposit", + "holder-1", + { from: holder(), amount: usdc(200) }, + true, + ), + ).toBe("accepted") + expect( + await call( + "close_epoch", + "manager", + { caller: c.keys.manager.publicKey() }, + true, + ), + ).toBe("accepted") + await attest(c, 2n * 10n ** 18n) + expect( + await call("fulfill_epoch", "outsider", { epoch_id: 1n }, true), + ).toBe("accepted") + expect( + await call( + "claim_deposit", + "holder-1", + { caller: holder(), epoch_id: 1n }, + true, + ), + ).toBe("accepted") + }) + + it("the holder asks to redeem part of it", async () => { + expect( + await call( + "request_redeem", + "holder-1", + { from: holder(), shares: usdc(10) }, + true, + ), + ).toBe("accepted") + }) + + it("governance proposes the wind-down and anyone activates it", async () => { + const gov = c.keys.governance.publicKey() + expect( + await call("propose_wind_down", "governance", { caller: gov }, true), + ).toBe("accepted") + expect(await call("activate_wind_down", "outsider", {}, true)).toBe( + "accepted", + ) + }) + + it("anyone finalises a round and the holder claims", async () => { + expect(await call("finalize_wind_down_round", "outsider", {}, true)).toBe( + "accepted", + ) + expect( + await call("claim_wind_down", "holder-1", { holder: holder() }, true), + ).toBe("accepted") + expect( + (await vault(c, c.keys.outsider).wind_down_owed()).result, + ).toBeGreaterThanOrEqual(0n) + }) + + it("reads the economic supply after the claim", async () => { + await call("total_economic_supply", "outsider") + }) + + it("every other vault function answers through its binding", async () => { + const gov = c.keys.governance.publicKey() + const someone = c.keys.outsider.publicKey() + const views = [ + "asset", + "share_token", + "oracle", + "governance", + "manager", + "treasury", + "guardian", + "custodian", + "net_deployed", + "free_reserve", + "committed", + "uncovered", + "liquid_reserve", + "cancellable_escrow", + "pending_mint_shares", + "pending_burn_shares", + "deposit_cap", + "wind_down_delay", + "wind_down", + "wind_down_owed", + "wind_down_supply", + "wind_down_acc", + "notice", + "current_epoch", + "upgrade_delay", + "schema_version", + "pending_changes", + "setup_open", + "health", + "timelock_clock", + "get_admin", + "get_existing_roles", + "paused", + ] + for (const name of views) await call(name, "outsider") + await call("wind_down_claimable", "outsider", { holder: holder() }) + await call("get_epoch", "outsider", { epoch_id: 1n }) + await call("get_epoch_settlement", "outsider", { epoch_id: 1n }) + await call("get_deposit_request", "outsider", { + epoch_id: 1n, + controller: holder(), + }) + await call("get_redeem_request", "outsider", { + epoch_id: 2n, + controller: holder(), + }) + await call("has_role", "outsider", { account: gov, role: "manager" }) + await call("get_role_member_count", "outsider", { role: "manager" }) + await call("get_role_member", "outsider", { role: "manager", index: 0 }) + await call("get_role_admin", "outsider", { role: "manager" }) + + // Writes the wind-down or the timelock refuses: the call still has to + // encode and reach the contract. + const refusedWrites: Array<[string, Signer, Record]> = [ + ["set_deposit_cap", "governance", { cap: 1n, caller: gov }], + ["set_custodian", "governance", { custodian: someone, caller: gov }], + ["set_wind_down_delay", "governance", { secs: 1n, caller: gov }], + ["cancel_wind_down_proposal", "governance", { caller: gov }], + ["set_notice", "governance", { secs: 1n, caller: gov }], + ["finish_setup", "governance", { caller: gov }], + [ + "propose_change", + "governance", + { change: { tag: "Custodian", values: [someone] }, caller: gov }, + ], + ["cancel_change", "governance", { id: 1, caller: gov }], + ["apply_change", "governance", { id: 1, caller: gov }], + [ + "deploy_to_custodian", + "treasury", + { caller: c.keys.treasury.publicKey(), assets: 1n }, + ], + ["fund", "outsider", { from: someone, assets: 1n }], + ["cancel_deposit", "holder-1", { from: holder(), epoch_id: 1n }], + ["cancel_redeem", "holder-1", { from: holder(), epoch_id: 2n }], + ["claim_redeem", "holder-1", { caller: holder(), epoch_id: 2n }], + [ + "grant_role", + "governance", + { account: someone, role: "manager", caller: gov }, + ], + [ + "revoke_role", + "governance", + { account: someone, role: "manager", caller: gov }, + ], + ["renounce_role", "outsider", { role: "manager", caller: someone }], + [ + "transfer_admin_role", + "governance", + { new_admin: someone, live_until_ledger: 1 }, + ], + ["accept_admin_transfer", "outsider", {}], + [ + "set_role_admin", + "governance", + { role: "manager", admin_role: "guardian" }, + ], + ["renounce_admin", "governance", {}], + ["pause", "guardian", { caller: c.keys.guardian.publicKey() }], + ["unpause", "governance", { caller: gov }], + ] + for (const [name, as, args] of refusedWrites) await call(name, as, args) + + const spec = vault(c, c.keys.outsider).spec + const every = spec + .funcs() + .map((f) => f.name().toString()) + .filter((name) => name !== "__constructor") + expect(every.filter((name) => !called.has(name))).toEqual([]) + }) +}) From 37b050a0a88d23a73d806f5fd2296982853ac54f Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:34:08 -0300 Subject: [PATCH 051/147] fix(harness): assert payout, require vault wasm --- scripts/harness/deploy.ts | 7 +++++-- scripts/harness/wind-down.test.ts | 24 ++++++++++++++++++------ 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/scripts/harness/deploy.ts b/scripts/harness/deploy.ts index 1f17fcde..0b1492c1 100644 --- a/scripts/harness/deploy.ts +++ b/scripts/harness/deploy.ts @@ -44,8 +44,10 @@ const clientDir = (name: ContractName): string => const clientEntry = (name: ContractName): string => pathToFileURL(join(clientDir(name), "dist", "index.js")).href -function requireWasm(): void { - const missing = CONTRACT_NAMES.filter((name) => !existsSync(wasmOf(name))) +function requireWasm(vaultWasm: string = wasmOf("async_vault")): void { + const missing = CONTRACT_NAMES.filter( + (name) => !existsSync(name === "async_vault" ? vaultWasm : wasmOf(name)), + ) if (missing.length > 0) { throw new Error( `no wasm for ${missing.join(", ")}; run \`stellar contract build\` first`, @@ -301,6 +303,7 @@ export async function deployThrowaway( net: Network, vaultWasm: string, ): Promise { + requireWasm(vaultWasm) const wasms = Object.fromEntries( CONTRACT_NAMES.map((name) => [name, wasmOf(name)]), ) as Record diff --git a/scripts/harness/wind-down.test.ts b/scripts/harness/wind-down.test.ts index 90dc3119..66355822 100644 --- a/scripts/harness/wind-down.test.ts +++ b/scripts/harness/wind-down.test.ts @@ -8,6 +8,7 @@ import { identityVerifier, send, shareToken, + usdcBalance, vault, type Connected, } from "./lib/clients.js" @@ -134,12 +135,23 @@ describe("a wind-down on a throwaway vault", () => { expect(await call("finalize_wind_down_round", "outsider", {}, true)).toBe( "accepted", ) - expect( - await call("claim_wind_down", "holder-1", { holder: holder() }, true), - ).toBe("accepted") - expect( - (await vault(c, c.keys.outsider).wind_down_owed()).result, - ).toBeGreaterThanOrEqual(0n) + + const reader = vault(c, c.keys.outsider) + const owedBefore = (await reader.wind_down_owed()).result + const claimableBefore = ( + await reader.wind_down_claimable({ holder: holder() }) + ).result + const balanceBefore = await usdcBalance(c, holder()) + + called.add("claim_wind_down") + const paid = await send( + vault(c, c.keys["holder-1"]).claim_wind_down({ holder: holder() }), + ) + + expect(paid).toBeGreaterThan(0n) + expect(paid).toBe(claimableBefore) + expect(await usdcBalance(c, holder())).toBe(balanceBefore + paid) + expect((await reader.wind_down_owed()).result).toBe(owedBefore - paid) }) it("reads the economic supply after the claim", async () => { From 936922906180d1332cce6b2428db38d6bf403256 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:29:39 -0300 Subject: [PATCH 052/147] feat(token): co-sign legal transfers --- contracts/share-token/src/contract.rs | 68 ++++- contracts/share-token/src/error.rs | 10 + contracts/share-token/src/event.rs | 12 +- contracts/share-token/src/lib.rs | 4 +- contracts/share-token/src/roles.rs | 7 +- contracts/share-token/src/test.rs | 425 +++++++++++++++++++++++++- 6 files changed, 515 insertions(+), 11 deletions(-) diff --git a/contracts/share-token/src/contract.rs b/contracts/share-token/src/contract.rs index 29394bd6..d31aaa36 100644 --- a/contracts/share-token/src/contract.rs +++ b/contracts/share-token/src/contract.rs @@ -1,4 +1,5 @@ use crate::error::ShareTokenError; +use crate::event::LegalTransfer; use crate::governance::{self, TokenChange, TokenPendingChange}; use crate::roles::COMPLIANCE_ROLE; @@ -9,12 +10,30 @@ use soroban_sdk::{ use stellar_access::access_control::{self as access_control, AccessControl}; use stellar_contract_utils::pausable::{self as pausable, Pausable}; use stellar_contract_utils::upgradeable; -use stellar_macros::{only_admin, only_any_role, only_role}; +use stellar_macros::{only_admin, only_role}; use stellar_tokens::{ fungible::{Base, ContractOverrides, FungibleToken}, - rwa::{RWAToken, RWA}, + rwa::{identity_verification::IdentityVerifierClient, RWAToken, RWA}, }; +/// Longest order reference a legal transfer accepts, in bytes. +pub const MAX_REFERENCE_LEN: u32 = 128; + +/// The admin co-signs the call, and must be a different account from the +/// compliance authority that asks for it. +fn require_admin_co_sign(e: &Env, operator: &Address) { + let admin = access_control::enforce_admin_auth(e); + if admin == *operator { + panic_with_error!(e, ShareTokenError::CoSignRequired); + } +} + +fn refuse_vault_address(e: &Env, account: &Address) { + if timelock::setup::source(e).as_ref() == Some(account) { + panic_with_error!(e, ShareTokenError::VaultAddress); + } +} + #[contract] pub struct ShareToken; @@ -91,6 +110,36 @@ impl ShareToken { pub fn pending_changes(e: &Env) -> Vec { governance::pending(e) } + + /// Moves shares under a legal order, at once. The compliance authority + /// asks and the admin co-signs the same call. Never out of or into the + /// vault's address, and only to an account the identity verifier accepts. + #[only_role(operator, "compliance")] + pub fn legal_transfer( + e: &Env, + from: Address, + to: Address, + amount: i128, + reference: String, + operator: Address, + ) { + storage::bump_instance(e); + require_admin_co_sign(e, &operator); + if reference.is_empty() || reference.len() > MAX_REFERENCE_LEN { + panic_with_error!(e, ShareTokenError::InvalidReference); + } + refuse_vault_address(e, &from); + refuse_vault_address(e, &to); + IdentityVerifierClient::new(e, &RWA::identity_verifier(e)).verify_identity(&to); + RWA::forced_transfer(e, &from, &to, amount); + LegalTransfer { + from, + to, + amount, + reference, + } + .publish(e); + } } #[contractimpl(contracttrait)] @@ -201,12 +250,16 @@ impl RWAToken for ShareToken { storage::bump_instance(e); RWA::burn(e, &user_address, amount); } - /// The vault escrows redemptions through it; compliance intervenes with it. - #[only_any_role(operator, ["manager", "compliance"])] + /// The vault escrows redemptions through it. Compliance moves shares with + /// `legal_transfer` instead. + #[only_role(operator, "manager")] fn forced_transfer(e: &Env, from: Address, to: Address, amount: i128, operator: Address) { storage::bump_instance(e); RWA::forced_transfer(e, &from, &to, amount); } + /// Moves a whole balance to the account the identity verifier names, so + /// it needs the admin's co-signature like a legal transfer, and never + /// moves shares out of or into the vault's address. #[only_role(operator, "compliance")] fn recover_balance( e: &Env, @@ -215,16 +268,23 @@ impl RWAToken for ShareToken { operator: Address, ) -> bool { storage::bump_instance(e); + require_admin_co_sign(e, &operator); + refuse_vault_address(e, &old_account); + refuse_vault_address(e, &new_account); RWA::recover_balance(e, &old_account, &new_account) } #[only_role(operator, "compliance")] fn set_address_frozen(e: &Env, user_address: Address, freeze: bool, operator: Address) { storage::bump_instance(e); + if freeze { + refuse_vault_address(e, &user_address); + } RWA::set_address_frozen(e, &user_address, freeze); } #[only_role(operator, "compliance")] fn freeze_partial_tokens(e: &Env, user_address: Address, amount: i128, operator: Address) { storage::bump_instance(e); + refuse_vault_address(e, &user_address); RWA::freeze_partial_tokens(e, &user_address, amount); } #[only_role(operator, "compliance")] diff --git a/contracts/share-token/src/error.rs b/contracts/share-token/src/error.rs index b3b950be..31353a62 100644 --- a/contracts/share-token/src/error.rs +++ b/contracts/share-token/src/error.rs @@ -35,6 +35,16 @@ pub enum ShareTokenError { DelayBelowNotice = 7010, /// An upgrade already waits. Cancel it before proposing another. UpgradeAlreadyPending = 7011, + /// The vault's own address cannot be frozen, and a legal transfer or a + /// recovery never moves shares out of it or into it: its balance is other + /// investors' escrow. + VaultAddress = 7012, + /// A legal transfer or a recovery needs the compliance authority and the + /// admin as two different accounts, so no single key moves value. + CoSignRequired = 7013, + /// A legal transfer names the order behind it: a reference of 1 to 128 + /// bytes. + InvalidReference = 7015, /// 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. diff --git a/contracts/share-token/src/event.rs b/contracts/share-token/src/event.rs index 524c24ae..6a08551b 100644 --- a/contracts/share-token/src/event.rs +++ b/contracts/share-token/src/event.rs @@ -1,4 +1,4 @@ -use soroban_sdk::{contractevent, Address}; +use soroban_sdk::{contractevent, Address, String}; use crate::governance::TokenChange; @@ -31,3 +31,13 @@ pub struct VaultBound { #[topic] pub vault: Address, } + +#[contractevent] +pub struct LegalTransfer { + #[topic] + pub from: Address, + #[topic] + pub to: Address, + pub amount: i128, + pub reference: String, +} diff --git a/contracts/share-token/src/lib.rs b/contracts/share-token/src/lib.rs index 5d530e06..a4ac4d7f 100644 --- a/contracts/share-token/src/lib.rs +++ b/contracts/share-token/src/lib.rs @@ -6,7 +6,9 @@ mod governance; mod roles; pub use contract::*; pub use error::ShareTokenError; -pub use event::{ChangeApplied, ChangeCancelled, ChangeProposed, SetupFinished, VaultBound}; +pub use event::{ + ChangeApplied, ChangeCancelled, ChangeProposed, LegalTransfer, SetupFinished, VaultBound, +}; pub use governance::{TokenChange, TokenPendingChange, ADMIN_ACCEPT_LEDGERS}; #[cfg(test)] diff --git a/contracts/share-token/src/roles.rs b/contracts/share-token/src/roles.rs index af118c87..1893c792 100644 --- a/contracts/share-token/src/roles.rs +++ b/contracts/share-token/src/roles.rs @@ -1,8 +1,9 @@ use soroban_sdk::{symbol_short, Symbol}; -/// Role for allowlist-driven interventions: freeze, forced transfer, recovery. -/// Held by the compliance authority. Minting, burning and escrow use the -/// `manager` role, which governance grants to the vault. +/// Role for allowlist-driven interventions: freezes, plus legal transfers and +/// recovery with the admin's co-signature. Held by the compliance authority. +/// Minting, burning and escrow use the `manager` role, which governance grants +/// to the vault. pub const COMPLIANCE_ROLE: &str = "compliance"; /// The vault's role. Without it the vault cannot mint or burn, so no claim pays. diff --git a/contracts/share-token/src/test.rs b/contracts/share-token/src/test.rs index 947798ef..7d7b7c63 100644 --- a/contracts/share-token/src/test.rs +++ b/contracts/share-token/src/test.rs @@ -298,8 +298,14 @@ fn the_compliance_authority_freezes_and_moves_balances() { .unfreeze_partial_tokens(&holder, &400, &f.compliance_authority); assert_eq!(f.token.get_frozen_tokens(&holder), 0); - f.token - .forced_transfer(&holder, &target, &250, &f.compliance_authority); + identity.allow(&target, &true, &f.admin); + f.token.legal_transfer( + &holder, + &target, + &250, + &String::from_str(&e, "court-order-2026-117"), + &f.compliance_authority, + ); assert_eq!(f.token.balance(&target), 250); } @@ -510,3 +516,418 @@ fn compliance_interventions_restore_the_instance_ttl() { assert_eq!(instance_ttl(&e, &f.token.address), INSTANCE_EXTEND_AMOUNT); } + +#[test] +fn a_legal_transfer_restores_the_instance_ttl() { + let e = Env::default(); + let (f, holder, other) = decayed_live_token(&e); + + f.token.legal_transfer( + &holder, + &other, + &10, + &String::from_str(&e, "court-order-2026-117"), + &f.compliance_authority, + ); + + assert_eq!(instance_ttl(&e, &f.token.address), INSTANCE_EXTEND_AMOUNT); +} + +mod legal_transfer { + use soroban_sdk::{ + contract, contractimpl, contracttype, + testutils::Events as _, + xdr::{ScErrorCode, ScErrorType}, + Event as _, + }; + use stellar_tokens::rwa::identity_verification; + use timelock::{Clock, MIN_DELAY}; + + use super::*; + use crate::{LegalTransfer, ShareTokenError, MAX_REFERENCE_LEN}; + + /// Plays the vault: the token binds only to an address that reports a + /// clock. + #[contract] + struct VaultStub; + + #[contractimpl(contracttrait)] + impl timelock::ClockSource for VaultStub { + fn timelock_clock(_e: &Env) -> Clock { + Clock { + delay: MIN_DELAY, + exit_window: 0, + paused_secs: 0, + paused: false, + winding_down: false, + } + } + } + + fn order(e: &Env) -> String { + String::from_str(e, "court-order-2026-117") + } + + /// Real rules and verifier, a holder with 1,000 shares, an allowlisted + /// destination and a bound vault that holds 500 shares of escrow. + fn live(e: &Env) -> (Fixture<'_>, Address, Address, Address) { + let (f, identity) = setup_live(e); + let holder = Address::generate(e); + let destination = Address::generate(e); + let vault = e.register(VaultStub, ()); + for who in [&holder, &destination, &vault] { + identity.allow(who, &true, &f.admin); + } + f.token.mint(&holder, &1_000, &f.manager); + f.token.mint(&vault, &500, &f.manager); + f.token.bind_vault(&vault, &f.admin); + (f, holder, destination, vault) + } + + /// A missing signature fails in the host, not as a contract error. + fn missing_signature( + result: Result, Result>, + ) { + assert_eq!( + result.err(), + Some(Ok(soroban_sdk::Error::from_type_and_code( + ScErrorType::Context, + ScErrorCode::InvalidAction + ))), + ); + } + + #[test] + fn compliance_and_the_admin_move_shares_together() { + let e = Env::default(); + let (f, holder, destination, _) = live(&e); + + f.token.legal_transfer( + &holder, + &destination, + &250, + &order(&e), + &f.compliance_authority, + ); + + let events = e.events().all().filter_by_contract(&f.token.address); + assert_eq!( + events.events().last(), + Some( + &LegalTransfer { + from: holder.clone(), + to: destination.clone(), + amount: 250, + reference: order(&e), + } + .to_xdr(&e, &f.token.address) + ) + ); + assert_eq!(f.token.balance(&holder), 750); + assert_eq!(f.token.balance(&destination), 250); + } + + #[test] + fn compliance_alone_cannot_move_shares() { + let e = Env::default(); + let (f, holder, destination, _) = live(&e); + let invoke = MockAuthInvoke { + contract: &f.token.address, + fn_name: "legal_transfer", + args: ( + holder.clone(), + destination.clone(), + 250i128, + order(&e), + f.compliance_authority.clone(), + ) + .into_val(&e), + sub_invokes: &[], + }; + let transfer = || { + f.token.try_legal_transfer( + &holder, + &destination, + &250, + &order(&e), + &f.compliance_authority, + ) + }; + + e.mock_auths(&[MockAuth { + address: &f.compliance_authority, + invoke: &invoke, + }]); + missing_signature(transfer()); + assert_eq!(f.token.balance(&holder), 1_000); + + e.mock_auths(&[ + MockAuth { + address: &f.compliance_authority, + invoke: &invoke, + }, + MockAuth { + address: &f.admin, + invoke: &invoke, + }, + ]); + assert_eq!(transfer(), Ok(Ok(()))); + assert_eq!(f.token.balance(&destination), 250); + } + + #[test] + fn nothing_leaves_the_vault_address() { + let e = Env::default(); + let (f, _, destination, vault) = live(&e); + + assert_eq!( + f.token + .try_legal_transfer( + &vault, + &destination, + &100, + &order(&e), + &f.compliance_authority + ) + .err(), + Some(Ok(ShareTokenError::VaultAddress.into())), + ); + assert_eq!(f.token.balance(&vault), 500); + } + + #[test] + fn nothing_lands_on_the_vault_address() { + let e = Env::default(); + let (f, holder, _, vault) = live(&e); + + assert_eq!( + f.token + .try_legal_transfer(&holder, &vault, &100, &order(&e), &f.compliance_authority) + .err(), + Some(Ok(ShareTokenError::VaultAddress.into())), + ); + assert_eq!(f.token.balance(&holder), 1_000); + } + + #[test] + fn the_destination_must_pass_the_identity_check() { + let e = Env::default(); + let (f, holder, _, _) = live(&e); + let stranger = Address::generate(&e); + + assert_eq!( + f.token + .try_legal_transfer( + &holder, + &stranger, + &100, + &order(&e), + &f.compliance_authority + ) + .err(), + Some(Ok(RWAError::IdentityVerificationFailed.into())), + ); + assert_eq!(f.token.balance(&holder), 1_000); + } + + #[test] + fn the_order_reference_is_required_and_bounded() { + let e = Env::default(); + let (f, holder, destination, _) = live(&e); + let bytes = [b'x'; MAX_REFERENCE_LEN as usize + 1]; + let reference_of = |len: u32| String::from_bytes(&e, &bytes[..len as usize]); + + for reference in [reference_of(0), reference_of(MAX_REFERENCE_LEN + 1)] { + assert_eq!( + f.token + .try_legal_transfer( + &holder, + &destination, + &100, + &reference, + &f.compliance_authority + ) + .err(), + Some(Ok(ShareTokenError::InvalidReference.into())), + ); + } + + for reference in [reference_of(1), reference_of(MAX_REFERENCE_LEN)] { + f.token.legal_transfer( + &holder, + &destination, + &100, + &reference, + &f.compliance_authority, + ); + } + assert_eq!(f.token.balance(&destination), 200); + } + + #[test] + fn one_account_cannot_co_sign_with_itself() { + let e = Env::default(); + let (f, holder, destination, _) = live(&e); + f.token + .grant_role(&f.admin, &Symbol::new(&e, "compliance"), &f.admin); + + assert_eq!( + f.token + .try_legal_transfer(&holder, &destination, &100, &order(&e), &f.admin) + .err(), + Some(Ok(ShareTokenError::CoSignRequired.into())), + ); + assert_eq!(f.token.balance(&holder), 1_000); + } + + #[test] + fn compliance_no_longer_forces_transfers() { + let e = Env::default(); + let (f, holder, destination, _) = live(&e); + + assert_unauthorized(f.token.try_forced_transfer( + &holder, + &destination, + &100, + &f.compliance_authority, + )); + } + + /// Accepts every account and names one recovery target; the kit's + /// verifier names none, so no recovery could succeed with it. + #[contract] + struct RecoveringVerifier; + + #[contracttype] + enum RecoveryKey { + Target, + } + + #[contractimpl] + impl RecoveringVerifier { + pub fn set_target(e: Env, target: Address) { + e.storage().instance().set(&RecoveryKey::Target, &target); + } + } + + #[contractimpl(contracttrait)] + impl identity_verification::IdentityVerifier for RecoveringVerifier { + fn verify_identity(_e: &Env, _account: &Address) {} + + fn recovery_target(e: &Env, _old_account: &Address) -> Option
{ + e.storage().instance().get(&RecoveryKey::Target) + } + + fn set_claim_topics_and_issuers( + _e: &Env, + _claim_topics_and_issuers: Address, + _operator: Address, + ) { + } + } + + /// The live fixture's setup is still open, so the verifier swap is instant. + fn recovering_to(e: &Env, f: &Fixture, target: &Address) { + let verifier = RecoveringVerifierClient::new(e, &e.register(RecoveringVerifier, ())); + verifier.set_target(target); + f.token.set_identity_verifier(&verifier.address, &f.admin); + } + + #[test] + fn recovery_needs_the_admin_too() { + let e = Env::default(); + let (f, holder, destination, _) = live(&e); + recovering_to(&e, &f, &destination); + let invoke = MockAuthInvoke { + contract: &f.token.address, + fn_name: "recover_balance", + args: ( + holder.clone(), + destination.clone(), + f.compliance_authority.clone(), + ) + .into_val(&e), + sub_invokes: &[], + }; + + e.mock_auths(&[MockAuth { + address: &f.compliance_authority, + invoke: &invoke, + }]); + missing_signature(f.token.try_recover_balance( + &holder, + &destination, + &f.compliance_authority, + )); + assert_eq!(f.token.balance(&holder), 1_000); + + e.mock_auths(&[ + MockAuth { + address: &f.compliance_authority, + invoke: &invoke, + }, + MockAuth { + address: &f.admin, + invoke: &invoke, + }, + ]); + assert!(f + .token + .recover_balance(&holder, &destination, &f.compliance_authority)); + assert_eq!(f.token.balance(&holder), 0); + assert_eq!(f.token.balance(&destination), 1_000); + } + + #[test] + fn recovery_never_takes_the_vault_balance() { + let e = Env::default(); + let (f, _, destination, vault) = live(&e); + recovering_to(&e, &f, &destination); + + assert_eq!( + f.token + .try_recover_balance(&vault, &destination, &f.compliance_authority) + .err(), + Some(Ok(ShareTokenError::VaultAddress.into())), + ); + assert_eq!(f.token.balance(&vault), 500); + } + + #[test] + fn recovery_never_lands_on_the_vault_address() { + let e = Env::default(); + let (f, holder, _, vault) = live(&e); + recovering_to(&e, &f, &vault); + + assert_eq!( + f.token + .try_recover_balance(&holder, &vault, &f.compliance_authority) + .err(), + Some(Ok(ShareTokenError::VaultAddress.into())), + ); + assert_eq!(f.token.balance(&holder), 1_000); + } + + #[test] + fn the_vault_address_cannot_be_frozen() { + let e = Env::default(); + let (f, _, _, vault) = live(&e); + + assert_eq!( + f.token + .try_set_address_frozen(&vault, &true, &f.compliance_authority) + .err(), + Some(Ok(ShareTokenError::VaultAddress.into())), + ); + assert_eq!( + f.token + .try_freeze_partial_tokens(&vault, &100, &f.compliance_authority) + .err(), + Some(Ok(ShareTokenError::VaultAddress.into())), + ); + f.token + .set_address_frozen(&vault, &false, &f.compliance_authority); + assert!(!f.token.is_frozen(&vault)); + assert_eq!(f.token.get_frozen_tokens(&vault), 0); + } +} From c29ea4f65cc5b2f80df4356b0badedf9be6858e4 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:29:54 -0300 Subject: [PATCH 053/147] feat(vault): refuse setup with a frozen address --- contracts/async-vault/src/governance.rs | 3 +++ contracts/async-vault/src/health.rs | 2 ++ contracts/async-vault/src/test/health.rs | 8 ++++++++ contracts/async-vault/src/test/mod.rs | 5 +++++ contracts/async-vault/src/test/setup.rs | 2 ++ scripts/harness/deploy.ts | 1 + 6 files changed, 21 insertions(+) diff --git a/contracts/async-vault/src/governance.rs b/contracts/async-vault/src/governance.rs index df168453..b51473d1 100644 --- a/contracts/async-vault/src/governance.rs +++ b/contracts/async-vault/src/governance.rs @@ -68,6 +68,7 @@ pub(crate) struct Wiring { /// Shares minted before setup would redeem against depositors' money. pub no_shares_outstanding: bool, pub allowlisted: bool, + pub vault_unfrozen: bool, pub compliance_bound: bool, } @@ -78,6 +79,7 @@ impl Wiring { && self.sole_token_manager && self.no_shares_outstanding && self.allowlisted + && self.vault_unfrozen && self.compliance_bound } } @@ -168,6 +170,7 @@ pub(crate) fn wiring(e: &Env) -> Wiring { ), _ => false, }, + vault_unfrozen: matches!(rwa.try_is_frozen(&me), Ok(Ok(false))), compliance_bound: match rwa.try_compliance() { Ok(Ok(compliance)) => matches!( TokenBinderClient::new(e, &compliance).try_linked_tokens(), diff --git a/contracts/async-vault/src/health.rs b/contracts/async-vault/src/health.rs index c0768428..5b6ec76e 100644 --- a/contracts/async-vault/src/health.rs +++ b/contracts/async-vault/src/health.rs @@ -17,6 +17,7 @@ pub struct Health { pub oracle_bound: bool, pub sole_token_manager: bool, pub allowlisted: bool, + pub vault_unfrozen: bool, pub compliance_bound: bool, pub feed_valid: bool, /// A report outlives the notice, so an epoch's cancellation window does @@ -34,6 +35,7 @@ pub(crate) fn health(e: &Env) -> Health { oracle_bound: wiring.oracle_bound, sole_token_manager: wiring.sole_token_manager, allowlisted: wiring.allowlisted, + vault_unfrozen: wiring.vault_unfrozen, compliance_bound: wiring.compliance_bound, feed_valid: matches!(feed.try_state(), Ok(Ok(OracleState::Valid))), freshness_covers_notice: matches!( diff --git a/contracts/async-vault/src/test/health.rs b/contracts/async-vault/src/test/health.rs index 07bf6a4e..7a7ce66d 100644 --- a/contracts/async-vault/src/test/health.rs +++ b/contracts/async-vault/src/test/health.rs @@ -8,6 +8,7 @@ fn all_healthy() -> Health { oracle_bound: true, sole_token_manager: true, allowlisted: true, + vault_unfrozen: true, compliance_bound: true, feed_valid: true, freshness_covers_notice: true, @@ -74,6 +75,13 @@ fn each_missing_wiring_step_shows_in_its_own_flag() { ..all_healthy() }, ), + ( + Miswire::VaultFrozen, + Health { + vault_unfrozen: false, + ..all_healthy() + }, + ), ( Miswire::ComplianceUnbound, Health { diff --git a/contracts/async-vault/src/test/mod.rs b/contracts/async-vault/src/test/mod.rs index e2978d91..7e92e114 100644 --- a/contracts/async-vault/src/test/mod.rs +++ b/contracts/async-vault/src/test/mod.rs @@ -235,6 +235,7 @@ enum Miswire { SecondManager, PreMinted, NotAllowlisted, + VaultFrozen, ComplianceUnbound, } @@ -361,6 +362,10 @@ fn build<'a>( if miswire != Miswire::NotAllowlisted { identity.allow(&contract_id, &true, &compliance_authority); } + if miswire == Miswire::VaultFrozen { + // Until `bind_vault`, the token does not know this address is its vault. + share.set_address_frozen(&contract_id, &true, &compliance_authority); + } match miswire { Miswire::TokenUnbound => {} Miswire::TokenBoundElsewhere => { diff --git a/contracts/async-vault/src/test/setup.rs b/contracts/async-vault/src/test/setup.rs index 2e1c1617..2890fb70 100644 --- a/contracts/async-vault/src/test/setup.rs +++ b/contracts/async-vault/src/test/setup.rs @@ -60,6 +60,7 @@ fn every_wiring_step_is_checked_before_setup_finishes() { Miswire::SecondManager, Miswire::PreMinted, Miswire::NotAllowlisted, + Miswire::VaultFrozen, Miswire::ComplianceUnbound, ] { let f = miswired(miswire); @@ -131,6 +132,7 @@ fn a_token_that_cannot_answer_fails_its_checks_instead_of_trapping() { assert!(!health.token_bound); assert!(!health.sole_token_manager); assert!(!health.allowlisted); + assert!(!health.vault_unfrozen); assert!(!health.compliance_bound); refused(vault.try_finish_setup(&admin), VaultError::SetupIncomplete); diff --git a/scripts/harness/deploy.ts b/scripts/harness/deploy.ts index 0b1492c1..5398ad19 100644 --- a/scripts/harness/deploy.ts +++ b/scripts/harness/deploy.ts @@ -248,6 +248,7 @@ async function finishVaultSetup( "oracle_bound", "sole_token_manager", "allowlisted", + "vault_unfrozen", "compliance_bound", ] as const const broken = wiring.filter((flag) => !health[flag]) From 30d1abb3e1dc94bcc2f004fb092b31d23b2f11bb Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:30:08 -0300 Subject: [PATCH 054/147] docs: describe the co-signed legal transfer --- README.md | 2 +- docs/AUTHORIZATION_MATRIX.md | 19 +++++++------- docs/COMPLIANCE_MATRIX.md | 5 +++- docs/TESTING.md | 3 ++- docs/kit-spec.md | 21 ++++++++------- docs/strata-product-and-architecture.md | 34 +++++++++++++++---------- 6 files changed, 50 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index bbb36ca1..24a766b2 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ vault is allowlisted share_token bound to compliance nav_oracle bound to the vault, setup finished vault setup finished -health {"allowlisted":true,"compliance_bound":true,"feed_valid":false,"freshness_covers_notice":true,"oracle_bound":true,"setup_open":false,"sole_token_manager":true,"token_bound":true} +health {"allowlisted":true,"compliance_bound":true,"feed_valid":false,"freshness_covers_notice":true,"oracle_bound":true,"setup_open":false,"sole_token_manager":true,"token_bound":true,"vault_unfrozen":true} wrote deployed.local.json ``` diff --git a/docs/AUTHORIZATION_MATRIX.md b/docs/AUTHORIZATION_MATRIX.md index eff093f8..2dc1e757 100644 --- a/docs/AUTHORIZATION_MATRIX.md +++ b/docs/AUTHORIZATION_MATRIX.md @@ -10,12 +10,12 @@ This document provides the complete authorization and role model specification f 2. **Strict Caller Authentication**: No entrypoint takes a caller argument that it does not authenticate. Every function accepting a `caller: Address` executes `caller.require_auth()`. 3. **Exit guarantee, with one hold**: A covered redemption claim pays out cash regardless of pause, staleness or allowlist status. A delisted investor exits through the cash path and cannot re-enter share circulation. A frozen investor is held: the vault refuses their requests, cancellations and claims, and pays them only after the freeze lifts. 4. **Five Separate Authorities**: - - **Governance (Admin)**: Root authority; upgrades, timelock parameters, custodian settings, unpause, oracle parameters, emergency NAV pause reset. + - **Governance (Admin)**: Root authority; upgrades, timelock parameters, custodian settings, unpause, oracle parameters, emergency NAV pause reset. As the share token's admin, it co-signs compliance's `legal_transfer` and `recover_balance`. - **Manager (Vault)**: Closes epochs. - **Treasury**: Deploys free reserve assets to custodian. - **Guardian**: Emergency responder; pauses vault entries, pauses the oracle's NAV. - **Attester (Oracle)**: Submits off-chain valuation reports within strict deviation and rate bounds. - - **Compliance**: Writes the allowlist on the identity verifier and holds the share token's `compliance` role for freezes, forced transfers and recovery. Cannot mint, burn or swap the token's compliance or identity-verifier contract. + - **Compliance**: Writes the allowlist on the identity verifier and holds the share token's `compliance` role for freezes and partial freezes. It moves shares only with `legal_transfer` or `recover_balance`, each needing governance's co-signature in the same call, and never into or out of the vault's address. Cannot mint, burn, call `forced_transfer`, or swap the token's compliance or identity-verifier contract. --- @@ -28,7 +28,7 @@ The symbol `"manager"` names two completely distinct roles living on separate co | **`AsyncVault`** | `symbol_short!("manager")` | Operator Multisig / Automation Bot | Strictly calls `close_epoch` to freeze orders for pricing. Holds **no** token minting or burning power. | | **`ShareToken`** | `symbol_short!("manager")` | `AsyncVault` Contract Instance | Authorized to call `mint` (claim deposits), `burn` (claim redemptions) and `forced_transfer` (escrow redemptions). Holds no freeze or recovery power. | -The share token has a third role, `"compliance"`, held by the compliance authority: freezes, partial freezes, recovery and `forced_transfer`. `forced_transfer` is the one entrypoint both roles reach, because the vault escrows redemptions with it. +The share token has a third role, `"compliance"`, held by the compliance authority: freezes and partial freezes, plus `legal_transfer` and `recover_balance` with governance's co-signature. Only `manager` reaches `forced_transfer`, which the vault uses to escrow redemptions. ### Deployment Script Safety In `scripts/harness/deploy.ts` and automated deployment scripts, the separation is enforced structurally: @@ -38,7 +38,7 @@ In `scripts/harness/deploy.ts` and automated deployment scripts, the separation - After granting, the script checks `token.has_role({ account: vault, role: "manager" })` and stops if the vault does not hold the role. That no other account holds it is checked by the vault's `finish_setup` below. - 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). - 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). -- Last, it calls `vault.set_custodian({ custodian, caller: governance })` and `vault.finish_setup({ caller: governance })`, and stops if `vault.setup_open()` still reads true. `finish_setup` refuses with `SetupIncomplete` unless the token and the oracle report this vault with their setup closed, the vault is the token's only `manager`, the token has no shares outstanding, the vault passes the identity check and the token is bound to its compliance contract. From then on the vault takes deposits and its admin changes wait for its timelock (§5.1). +- Last, it calls `vault.set_custodian({ custodian, caller: governance })` and `vault.finish_setup({ caller: governance })`, and stops if `vault.setup_open()` still reads true. `finish_setup` refuses with `SetupIncomplete` unless the token and the oracle report this vault with their setup closed, the vault is the token's only `manager`, the token has no shares outstanding, the vault passes the identity check, its address is not frozen on the token and the token is bound to its compliance contract. From then on the vault takes deposits and its admin changes wait for its timelock (§5.1). --- @@ -90,7 +90,7 @@ Tests: [`controls.rs`](../contracts/async-vault/src/test/controls.rs), [`epochs. | `set_notice` | `#[only_admin]`, `caller.require_auth()` | `secs <= MAX_NOTICE_SECS`, `secs <= upgrade_delay`. Raising is instant; lowering is `UseTimelock` after setup | `test::governance::raising_the_notice_is_instant_and_lowering_waits`, `test::notice::setting_the_notice_needs_governance_authorisation` | | `set_wind_down_delay` | `#[only_admin]`, `caller.require_auth()` | `MIN_WIND_DOWN_DELAY <= secs <= MAX_WIND_DOWN_DELAY`. Raising is instant; lowering is `UseTimelock` after setup | `test::governance::raising_the_wind_down_delay_is_instant_and_lowering_waits`, `test::governance::a_wind_down_delay_below_the_minimum_cannot_be_proposed`, `test::wind_down::only_governance_sets_the_delay` | | `set_deposit_cap` | `#[only_admin]`, `caller.require_auth()` | Not negative. Instant both ways, also after setup | `test::governance::the_deposit_cap_stays_instant_both_ways` | -| `finish_setup` | `#[only_admin]`, `caller.require_auth()` | Once. The token and the oracle report this vault with their setup closed, the vault is the token's only `manager`, the token's `total_supply` is zero, the vault passes `verify_identity`, and the token is in its compliance contract's `linked_tokens`; no admin hand-over offered during setup is pending | `test::setup::every_wiring_step_is_checked_before_setup_finishes`, `test::setup::setup_cannot_finish_while_a_hand_over_is_pending`, `test::setup::setup_never_finishes_twice`, `test::setup::only_governance_finishes_setup` | +| `finish_setup` | `#[only_admin]`, `caller.require_auth()` | Once. The token and the oracle report this vault with their setup closed, the vault is the token's only `manager`, the token's `total_supply` is zero, the vault passes `verify_identity`, the token's `is_frozen` is false for the vault, and the token is in its compliance contract's `linked_tokens`; no admin hand-over offered during setup is pending | `test::setup::every_wiring_step_is_checked_before_setup_finishes`, `test::setup::setup_cannot_finish_while_a_hand_over_is_pending`, `test::setup::setup_never_finishes_twice`, `test::setup::only_governance_finishes_setup` | | `grant_role` / `transfer_admin_role` | Admin | Instant during setup, `UseTimelock` after. A zero `live_until_ledger` cancels instantly | `test::governance::grants_and_manager_revokes_wait_for_the_timelock`, `test::governance::an_admin_hand_over_waits_but_cancelling_one_does_not` | | `revoke_role` | Admin | `treasury`, `guardian`: instant. `manager`: instant during setup, `UseTimelock` after | `test::governance::grants_and_manager_revokes_wait_for_the_timelock` (instant case: `test::governance::revoking_treasury_or_guardian_stays_instant`; caller check: `test::governance::only_the_admin_revokes_a_role`) | | `set_role_admin` | — | Always refused (`UseTimelock`) | `test::governance::no_role_ever_gets_a_role_admin` | @@ -116,11 +116,12 @@ Tests: [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.r | `unpause` | `#[only_admin]`, `caller.require_auth()` | Paused | `test::unauthorized_caller_cannot_pause_or_unpause` | | `mint` | `#[only_role(operator, "manager")]` | Receiver allowlisted | `test::unauthorized_caller_cannot_mint`, `test::the_compliance_authority_cannot_mint_burn_or_swap_the_rules` | | `burn` | `#[only_role(operator, "manager")]` | Balance sufficient | `test::unauthorized_caller_cannot_burn` | -| `forced_transfer` | `#[only_any_role(operator, ["manager", "compliance"])]` | Balance sufficient | `test::unauthorized_caller_cannot_forced_transfer` | -| `set_address_frozen` | `#[only_role(operator, "compliance")]` | None | `test::unauthorized_caller_cannot_freeze_or_recover`, `test::the_manager_escrows_but_does_not_intervene` | -| `freeze_partial_tokens` | `#[only_role(operator, "compliance")]` | Balance sufficient | `test::unauthorized_caller_cannot_freeze_or_recover`, `test::the_manager_escrows_but_does_not_intervene` | +| `forced_transfer` | `#[only_role(operator, "manager")]` | Balance sufficient | `test::unauthorized_caller_cannot_forced_transfer`, `test::legal_transfer::compliance_no_longer_forces_transfers` | +| `legal_transfer` | `#[only_role(operator, "compliance")]` and the admin's `require_auth()` in the same call, from two different accounts (`CoSignRequired`) | Reference of 1–128 bytes (`InvalidReference`); never from or to the vault's address (`VaultAddress`); destination passes `verify_identity`; balance sufficient | `test::legal_transfer::*` | +| `set_address_frozen` | `#[only_role(operator, "compliance")]` | Never freezes the vault's address (`VaultAddress`); unfreezing it stays possible | `test::unauthorized_caller_cannot_freeze_or_recover`, `test::the_manager_escrows_but_does_not_intervene`, `test::legal_transfer::the_vault_address_cannot_be_frozen` | +| `freeze_partial_tokens` | `#[only_role(operator, "compliance")]` | Balance sufficient; never the vault's address (`VaultAddress`) | `test::unauthorized_caller_cannot_freeze_or_recover`, `test::the_manager_escrows_but_does_not_intervene`, `test::legal_transfer::the_vault_address_cannot_be_frozen` | | `unfreeze_partial_tokens`| `#[only_role(operator, "compliance")]` | Frozen amount sufficient | `test::unauthorized_caller_cannot_freeze_or_recover`, `test::the_manager_escrows_but_does_not_intervene` | -| `recover_balance` | `#[only_role(operator, "compliance")]` | Target allowlisted and named by the verifier's `recovery_target` (the testnet stub names none, so recovery always fails there) | `test::unauthorized_caller_cannot_freeze_or_recover`, `test::the_compliance_authority_reaches_recovery` | +| `recover_balance` | `#[only_role(operator, "compliance")]` and the admin's `require_auth()` in the same call, from two different accounts (`CoSignRequired`) | Never from or to the vault's address (`VaultAddress`); target allowlisted and named by the verifier's `recovery_target` (the testnet stub names none, so recovery always fails there) | `test::unauthorized_caller_cannot_freeze_or_recover`, `test::the_compliance_authority_reaches_recovery`, `test::legal_transfer::recovery_needs_the_admin_too`, `test::legal_transfer::recovery_never_takes_the_vault_balance`, `test::legal_transfer::recovery_never_lands_on_the_vault_address` | | `set_compliance` | `#[only_admin]`, `operator.require_auth()` | Instant during setup; `UseTimelock` after | `test::unauthorized_caller_cannot_set_compliance_or_verifier`, `test::the_compliance_authority_cannot_mint_burn_or_swap_the_rules`, `test_governance::rule_and_verifier_swaps_wait_after_setup` | | `set_identity_verifier` | `#[only_admin]`, `operator.require_auth()` | Instant during setup; `UseTimelock` after | `test::unauthorized_caller_cannot_set_compliance_or_verifier`, `test::the_compliance_authority_cannot_mint_burn_or_swap_the_rules`, `test_governance::rule_and_verifier_swaps_wait_after_setup` | | `renounce_admin` | Refused always (`ShareTokenError::AdminRequired`) | None | `test::the_admin_cannot_renounce_itself_out_of_the_token`; `test::admin_handover_in_two_steps_still_works`; `test_governance::an_applied_admin_change_starts_a_hand_over_the_next_admin_accepts`. A token without an admin could never be unpaused or rotate its manager; hand over by proposing and applying an `Admin` change (`transfer_admin_role` during setup), then the nominee calls `accept_admin_transfer`. | diff --git a/docs/COMPLIANCE_MATRIX.md b/docs/COMPLIANCE_MATRIX.md index 02e8cd9d..2be50f78 100644 --- a/docs/COMPLIANCE_MATRIX.md +++ b/docs/COMPLIANCE_MATRIX.md @@ -21,7 +21,7 @@ Every investor account in the vault ecosystem exists in one of three primary com 3. **Frozen (`Frozen`)**: - The account is subject to sanctions, legal freezing, or security hold (`is_frozen(account) == true` via `ShareToken::set_address_frozen`). - The share token refuses a `transfer` from or to a frozen address. Minting does not check freezes, and `forced_transfer` ignores them. - - What the vault owes a frozen investor is **held**: not paid, not returned, not moved. Priced cash stays in `committed` and a wind-down slice stays in `wind_down_owed`, where no other holder can take it. Only an unfreeze releases it. A partial freeze limits exits to the free balance. + - What the vault owes a frozen investor is **held**: not paid, not returned, not moved. Priced cash stays in `committed` and a wind-down slice stays in `wind_down_owed`, where no other holder can take it. Only an unfreeze releases it. A legal-order transfer (`legal_transfer`), which compliance and governance sign together, can move shares in the investor's own wallet, but not value the vault holds for them: deposit escrow, priced but unminted shares, shares escrowed in a redemption, priced cash, or a wind-down slice. A partial freeze limits exits to the free balance. This follows how asset-freeze and blocking rules generally treat frozen property: held, not released, not returned to the owner and not confiscated, with the entitlement kept and recorded. It is not legal advice; counsel must confirm it for each jurisdiction. @@ -39,6 +39,7 @@ Every investor account in the vault ecosystem exists in one of three primary com | **`claim_redeem`** | ✅ Allowed | ✅ **Allowed** | ❌ **Refused** (`Frozen`); the cash stays in `committed` | **Exit-Only Cash Path**: the settlement asset pays out from the liquid reserve and the escrowed shares are burned from the vault. The cash claim checks `is_frozen` on the share token first; it does not check the allowlist. A partial freeze is not checked. | | **`claim_wind_down`** | ✅ Allowed | ✅ **Allowed** | ❌ **Refused** (`Frozen`); the slice stays in `wind_down_owed` | **Wind-Down Payout**: pays the holder's pro-rata entitlement and burns the free shares it surrenders. The vault checks `is_frozen` and `get_frozen_tokens` on the share token first; a partial freeze surrenders only the free shares — the rest keeps earning every round and is paid once unfrozen. | | **`transfer`** (Secondary) | ✅ Allowed | ❌ **Refused** | ❌ **Refused** | Standard peer-to-peer share transfer via SEP-57. Requires both sender and receiver to be allowlisted and neither to be frozen. | +| **`legal_transfer`** (compliance + governance) | ✅ Allowed | ✅ Allowed | ✅ Allowed | Moves shares in the holder's wallet under a legal order; the destination must pass `verify_identity`; never into or out of the vault. It cannot reach value the vault holds for a frozen holder; only an unfreeze releases that. | --- @@ -92,6 +93,7 @@ The share token is OpenZeppelin's RWA token. The allowlist and freeze checks sit | `transfer` | Refuses if sender or receiver is frozen; the sender needs enough unfrozen balance | Sender and receiver | `can_transfer` before, `transferred` after | | `mint` | None | Receiver | `can_create` before, `created` after | | `forced_transfer` | None; releases as much of a partial freeze as the amount needs | None | `transferred` after | +| `legal_transfer` (the kit's own, built on `forced_transfer`) | None; releases as much of a partial freeze as the amount needs | Receiver | `transferred` after | | `burn` | None; releases as much of a partial freeze as the amount needs | None | `destroyed` after | 0.7.2 has five compliance hooks: `can_transfer` and `can_create` are asked before the state change, and `transferred`, `created` and `destroyed` are told after it. There is no `can_destroy`. @@ -126,3 +128,4 @@ The behavior specified in this matrix is verified by on-chain automated test sui * `a_frozen_holders_slice_stays_owed_until_the_freeze_lifts` ([`test/wind_down.rs`](../contracts/async-vault/src/test/wind_down.rs)): Verifies a frozen holder's wind-down slice stays in `wind_down_owed` until the freeze lifts. * `a_frozen_holders_escrowed_exit_waits_through_a_wind_down`: Verifies a frozen holder's escrowed redemption and wind-down claim are both held through a wind-down. * `a_partial_freeze_keeps_frozen_shares_out_of_a_wind_down_claim`: Verifies a partial freeze excludes frozen shares from a wind-down surrender. +* `legal_transfer::*` ([`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.rs)): Verifies a legal-order transfer needs compliance and governance as two different accounts, never moves shares out of or into the vault's address, lands only on an allowlisted account and publishes its order reference; recovery needs the same co-signature, and the vault's address cannot be frozen. diff --git a/docs/TESTING.md b/docs/TESTING.md index 8c0709ed..c10d67e3 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -78,6 +78,7 @@ nothing. | Guardian, governance and upgrade powers stay with their role | [`contracts/async-vault/src/test/controls.rs`](../contracts/async-vault/src/test/controls.rs), [`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) | `the_guardian_pauses_but_only_governance_unpauses`, `only_governance_proposes_and_cancels`, `only_governance_applies`, `only_governance_sets_the_delay` | | Roles that must stay separate are refused at deployment | [`contracts/async-vault/src/test/constructor.rs`](../contracts/async-vault/src/test/constructor.rs) | `the_treasury_may_not_also_be_the_guardian`, `compliance_may_not_also_be_governance` | | 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: compliance moves a holder's shares only with the admin's signature in the same call, never out of or into the vault's address, and cannot freeze the vault | [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.rs) | `compliance_alone_cannot_move_shares`, `one_account_cannot_co_sign_with_itself`, `nothing_leaves_the_vault_address`, `recovery_needs_the_admin_too`, `the_vault_address_cannot_be_frozen` | | 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` | @@ -124,7 +125,7 @@ fixture wires them as the deploy does and finishes the vault's setup; | The vault mints and burns shares through its role on the share token | [`contracts/async-vault/src/test/constructor.rs`](../contracts/async-vault/src/test/constructor.rs), [`contracts/async-vault/src/test/redeem.rs`](../contracts/async-vault/src/test/redeem.rs) | `the_vault_holds_the_manager_role_on_the_share_token`, `claim_redeem_pays_assets_and_burns_the_shares` | | 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` | -| The vault finishes setup only once the token and the oracle report it with their own setup closed, it is the token's only `manager`, the token has no shares outstanding, it passes the identity check and the token is bound to compliance; before that it takes no deposit | [`contracts/async-vault/src/test/setup.rs`](../contracts/async-vault/src/test/setup.rs) | `every_wiring_step_is_checked_before_setup_finishes`, `a_deposit_before_setup_finishes_is_refused`, `setup_cannot_finish_while_a_hand_over_is_pending` | +| The vault finishes setup only once the token and the oracle report it with their own setup closed, it is the token's only `manager`, the token has no shares outstanding, it passes the identity check, its address is not frozen and the token is bound to compliance; before that it takes no deposit | [`contracts/async-vault/src/test/setup.rs`](../contracts/async-vault/src/test/setup.rs) | `every_wiring_step_is_checked_before_setup_finishes`, `a_deposit_before_setup_finishes_is_refused`, `setup_cannot_finish_while_a_hand_over_is_pending` | | 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; 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 b7e46b91..1d2dd679 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -85,7 +85,7 @@ The contracts enforce a set of mathematical and state-machine invariants across - **Testable Property**: - Upgrade delay $\in [7 \text{ days}, 90 \text{ days}]$ and $\text{delay} \ge \text{notice}$, checked when a delay change is proposed and again when it applies. - Wind-down delay $\in [7 \text{ days}, 90 \text{ days}]$ (`WindDownDelayTooShort`, `WindDownDelayTooLong`); 7 days when none was set. - - The vault takes no deposit until its one-time setup closes (`SetupOpen`). `finish_setup` refuses with `SetupIncomplete` until the share token and the oracle report this vault with their own setup closed, the vault is the token's only `manager`, the token has no shares outstanding, the vault passes the token's identity check, and the token is bound to its compliance contract. Shares minted before setup closes would redeem against depositors' money. It refuses with `AdminTransferPending` while an admin hand-over offered during setup is pending. Setup never reopens (`SetupClosed`). + - The vault takes no deposit until its one-time setup closes (`SetupOpen`). `finish_setup` refuses with `SetupIncomplete` until the share token and the oracle report this vault with their own setup closed, the vault is the token's only `manager`, the token has no shares outstanding, the vault passes the token's identity check, the token has not frozen the vault's address, and the token is bound to its compliance contract. Shares minted before setup closes would redeem against depositors' money. It refuses with `AdminTransferPending` while an admin hand-over offered during setup is pending. Setup never reopens (`SetupClosed`). - After setup, the vault's custodian, role grants, revoking `manager`, admin hand-overs and lowering the notice or the wind-down delay go through the queue; a direct call is refused with `UseTimelock`. Raising the notice or the wind-down delay, the deposit cap, `pause`/`unpause` and revoking any other role stay instant. A `Notice` or `WindDownDelay` change is checked against its bounds when proposed and again when it applies. - Every change waits in the queue (`propose_change` → `apply_change`). Applying before its eta reverts with `ChangeNotReady`. - A change whose delay is shorter than the notice standing at apply time reverts with `UpgradeDelayBelowNotice`. @@ -120,7 +120,7 @@ Deploying the Strata Vault Kit involves five contracts configured via `environme | **`share_token`** | `name` | `String` | Human-readable token name (e.g. `"Strata Vault USDC"`) | `share_token.constructor_args` | | **`share_token`** | `symbol` | `String` | Token ticker symbol (e.g. `"bvUSDC"`) | `share_token.constructor_args` | | **`share_token`** | `admin` | `Address` | Token administrative multisig (Governance) | `share_token.constructor_args` | -| **`share_token`** | `compliance_authority` | `Address` | Compliance multisig; receives the `compliance` role for freezes, forced transfers and recovery. The vault's `manager` role is granted after deploy | `share_token.constructor_args` | +| **`share_token`** | `compliance_authority` | `Address` | Compliance multisig; receives the `compliance` role for freezes, plus legal transfers and recovery with governance's co-signature. The vault's `manager` role is granted after deploy | `share_token.constructor_args` | | **`share_token`** | `compliance` | `Address` | Address of deployed `compliance` contract | `share_token.constructor_args` | | **`share_token`** | `identity_verifier` | `Address` | Address of deployed `identity_verifier` contract | `share_token.constructor_args` | | **`nav_oracle`** | `admin` | `Address` | Oracle admin multisig (Governance) | `nav_oracle.constructor_args` | @@ -174,6 +174,7 @@ The `async_vault` constructor strictly verifies pairwise separation across autho | `oracle_bound` | The oracle names this vault through `vault()` and its own setup is finished. | | `sole_token_manager` | This vault is the share token's only `manager`. | | `allowlisted` | This vault passes the share token's identity verifier, so it can hold escrowed shares. A delisted holder's deposit converts to an exit by minting the shares to the vault's own address, so that conversion needs this flag true. | +| `vault_unfrozen` | The share token has not frozen this vault's address. A frozen vault could not hand escrowed shares back when a holder cancels a redemption. | | `compliance_bound` | The share token is in its compliance contract's `linked_tokens`. | | `feed_valid` | The oracle reports `Valid`: a report exists, it has not expired and the NAV is not paused. | | `freshness_covers_notice` | The oracle's `freshness_duration` is at least the vault's notice. | @@ -184,17 +185,17 @@ The `async_vault` constructor strictly verifies pairwise separation across autho ## 3. Role & Authority Matrix -Strata distributes control across five native Stellar multisig authorities and permissionless public entrypoints. Every privileged function is mapped to exactly one authority. +Strata distributes control across five native Stellar multisig authorities and permissionless public entrypoints. Every privileged function is mapped to exactly one authority, except the share token's `legal_transfer` and `recover_balance`, which need compliance and governance in the same call. ### Authority Capabilities & Anti-Capabilities | Authority | Quorum / Profile | Authorized Capabilities | Anti-Capabilities (What It Cannot Do) | |---|---|---|---| -| **Governance** | High-quorum deliberative multisig | Set parameters (`notice`, `custodian`, `deposit_cap`, `wind_down_delay`), propose/apply upgrades, propose/cancel wind-down, unpause the vault, pause/unpause the share token, transfer admin, finish each contract's one-time setup. After setup, these wait for the timelock: role grants, revoking the manager, admin hand-over, the custodian, lowering the notice or the wind-down delay, upgrades, the token's compliance and verifier swaps, and oracle config changes other than a shorter freshness window. Revoking the treasury or the guardian stays instant, because taking a power away moves no value. | **Cannot pause the vault directly**; cannot move funds; cannot initiate custodian transfers; cannot attest prices; cannot bypass timelocks. | +| **Governance** | High-quorum deliberative multisig | Set parameters (`notice`, `custodian`, `deposit_cap`, `wind_down_delay`), propose/apply upgrades, propose/cancel wind-down, unpause the vault, pause/unpause the share token, transfer admin, finish each contract's one-time setup, co-sign compliance's `legal_transfer` and `recover_balance` as the share token's admin. After setup, these wait for the timelock: role grants, revoking the manager, admin hand-over, the custodian, lowering the notice or the wind-down delay, upgrades, the token's compliance and verifier swaps, and oracle config changes other than a shorter freshness window. Revoking the treasury or the guardian stays instant, because taking a power away moves no value. | **Cannot pause the vault directly**; cannot move funds; cannot initiate custodian transfers; cannot attest prices; cannot bypass timelocks. | | **Guardian** | Low-threshold fast-reaction multisig | `pause` (vault); `pause_nav` (oracle emergency price freeze). | **Cannot unpause**; cannot move funds; cannot block redemption requests; cannot block payable claims or cancellations. | | **Treasury** | Operational capital multisig | `deploy_to_custodian` (moves free reserve); `fund` (returns assets from custodian). | **Cannot deploy while anything is uncovered**; cannot touch escrowed subscription assets; cannot alter parameters or pause. | | **Attester** | High-security attestation multisig | `attest` (submits NAV report with proof reference). | **Cannot price outside deviation band**; cannot violate cooldown; cannot operate on the vault directly. | -| **Compliance** | Legal / KYC operator multisig | Manage allowlist (`allow`), topic claims; token interventions (`freeze`, `forced_transfer`, `recovery`). | **Cannot touch vault reserve**; cannot pause vault operations; cannot bypass exit-only cash redemptions for an unfrozen holder — freezing holds a covered claim until it lifts, rather than paying or redirecting it. | +| **Compliance** | Legal / KYC operator multisig | Manage allowlist (`allow`), topic claims; token interventions (`freeze`; `legal_transfer` and `recovery` co-signed by governance). | **Cannot touch vault reserve**; cannot move a holder's shares without governance's signature in the same call; cannot move shares out of or into the vault's address, or freeze it; cannot pause vault operations; cannot bypass exit-only cash redemptions for an unfrozen holder — freezing holds a covered claim until it lifts, rather than paying or redirecting it. | | **Manager** | Operational keeper account / vault | `close_epoch` (vault); `mint`, `burn`, `forced_transfer` (share token). | **Cannot choose prices**; cannot prevent epoch fulfillment; cannot alter timelocks. | | **Permissionless** | Any caller / keeper / investor | `fulfill_epoch`, `request_deposit`, `cancel_deposit`, `claim_deposit`, `request_redeem`, `cancel_redeem`, `claim_redeem`, `activate_wind_down`, `finalize_wind_down_round`, `claim_wind_down`. | Operations are strictly constrained by contract state, timing, and solvency guards. | @@ -253,8 +254,10 @@ Strata distributes control across five native Stellar multisig authorities and p | **`ShareToken`** | `unpause` | Admin (Governance) | `#[only_admin]`, `caller.require_auth()` | N/A | | **`ShareToken`** | `mint` | Manager (Vault) | `#[only_role(operator, "manager")]` | Bypasses token pause | | **`ShareToken`** | `burn` | Manager (Vault) | `#[only_role(operator, "manager")]` | Bypasses token pause | -| **`ShareToken`** | `forced_transfer` | Manager (Vault) or Compliance | `#[only_any_role(operator, ["manager", "compliance"])]` | Bypasses token pause | -| **`ShareToken`** | `set_address_frozen`, `freeze_partial_tokens`, `unfreeze_partial_tokens`, `recover_balance` | Compliance | `#[only_role(operator, "compliance")]` | No | +| **`ShareToken`** | `forced_transfer` | Manager (Vault) | `#[only_role(operator, "manager")]` | Bypasses token pause | +| **`ShareToken`** | `legal_transfer` | Compliance, co-signed by Governance (token admin) | `#[only_role(operator, "compliance")]` and the admin's `require_auth()` in the same call, from two different accounts; never from or to the vault's address; the destination passes `verify_identity`; a reference of 1–128 bytes | Bypasses token pause | +| **`ShareToken`** | `set_address_frozen`, `freeze_partial_tokens`, `unfreeze_partial_tokens` | Compliance | `#[only_role(operator, "compliance")]`; never freezes the vault's address | No | +| **`ShareToken`** | `recover_balance` | Compliance, co-signed by Governance (token admin) | `#[only_role(operator, "compliance")]` and the admin's `require_auth()` in the same call, from two different accounts; never from or to the vault's address | No | | **`ShareToken`** | `set_compliance`, `set_identity_verifier` | Governance | `#[only_admin]`; instant during setup, then `propose_change` | No | | **`ShareToken`** | `bind_vault` | Governance (token admin) | `#[only_admin]`; during setup, once | No | | **`ShareToken`** | `finish_setup` | Governance | `#[only_admin]`; needs a bound vault and no pending admin hand-over | No | @@ -283,7 +286,7 @@ A clean deployment executes in three sequenced phases: - 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. - On the vault, call `set_custodian(custodian)`, `set_notice(secs)` and, if initial capital intake is bounded, `set_deposit_cap(cap)`. All three are instant during setup, so deposits never open without the cap. - - Then call `finish_setup()` on the vault. It refuses with `SetupIncomplete` until every wiring step above is in place and the token has no shares outstanding, and with `AdminTransferPending` while an admin hand-over offered during setup is pending. The vault takes no deposit before it. + - Then call `finish_setup()` on the vault. It refuses with `SetupIncomplete` until every wiring step above is in place, the token has no shares outstanding and the vault's address is not frozen on it, and with `AdminTransferPending` while an admin hand-over offered during setup is pending. The vault takes no deposit before it. 3. **Phase 3: Operational Parameterization** - Oracle reporter submits the genesis NAV attestation (`attest`). @@ -299,7 +302,7 @@ instance is archived refuses every call. |---|---|---|---| | `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, 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 | +| `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`, `legal_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 | diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index 014ff84f..ef15fd12 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -79,7 +79,7 @@ or legal solution, and not a vault for on-chain RWA tokens. | Valuation reporter | attestation | Attests the share price with a proof reference, under on-chain guardrails | | Treasury ops | treasury | Moves free reserve between the vault and the custodian | | Epoch sealer | manager (vault role, not an authority) | Seals the open epoch (`close_epoch`), nothing else; a vault role with its own key at deploy, meant to become permissionless (§8.4) | -| Compliance | compliance | Maintains the allowlist; token interventions (freeze, forced transfer, recovery) | +| Compliance | compliance | Maintains the allowlist; token interventions (freeze; legal-order transfer and recovery, each co-signed by governance) | | Guardian | guardian | Pauses the vault, which stops deposit requests, epoch pricing and upgrade application and freezes the upgrade timelock; can never block redemption requests, payable claims, or custodian deployments | | Governance | governance | Parameters, roles, custodian rotation, upgrades, wind-down proposal and proposal cancellation; once each contract's one-time setup closes, the changes that can move value or shorten an exit wait for the timelock | | Custodian | Not an authority | Off-chain party holding the real-world structure; a genesis-configured slot rotatable only by governance | @@ -167,8 +167,9 @@ For the exhaustive access control matrix, dual `manager` role distinction, and e and never identity-gated. A delisted, non-frozen investor exits through the exit-only cash path. A frozen investor's claims are held, not paid, until the freeze is lifted ([`COMPLIANCE_MATRIX.md`](./COMPLIANCE_MATRIX.md) §1). - Outside this guarantee, disclosed as trust assumptions: deposit-asset - issuer controls and reserve liquidity. + A legal-order transfer moves only shares in the investor's wallet, never + value the vault holds. Outside this guarantee, disclosed as trust + assumptions: deposit-asset issuer controls and reserve liquidity. 6. **Split accounting, with priced and payable as separate states.** The vault exposes committed (priced redemption liabilities), cancellable escrow @@ -416,9 +417,12 @@ interface today. replaces it with OZ's contracts without touching the token. - KYC happens wherever the operator runs it; the chain sees only its output. The compliance authority writes allowlist entries on the identity verifier. -- Token interventions (freeze, unfreeze, forced transfer, recovery) are - compliance operations through the share token's `compliance` role, available even while the vault is - paused. +- Token interventions are compliance operations through the share token's + `compliance` role, available even while the vault is paused. Freezing and + unfreezing are compliance's alone; moving shares under a legal order + (`legal_transfer`) and recovering a lost wallet (`recover_balance`) also need + governance's signature in the same call. Neither moves shares out of or into + the vault's address, and the vault's address cannot be frozen. - The complete specification of investor states (Allowlisted, De-listed, Frozen) and the exit-only cash path across all vault lifecycle operations is detailed in [`docs/COMPLIANCE_MATRIX.md`](./COMPLIANCE_MATRIX.md). @@ -483,9 +487,10 @@ investor sees the exit-only path. ### Admin panel -Operates an existing vault; deploys nothing. Each privileged entrypoint is gated -on one role of one contract, except the share token's `forced_transfer`, which -accepts its `manager` (the vault) or `compliance` role. The oracle's NAV pause has +Operates an existing vault; deploys nothing. Each privileged entrypoint needs +one role of one contract, except the share token's `legal_transfer` and +`recover_balance`, which need its `compliance` role and its admin in the same +call. The oracle's NAV pause has two entrypoints: `pause_nav` for its guardian and `set_nav_paused` for its admin. The panel groups the entrypoints into five domain surfaces. A surface is visible to the authorities that act in it, and each action only to its own @@ -494,7 +499,7 @@ visible to the authorities that act in it, and each action only to its own | Surface | Caller | Cadence | Operations | | ------------- | ----------------------------------------------------------------------------------------------------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Cycle | manager, attestation, treasury; anyone prices, funds, activates and finalizes | Continuous | Epoch sealing (manager); attestations; transfers to the custodian (treasury); pricing, funding, wind-down activation and round finalization (permissionless) | -| Compliance | compliance; verifier admin (compliance by deploy) | Continuous | Allowlist (verifier admin); freeze/unfreeze of an address or of part of its shares, forced transfer (compliance); recovery is not offered, the kit's verifier names no recovery target | +| Compliance | compliance; verifier admin (compliance by deploy); share-token admin (governance by deploy) as co-signer | Continuous | Allowlist (verifier admin); freeze/unfreeze of an address or of part of its shares (compliance); legal-order transfer (compliance and share-token admin); recovery is not offered, the kit's verifier names no target | | Emergency | guardian, governance; oracle admin and share-token admin (both governance by deploy) | Rare and urgent | Vault pause (guardian) and unpause (governance); oracle NAV pause (oracle guardian, or oracle admin through `set_nav_paused`), reset and `clear_latest` (oracle admin); share-token pause/unpause (share-token admin) | | Configuration | governance; oracle admin and share-token admin (both governance by deploy) | Rare and deliberate | Custodian slot, deposit cap, notice and wind-down delay (governance); oracle guardrails (oracle admin); compliance and identity-verifier contracts (share-token admin). After setup, the vault's custodian and a lower notice or wind-down delay wait for the timelock, as do the oracle and token changes listed in §8.9; raising the notice or the delay and the deposit cap stay instant | | Governance | governance; oracle admin and share-token admin for their own roles and handover (both governance by deploy) | Very rare | Roles and admin handover on the vault, oracle and share token; upgrade and its delay, wind-down proposal and cancellation (governance). After setup, every grant, `manager` revoke, handover and upgrade waits for the timelock; revoking an operational role (treasury, guardian, attester, compliance) stays instant | @@ -537,7 +542,8 @@ flowchart LR TPAUSE[pause · unpause] TCFG[set_compliance ·
set_identity_verifier] TGOV[roles · admin handover] - TCMP[freeze · unfreeze ·
partial freeze · forced_transfer] + TCMP[freeze · unfreeze ·
partial freeze] + TLEG[legal_transfer] end subgraph VERIFIER["Identity verifier"] @@ -568,6 +574,8 @@ flowchart LR OADM --> OGOV --> GVN TADM --> TGOV --> GVN CMP --> TCMP --> CPL + CMP --> TLEG --> CPL + TADM -->|co-signs| TLEG VADM --> VALLOW --> CPL GOV -.->|by deploy| OADM GOV -.->|by deploy| TADM @@ -589,12 +597,12 @@ 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 vault's own custodian, role grants, `manager` revocations, admin handovers and lowering of the notice or the wind-down delay wait on that clock once its one-time setup closes, and it takes no deposit before then; revoking its treasury or guardian stays instant. Setup closes only once the token and the oracle run on the vault's clock with their own setup closed, the vault is the token's only minter, no shares exist yet (shares minted earlier would redeem against depositors' money), the vault passes the identity check and the token is bound to its compliance contract. 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. | +| 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 vault's own custodian, role grants, `manager` revocations, admin handovers and lowering of the notice or the wind-down delay wait on that clock once its one-time setup closes, and it takes no deposit before then; revoking its treasury or guardian stays instant. Setup closes only once the token and the oracle run on the vault's clock with their own setup closed, the vault is the token's only minter, no shares exist yet (shares minted earlier would redeem against depositors' money), the vault passes the identity check, its address is not frozen on the token and the token is bound to its compliance contract. 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. | | Mis-wired deployment | A vault goes live without every binding wired | `health()` reports each wiring step; `finish_setup` refuses until all hold, and the vault takes no deposit before that | | 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) | -| Compliance keys | Wrongful delisting or freeze | Delisted investors keep the exit-only cash path; freezes require the share token's compliance role and are auditable per operation | +| Compliance keys | Wrongful delisting, freeze or transfer | Delisted investors keep the exit-only cash path; freezes require the share token's compliance role and are auditable per operation; moving a holder's shares also needs governance's signature in the same call, lands only on an allowlisted account and never moves shares out of or into the vault's address, which cannot be frozen | | Compliance module | Faulty module blocks transfers | Fail-closed semantics; replaceable by governance without touching the token | | Deposit asset issuer | Freeze or clawback of the vault's reserve | Not mitigated by the kit; declared risk of the chosen asset, verified and reported at genesis (auth flags) | | Custodian / real world | Underlying loss or delay | Reflected through attested NAV; the kit constrains what reaches the chain, it does not verify the world | From 255cb4cfae09b0701e4898dd465663e1c9adbccf Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:53:47 -0300 Subject: [PATCH 055/147] fix(token): address legal transfer review round 1 --- contracts/share-token/src/contract.rs | 3 +-- docs/AUTHORIZATION_MATRIX.md | 4 ++-- docs/kit-spec.md | 4 ++-- docs/strata-product-and-architecture.md | 9 +++++---- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/contracts/share-token/src/contract.rs b/contracts/share-token/src/contract.rs index d31aaa36..acfbe495 100644 --- a/contracts/share-token/src/contract.rs +++ b/contracts/share-token/src/contract.rs @@ -19,8 +19,7 @@ use stellar_tokens::{ /// Longest order reference a legal transfer accepts, in bytes. pub const MAX_REFERENCE_LEN: u32 = 128; -/// The admin co-signs the call, and must be a different account from the -/// compliance authority that asks for it. +/// The co-signing admin must be a different account from the operator. fn require_admin_co_sign(e: &Env, operator: &Address) { let admin = access_control::enforce_admin_auth(e); if admin == *operator { diff --git a/docs/AUTHORIZATION_MATRIX.md b/docs/AUTHORIZATION_MATRIX.md index 2dc1e757..e7645aa2 100644 --- a/docs/AUTHORIZATION_MATRIX.md +++ b/docs/AUTHORIZATION_MATRIX.md @@ -118,8 +118,8 @@ Tests: [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.r | `burn` | `#[only_role(operator, "manager")]` | Balance sufficient | `test::unauthorized_caller_cannot_burn` | | `forced_transfer` | `#[only_role(operator, "manager")]` | Balance sufficient | `test::unauthorized_caller_cannot_forced_transfer`, `test::legal_transfer::compliance_no_longer_forces_transfers` | | `legal_transfer` | `#[only_role(operator, "compliance")]` and the admin's `require_auth()` in the same call, from two different accounts (`CoSignRequired`) | Reference of 1–128 bytes (`InvalidReference`); never from or to the vault's address (`VaultAddress`); destination passes `verify_identity`; balance sufficient | `test::legal_transfer::*` | -| `set_address_frozen` | `#[only_role(operator, "compliance")]` | Never freezes the vault's address (`VaultAddress`); unfreezing it stays possible | `test::unauthorized_caller_cannot_freeze_or_recover`, `test::the_manager_escrows_but_does_not_intervene`, `test::legal_transfer::the_vault_address_cannot_be_frozen` | -| `freeze_partial_tokens` | `#[only_role(operator, "compliance")]` | Balance sufficient; never the vault's address (`VaultAddress`) | `test::unauthorized_caller_cannot_freeze_or_recover`, `test::the_manager_escrows_but_does_not_intervene`, `test::legal_transfer::the_vault_address_cannot_be_frozen` | +| `set_address_frozen` | `#[only_role(operator, "compliance")]` | Never freezes the bound vault's address (`VaultAddress`); unfreezing it stays possible | `test::unauthorized_caller_cannot_freeze_or_recover`, `test::the_manager_escrows_but_does_not_intervene`, `test::legal_transfer::the_vault_address_cannot_be_frozen` | +| `freeze_partial_tokens` | `#[only_role(operator, "compliance")]` | Balance sufficient; never the bound vault's address (`VaultAddress`) | `test::unauthorized_caller_cannot_freeze_or_recover`, `test::the_manager_escrows_but_does_not_intervene`, `test::legal_transfer::the_vault_address_cannot_be_frozen` | | `unfreeze_partial_tokens`| `#[only_role(operator, "compliance")]` | Frozen amount sufficient | `test::unauthorized_caller_cannot_freeze_or_recover`, `test::the_manager_escrows_but_does_not_intervene` | | `recover_balance` | `#[only_role(operator, "compliance")]` and the admin's `require_auth()` in the same call, from two different accounts (`CoSignRequired`) | Never from or to the vault's address (`VaultAddress`); target allowlisted and named by the verifier's `recovery_target` (the testnet stub names none, so recovery always fails there) | `test::unauthorized_caller_cannot_freeze_or_recover`, `test::the_compliance_authority_reaches_recovery`, `test::legal_transfer::recovery_needs_the_admin_too`, `test::legal_transfer::recovery_never_takes_the_vault_balance`, `test::legal_transfer::recovery_never_lands_on_the_vault_address` | | `set_compliance` | `#[only_admin]`, `operator.require_auth()` | Instant during setup; `UseTimelock` after | `test::unauthorized_caller_cannot_set_compliance_or_verifier`, `test::the_compliance_authority_cannot_mint_burn_or_swap_the_rules`, `test_governance::rule_and_verifier_swaps_wait_after_setup` | diff --git a/docs/kit-spec.md b/docs/kit-spec.md index 1d2dd679..043c490d 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -195,7 +195,7 @@ Strata distributes control across five native Stellar multisig authorities and p | **Guardian** | Low-threshold fast-reaction multisig | `pause` (vault); `pause_nav` (oracle emergency price freeze). | **Cannot unpause**; cannot move funds; cannot block redemption requests; cannot block payable claims or cancellations. | | **Treasury** | Operational capital multisig | `deploy_to_custodian` (moves free reserve); `fund` (returns assets from custodian). | **Cannot deploy while anything is uncovered**; cannot touch escrowed subscription assets; cannot alter parameters or pause. | | **Attester** | High-security attestation multisig | `attest` (submits NAV report with proof reference). | **Cannot price outside deviation band**; cannot violate cooldown; cannot operate on the vault directly. | -| **Compliance** | Legal / KYC operator multisig | Manage allowlist (`allow`), topic claims; token interventions (`freeze`; `legal_transfer` and `recovery` co-signed by governance). | **Cannot touch vault reserve**; cannot move a holder's shares without governance's signature in the same call; cannot move shares out of or into the vault's address, or freeze it; cannot pause vault operations; cannot bypass exit-only cash redemptions for an unfrozen holder — freezing holds a covered claim until it lifts, rather than paying or redirecting it. | +| **Compliance** | Legal / KYC operator multisig | Manage allowlist (`allow`), topic claims; token interventions (`freeze`; `legal_transfer` and `recovery` co-signed by governance). | **Cannot touch vault reserve**; cannot move a holder's shares without governance's signature in the same call; cannot move shares out of or into the vault's address, or freeze it once the token knows its vault (an earlier freeze makes the vault's `finish_setup` refuse); cannot pause vault operations; cannot bypass exit-only cash redemptions for an unfrozen holder — freezing holds a covered claim until it lifts, rather than paying or redirecting it. | | **Manager** | Operational keeper account / vault | `close_epoch` (vault); `mint`, `burn`, `forced_transfer` (share token). | **Cannot choose prices**; cannot prevent epoch fulfillment; cannot alter timelocks. | | **Permissionless** | Any caller / keeper / investor | `fulfill_epoch`, `request_deposit`, `cancel_deposit`, `claim_deposit`, `request_redeem`, `cancel_redeem`, `claim_redeem`, `activate_wind_down`, `finalize_wind_down_round`, `claim_wind_down`. | Operations are strictly constrained by contract state, timing, and solvency guards. | @@ -256,7 +256,7 @@ Strata distributes control across five native Stellar multisig authorities and p | **`ShareToken`** | `burn` | Manager (Vault) | `#[only_role(operator, "manager")]` | Bypasses token pause | | **`ShareToken`** | `forced_transfer` | Manager (Vault) | `#[only_role(operator, "manager")]` | Bypasses token pause | | **`ShareToken`** | `legal_transfer` | Compliance, co-signed by Governance (token admin) | `#[only_role(operator, "compliance")]` and the admin's `require_auth()` in the same call, from two different accounts; never from or to the vault's address; the destination passes `verify_identity`; a reference of 1–128 bytes | Bypasses token pause | -| **`ShareToken`** | `set_address_frozen`, `freeze_partial_tokens`, `unfreeze_partial_tokens` | Compliance | `#[only_role(operator, "compliance")]`; never freezes the vault's address | No | +| **`ShareToken`** | `set_address_frozen`, `freeze_partial_tokens`, `unfreeze_partial_tokens` | Compliance | `#[only_role(operator, "compliance")]`; never freezes the vault's address once the token knows its vault | No | | **`ShareToken`** | `recover_balance` | Compliance, co-signed by Governance (token admin) | `#[only_role(operator, "compliance")]` and the admin's `require_auth()` in the same call, from two different accounts; never from or to the vault's address | No | | **`ShareToken`** | `set_compliance`, `set_identity_verifier` | Governance | `#[only_admin]`; instant during setup, then `propose_change` | No | | **`ShareToken`** | `bind_vault` | Governance (token admin) | `#[only_admin]`; during setup, once | No | diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index ef15fd12..8228d95d 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -287,8 +287,8 @@ 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 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 | +| **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 freezes, plus legal transfers and recovery co-signed by the token admin; 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, and makes legal transfers and recoveries co-signed by the token admin | 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 | | **Authorities** | governance (parameters, roles, timelocked upgrades), compliance (allowlist, token interventions), attestation (valuation only), treasury (reserve movements only), guardian (pause; never payable claims) | Each contract grants its own roles; the oracle's, share token's and verifier's admins and the oracle's attester and guardian are constructor parameters independent of the vault's roles, equal to governance, compliance and guardian by deploy only; treasury and guardian distinct, treasury and governance distinct, compliance distinct from governance and treasury; governance and guardian may coincide; the vault's manager role is unconstrained | | **Custodian** | Off-chain party holding the real-world structure; a genesis-configured slot rotatable only by governance | Not an on-chain authority | @@ -422,7 +422,8 @@ interface today. unfreezing are compliance's alone; moving shares under a legal order (`legal_transfer`) and recovering a lost wallet (`recover_balance`) also need governance's signature in the same call. Neither moves shares out of or into - the vault's address, and the vault's address cannot be frozen. + the vault's address, and that address cannot be frozen once the token knows + its vault; an earlier freeze makes the vault's `finish_setup` refuse. - The complete specification of investor states (Allowlisted, De-listed, Frozen) and the exit-only cash path across all vault lifecycle operations is detailed in [`docs/COMPLIANCE_MATRIX.md`](./COMPLIANCE_MATRIX.md). @@ -602,7 +603,7 @@ public surface given the vault address. | 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) | -| Compliance keys | Wrongful delisting, freeze or transfer | Delisted investors keep the exit-only cash path; freezes require the share token's compliance role and are auditable per operation; moving a holder's shares also needs governance's signature in the same call, lands only on an allowlisted account and never moves shares out of or into the vault's address, which cannot be frozen | +| Compliance keys | Wrongful delisting, freeze or transfer | Delisted investors keep the exit-only cash path; freezes require the share token's compliance role and are auditable per operation; moving a holder's shares also needs governance's signature in the same call, lands only on an allowlisted account and never moves shares out of or into the vault's address, which cannot be frozen once the token knows its vault | | Compliance module | Faulty module blocks transfers | Fail-closed semantics; replaceable by governance without touching the token | | Deposit asset issuer | Freeze or clawback of the vault's reserve | Not mitigated by the kit; declared risk of the chosen asset, verified and reported at genesis (auth flags) | | Custodian / real world | Underlying loss or delay | Reflected through attested NAV; the kit constrains what reaches the chain, it does not verify the world | From 86c8669b2a25846744fc3d39ac60aa86acbec4af Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:01:58 -0300 Subject: [PATCH 056/147] feat(compliance): timelock rule changes --- Cargo.lock | 2 + contracts/async-vault/src/governance.rs | 19 +- contracts/async-vault/src/test/compliance.rs | 6 +- contracts/async-vault/src/test/health.rs | 14 + contracts/async-vault/src/test/mod.rs | 28 +- contracts/async-vault/src/test/settlement.rs | 6 +- contracts/async-vault/src/test/setup.rs | 40 +- contracts/compliance/Cargo.toml | 2 + contracts/compliance/src/governance.rs | 159 ++++++ contracts/compliance/src/lib.rs | 155 ++++- contracts/compliance/src/test.rs | 2 + contracts/compliance/src/test/changes.rs | 560 +++++++++++++++++++ scripts/harness/deploy.ts | 8 + scripts/harness/extend.ts | 1 + scripts/harness/guards.test.ts | 39 ++ scripts/harness/lib/clients.ts | 3 + 16 files changed, 1026 insertions(+), 18 deletions(-) create mode 100644 contracts/compliance/src/governance.rs create mode 100644 contracts/compliance/src/test/changes.rs diff --git a/Cargo.lock b/Cargo.lock index 22a9197a..6e8107a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -351,11 +351,13 @@ dependencies = [ name = "compliance" version = "0.1.0" dependencies = [ + "bindings", "soroban-sdk", "stellar-access", "stellar-macros", "stellar-tokens", "storage", + "timelock", ] [[package]] diff --git a/contracts/async-vault/src/governance.rs b/contracts/async-vault/src/governance.rs index b51473d1..8cf8c8cc 100644 --- a/contracts/async-vault/src/governance.rs +++ b/contracts/async-vault/src/governance.rs @@ -143,6 +143,20 @@ pub(crate) fn bound_here(e: &Env, contract: &Address) -> bool { && matches!(binding.try_setup_open(), Ok(Ok(false))) } +fn compliance_bound(e: &Env, compliance: &Address, token: &Address) -> bool { + let linked = matches!( + TokenBinderClient::new(e, compliance).try_linked_tokens(), + Ok(Ok(linked)) if linked.contains(token) + ); + let binding = VaultBindingClient::new(e, compliance); + let on_this_clock = match (binding.try_vault(), binding.try_setup_open()) { + (Ok(Ok(vault)), Ok(Ok(open))) => vault == Some(e.current_contract_address()) && !open, + // An external module answers no binding view; its governance is a trust assumption. + _ => true, + }; + linked && on_this_clock +} + pub(crate) fn wiring(e: &Env) -> Wiring { let me = e.current_contract_address(); let token = state::get_addr(e, &DataKey::ShareToken); @@ -172,10 +186,7 @@ pub(crate) fn wiring(e: &Env) -> Wiring { }, vault_unfrozen: matches!(rwa.try_is_frozen(&me), Ok(Ok(false))), compliance_bound: match rwa.try_compliance() { - Ok(Ok(compliance)) => matches!( - TokenBinderClient::new(e, &compliance).try_linked_tokens(), - Ok(Ok(linked)) if linked.contains(&token) - ), + Ok(Ok(compliance)) => compliance_bound(e, &compliance, &token), _ => false, }, } diff --git a/contracts/async-vault/src/test/compliance.rs b/contracts/async-vault/src/test/compliance.rs index 5d7b3fbe..ffd539a7 100644 --- a/contracts/async-vault/src/test/compliance.rs +++ b/contracts/async-vault/src/test/compliance.rs @@ -101,11 +101,7 @@ fn a_compliance_module_that_refuses_the_mint_blocks_a_priced_claim() { f.fulfill_epoch(wad(2)); let module = f.e.register(RefusesEveryMint, ()); - ComplianceClient::new(&f.e, &f.share.compliance()).add_module_to( - &ComplianceHook::CanCreate, - &module, - &f.admin, - ); + f.add_compliance_module(ComplianceHook::CanCreate, &module); assert_eq!( f.vault.try_claim_deposit(&investor, &epoch).err(), diff --git a/contracts/async-vault/src/test/health.rs b/contracts/async-vault/src/test/health.rs index 7a7ce66d..829c4f53 100644 --- a/contracts/async-vault/src/test/health.rs +++ b/contracts/async-vault/src/test/health.rs @@ -89,6 +89,20 @@ fn each_missing_wiring_step_shows_in_its_own_flag() { ..all_healthy() }, ), + ( + Miswire::ComplianceBoundElsewhere, + Health { + compliance_bound: false, + ..all_healthy() + }, + ), + ( + Miswire::ComplianceSetupOpen, + Health { + compliance_bound: false, + ..all_healthy() + }, + ), ]; for (miswire, expected) in cases { let f = miswired(miswire); diff --git a/contracts/async-vault/src/test/mod.rs b/contracts/async-vault/src/test/mod.rs index 7e92e114..6f3c899a 100644 --- a/contracts/async-vault/src/test/mod.rs +++ b/contracts/async-vault/src/test/mod.rs @@ -35,12 +35,13 @@ pub(crate) use soroban_sdk::{ Address, BytesN, Env, String, }; -pub(crate) use ::compliance::{Compliance, ComplianceClient}; +pub(crate) use ::compliance::{Compliance, ComplianceChange, ComplianceClient}; pub(crate) use identity_verifier::{IdentityVerifier, IdentityVerifierClient}; pub(crate) use nav_oracle::{NavOracleContract, NavOracleContractClient, NavReport, OracleConfig}; pub(crate) use share_token::{ShareToken, ShareTokenClient}; pub(crate) use stellar_contract_utils::math::wad::WAD_SCALE; +use stellar_tokens::rwa::compliance::ComplianceHook; pub(crate) use timelock::DAY_DURATION; pub(crate) use crate::{ @@ -169,6 +170,17 @@ impl Fixture<'_> { self.vault.deploy_to_custodian(&self.treasury, &free); } } + + /// After setup, a module lands only through the compliance contract's timelock. + fn add_compliance_module(&self, hook: ComplianceHook, module: &Address) { + let rules = ComplianceClient::new(&self.e, &self.share.compliance()); + let id = rules.propose_change( + &ComplianceChange::AddModule(hook, module.clone()), + &self.admin, + ); + self.advance(MIN_UPGRADE_DELAY); + rules.apply_change(&id, &self.admin); + } } pub(crate) fn distinct_roles(e: &Env) -> VaultRoles { @@ -237,6 +249,8 @@ enum Miswire { NotAllowlisted, VaultFrozen, ComplianceUnbound, + ComplianceBoundElsewhere, + ComplianceSetupOpen, } /// A vault left in setup, wired except for `miswire`. @@ -390,6 +404,18 @@ fn build<'a>( oracle.finish_setup(&admin); } } + match miswire { + Miswire::ComplianceUnbound => {} + Miswire::ComplianceBoundElsewhere => { + compliance.bind_vault(®ister_with(&e, distinct_roles(&e)), &admin); + compliance.finish_setup(&admin); + } + Miswire::ComplianceSetupOpen => compliance.bind_vault(&contract_id, &admin), + _ => { + compliance.bind_vault(&contract_id, &admin); + compliance.finish_setup(&admin); + } + } let vault = AsyncVaultClient::new(&e, &contract_id); if finish { vault.set_custodian(&custodian, &admin); diff --git a/contracts/async-vault/src/test/settlement.rs b/contracts/async-vault/src/test/settlement.rs index 398b03b5..71e44408 100644 --- a/contracts/async-vault/src/test/settlement.rs +++ b/contracts/async-vault/src/test/settlement.rs @@ -191,11 +191,7 @@ fn a_refused_mint_keeps_a_priced_deposit_epoch_open() { f.fulfill_epoch(wad(2)); let module = f.e.register(super::compliance::RefusesEveryMint, ()); - ComplianceClient::new(&f.e, &f.share.compliance()).add_module_to( - &ComplianceHook::CanCreate, - &module, - &f.admin, - ); + f.add_compliance_module(ComplianceHook::CanCreate, &module); assert!(f.vault.try_claim_deposit(&investor, &epoch).is_err()); diff --git a/contracts/async-vault/src/test/setup.rs b/contracts/async-vault/src/test/setup.rs index 2890fb70..3dad44f7 100644 --- a/contracts/async-vault/src/test/setup.rs +++ b/contracts/async-vault/src/test/setup.rs @@ -1,4 +1,4 @@ -use soroban_sdk::{testutils::Events as _, Event as _, IntoVal}; +use soroban_sdk::{contract, contractimpl, testutils::Events as _, vec, Event as _, IntoVal, Vec}; use stellar_access::role_transfer::RoleTransferError; use super::*; @@ -62,6 +62,8 @@ fn every_wiring_step_is_checked_before_setup_finishes() { Miswire::NotAllowlisted, Miswire::VaultFrozen, Miswire::ComplianceUnbound, + Miswire::ComplianceBoundElsewhere, + Miswire::ComplianceSetupOpen, ] { let f = miswired(miswire); @@ -74,6 +76,42 @@ fn every_wiring_step_is_checked_before_setup_finishes() { } } +/// A compliance contract from outside the kit: it lists tokens and reports no vault. +#[contract] +struct ExternalRules; + +#[contractimpl] +impl ExternalRules { + pub fn link(e: Env, token: Address) { + e.storage().instance().set(&symbol_short!("token"), &token); + } + + pub fn linked_tokens(e: Env) -> Vec
{ + let token: Address = e.storage().instance().get(&symbol_short!("token")).unwrap(); + vec![&e, token] + } +} + +/// Its own governance is a trust assumption the vault cannot check. +#[test] +fn a_compliance_contract_from_outside_the_kit_is_accepted() { + let f = setup_with( + open_feed(), + |e, (name, symbol, admin, authority, _, verifier)| { + let rules = e.register(ExternalRules, ()); + let token = e.register( + ShareToken, + (name, symbol, admin, authority, rules.clone(), verifier), + ); + ExternalRulesClient::new(e, &rules).link(&token); + token + }, + ); + + assert!(!f.vault.setup_open()); + assert!(f.vault.health().compliance_bound); +} + /// A token that cannot answer `wiring`'s calls must not trap the vault: every /// check it needs the token for reads false, and `finish_setup` refuses /// cleanly instead of aborting the transaction. diff --git a/contracts/compliance/Cargo.toml b/contracts/compliance/Cargo.toml index dbc70e3f..6ebfdf51 100644 --- a/contracts/compliance/Cargo.toml +++ b/contracts/compliance/Cargo.toml @@ -15,6 +15,8 @@ stellar-access = { workspace = true } stellar-macros = { workspace = true } stellar-tokens = { workspace = true } storage = { path = "../../crates/storage" } +timelock = { path = "../../crates/timelock" } +bindings = { path = "../../crates/bindings" } [dev-dependencies] soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/compliance/src/governance.rs b/contracts/compliance/src/governance.rs new file mode 100644 index 00000000..ee70a2a1 --- /dev/null +++ b/contracts/compliance/src/governance.rs @@ -0,0 +1,159 @@ +use soroban_sdk::{contracttype, panic_with_error, Address, Env, Vec}; +use stellar_access::{ + access_control::{self, AccessControlError, AccessControlStorageKey}, + role_transfer, +}; +use stellar_tokens::rwa::{compliance, compliance::ComplianceHook, utils::token_binder}; +use timelock::{queue, setup, Clock, Refusal, TimelockHost}; + +use crate::{ + ChangeApplied, ChangeCancelled, ChangeProposed, Compliance, ComplianceContractError, + 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 ComplianceChange { + AddModule(ComplianceHook, Address), + RemoveModule(ComplianceHook, Address), + BindToken(Address), + UnbindToken(Address), + Admin(Address), +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CompliancePendingChange { + pub id: u32, + pub change: ComplianceChange, + pub eta: u64, +} + +/// The compliance contract keeps no clock of its own: it reads the vault's. +impl TimelockHost for Compliance { + fn clock(e: &Env) -> Result { + timelock::clock::from_source(e) + } +} + +fn refuse(e: &Env, refusal: Refusal) -> ! { + match refusal { + Refusal::WindingDown => panic_with_error!(e, ComplianceContractError::WindDownActive), + Refusal::Paused => panic_with_error!(e, ComplianceContractError::VaultPaused), + Refusal::NotElapsed => panic_with_error!(e, ComplianceContractError::ChangeNotReady), + Refusal::BelowExitWindow => { + panic_with_error!(e, ComplianceContractError::DelayBelowNotice) + } + Refusal::NotFound => panic_with_error!(e, ComplianceContractError::ChangeNotFound), + Refusal::TooMany => panic_with_error!(e, ComplianceContractError::TooManyPendingChanges), + Refusal::SetupOpen => panic_with_error!(e, ComplianceContractError::SetupOpen), + Refusal::SetupClosed | Refusal::AlreadyBound => { + panic_with_error!(e, ComplianceContractError::SetupClosed) + } + Refusal::Unbound => panic_with_error!(e, ComplianceContractError::VaultNotBound), + } +} + +fn vault_clock(e: &Env) -> Clock { + ::clock(e).unwrap_or_else(|r| refuse(e, r)) +} + +/// Direct rule changes are instant only before setup finishes. +pub(crate) fn require_setup_open(e: &Env) { + if !setup::is_open(e) { + panic_with_error!(e, ComplianceContractError::UseTimelock); + } +} + +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, ComplianceContractError::AdminTransferPending); + } + setup::finish::(e).unwrap_or_else(|r| refuse(e, r)); + SetupFinished {}.publish(e); +} + +pub(crate) fn propose(e: &Env, change: ComplianceChange) -> u32 { + 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: ComplianceChange = + queue::take_ready::(e, id).unwrap_or_else(|r| refuse(e, r)); + match &change { + ComplianceChange::AddModule(hook, module) => { + compliance::storage::add_module_to(e, hook.clone(), module.clone()) + } + ComplianceChange::RemoveModule(hook, module) => { + compliance::storage::remove_module_from(e, hook.clone(), module.clone()) + } + ComplianceChange::BindToken(token) => token_binder::bind_token(e, token), + ComplianceChange::UnbindToken(token) => token_binder::unbind_token(e, token), + ComplianceChange::Admin(next) => offer_admin(e, next), + } + 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, next: &Address) { + let admin = access_control::get_admin(e) + .unwrap_or_else(|| panic_with_error!(e, AccessControlError::AdminNotSet)); + 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(CompliancePendingChange { + id, + change, + eta: schedule.eta(&clock), + }); + } + } + out +} diff --git a/contracts/compliance/src/lib.rs b/contracts/compliance/src/lib.rs index 7f59fdad..f738634f 100644 --- a/contracts/compliance/src/lib.rs +++ b/contracts/compliance/src/lib.rs @@ -2,16 +2,87 @@ //! module (ERC-3643), not part of this product. Do not deploy to mainnet. #![no_std] -use soroban_sdk::{contract, contractimpl, contractmeta, Address, Env, Vec}; +mod governance; + +use soroban_sdk::{ + contract, contracterror, contractevent, contractimpl, contractmeta, Address, Env, Vec, +}; contractmeta!( key = "stub", val = "testnet-only; prod = external compliance module" ); +use bindings::VaultBinding; use stellar_access::access_control; use stellar_macros::only_admin; use stellar_tokens::rwa::{compliance, compliance::ComplianceHook, utils::token_binder}; +pub use governance::{ComplianceChange, CompliancePendingChange, ADMIN_ACCEPT_LEDGERS}; + +/// Compliance contract failures. Codes are stable once assigned and are never +/// reused; a removed variant leaves its number retired. +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum ComplianceContractError { + /// No pending change has this id. + ChangeNotFound = 8001, + /// The change's delay has not elapsed. + ChangeNotReady = 8002, + /// As many changes as the timelock holds already wait. + TooManyPendingChanges = 8003, + /// Changes are instant until setup finishes; there is nothing to propose yet. + SetupOpen = 8004, + /// A direct call cannot make this change after setup: it goes through + /// `propose_change`. + UseTimelock = 8005, + /// Only possible while setup is open, and only once. + SetupClosed = 8006, + /// Setup cannot finish before the contract is bound to its vault. + VaultNotBound = 8007, + /// The vault is winding down, so no change may be proposed or applied. + WindDownActive = 8008, + /// The vault is paused, which holds every timelocked change. + VaultPaused = 8009, + /// The change's delay is shorter than the vault's notice, so an investor + /// could not complete an exit before it lands. + DelayBelowNotice = 8010, + /// 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 = 8011, +} + +#[contractevent] +pub struct ChangeProposed { + #[topic] + pub id: u32, + pub change: ComplianceChange, + pub eta: u64, +} + +#[contractevent] +pub struct ChangeCancelled { + #[topic] + pub id: u32, +} + +#[contractevent] +pub struct ChangeApplied { + #[topic] + pub id: u32, + pub change: ComplianceChange, +} + +#[contractevent] +pub struct SetupFinished {} + +#[contractevent] +pub struct VaultBound { + #[topic] + pub vault: Address, +} + #[contract] pub struct Compliance; @@ -21,35 +92,115 @@ impl Compliance { access_control::set_admin(e, &admin); storage::bump_instance(e); } + + /// Names the vault whose clock this contract's timelock reads. Once, and + /// only during setup. + #[only_admin] + pub fn bind_vault(e: &Env, vault: Address, _operator: Address) { + storage::bump_instance(e); + governance::bind_vault(e, &vault); + } + + /// Ends the window in which module and binding changes are instant. It + /// never reopens. + #[only_admin] + pub fn finish_setup(e: &Env, _operator: Address) { + 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: ComplianceChange, _operator: Address) -> u32 { + storage::bump_instance(e); + governance::propose(e, change) + } + + #[only_admin] + pub fn cancel_change(e: &Env, id: u32, _operator: Address) { + 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, _operator: Address) { + storage::bump_instance(e); + governance::apply(e, id); + } + + pub fn pending_changes(e: &Env) -> Vec { + governance::pending(e) + } + + /// Instant during setup; after it, propose an `Admin` change. Cancelling a + /// pending hand-over (`live_until_ledger == 0`) keeps the current admin, + /// so it stays instant. + pub 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); + } + + pub fn accept_admin_transfer(e: &Env) { + storage::bump_instance(e); + access_control::accept_admin_transfer(e); + } + + pub fn get_admin(e: &Env) -> Option
{ + access_control::get_admin(e) + } +} + +#[contractimpl(contracttrait)] +impl VaultBinding for Compliance { + fn vault(e: &Env) -> Option
{ + timelock::setup::source(e) + } + + fn setup_open(e: &Env) -> bool { + timelock::setup::is_open(e) + } } +/// Binding and unbinding are instant during setup; after it, propose a +/// `BindToken` or `UnbindToken` change. #[contractimpl(contracttrait)] impl token_binder::TokenBinder for Compliance { #[only_admin] fn bind_token(e: &Env, token: Address, _operator: Address) { + governance::require_setup_open(e); storage::bump_instance(e); token_binder::bind_token(e, &token); } #[only_admin] fn unbind_token(e: &Env, token: Address, _operator: Address) { + governance::require_setup_open(e); storage::bump_instance(e); token_binder::unbind_token(e, &token); } } /// The token calls the check before and the hook after every mint, burn and -/// transfer, which is what keeps this instance alive. +/// transfer, which is what keeps this instance alive. Adding or removing a +/// module is instant during setup; after it, propose an `AddModule` or +/// `RemoveModule` change. #[contractimpl(contracttrait)] impl compliance::Compliance for Compliance { #[only_admin] fn add_module_to(e: &Env, hook: ComplianceHook, module: Address, _operator: Address) { + governance::require_setup_open(e); storage::bump_instance(e); compliance::storage::add_module_to(e, hook, module); } #[only_admin] fn remove_module_from(e: &Env, hook: ComplianceHook, module: Address, _operator: Address) { + governance::require_setup_open(e); storage::bump_instance(e); compliance::storage::remove_module_from(e, hook, module); } diff --git a/contracts/compliance/src/test.rs b/contracts/compliance/src/test.rs index 8e479e6c..14200161 100644 --- a/contracts/compliance/src/test.rs +++ b/contracts/compliance/src/test.rs @@ -1,3 +1,5 @@ +mod changes; + use soroban_sdk::{ testutils::{storage::Instance as _, Address as _, Ledger as _}, Address, Env, diff --git a/contracts/compliance/src/test/changes.rs b/contracts/compliance/src/test/changes.rs new file mode 100644 index 00000000..2c5bc55f --- /dev/null +++ b/contracts/compliance/src/test/changes.rs @@ -0,0 +1,560 @@ +use soroban_sdk::{ + contract, contractimpl, symbol_short, + testutils::{Address as _, Events as _, Ledger as _}, + vec, Address, Env, Event as _, +}; +use stellar_access::role_transfer::RoleTransferError; +use stellar_tokens::rwa::compliance::ComplianceHook; +use storage::INSTANCE_EXTEND_AMOUNT; +use timelock::{Clock, DAY_DURATION, MIN_DELAY}; + +use super::{age, instance_ttl}; +use crate::{ + ChangeApplied, ChangeProposed, Compliance, ComplianceChange, ComplianceClient, + ComplianceContractError, CompliancePendingChange, ADMIN_ACCEPT_LEDGERS, +}; + +/// 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() + } +} + +fn running() -> Clock { + Clock { + delay: MIN_DELAY, + exit_window: 0, + paused_secs: 0, + paused: false, + winding_down: false, + } +} + +struct Fixture<'a> { + e: Env, + rules: ComplianceClient<'a>, + clock: ClockStubClient<'a>, + admin: Address, +} + +impl Fixture<'_> { + fn propose(&self, change: &ComplianceChange) -> u32 { + self.rules.propose_change(change, &self.admin) + } + + fn land(&self, change: &ComplianceChange) { + let id = self.propose(change); + self.e.ledger().with_mut(|l| l.timestamp += MIN_DELAY); + self.rules.apply_change(&id, &self.admin); + } + + /// 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()) { + age(&self.e); + assert!(instance_ttl(&self.e, &self.rules.address) < INSTANCE_EXTEND_AMOUNT); + call(); + assert_eq!( + instance_ttl(&self.e, &self.rules.address), + INSTANCE_EXTEND_AMOUNT + ); + } +} + +/// A compliance contract in setup, and a vault clock it has not bound yet. +fn unbound<'a>() -> Fixture<'a> { + let e = Env::default(); + e.mock_all_auths(); + e.ledger().set_timestamp(10_000); + let admin = Address::generate(&e); + let rules = ComplianceClient::new(&e, &e.register(Compliance, (admin.clone(),))); + let clock = ClockStubClient::new(&e, &e.register(ClockStub, ())); + clock.set(&running()); + Fixture { + e, + rules, + clock, + admin, + } +} + +/// A compliance contract bound to a vault clock, with setup still open. +fn bound<'a>() -> Fixture<'a> { + let f = unbound(); + f.rules.bind_vault(&f.clock.address, &f.admin); + f +} + +fn finished<'a>() -> Fixture<'a> { + let f = bound(); + f.rules.finish_setup(&f.admin); + f +} + +fn refused( + result: Result, Result>, + expected: ComplianceContractError, +) { + assert_eq!(result, Err(Ok(expected.into()))); +} + +#[test] +fn bindings_are_instant_during_setup() { + let f = bound(); + let token = Address::generate(&f.e); + + f.rules.bind_token(&token, &f.admin); + + assert_eq!(f.rules.linked_tokens(), vec![&f.e, token]); +} + +#[test] +fn setup_cannot_finish_without_a_vault() { + let f = unbound(); + + refused( + f.rules.try_finish_setup(&f.admin), + ComplianceContractError::VaultNotBound, + ); + assert!(f.rules.setup_open()); +} + +#[test] +fn a_second_vault_or_a_late_vault_is_refused() { + let f = bound(); + refused( + f.rules.try_bind_vault(&Address::generate(&f.e), &f.admin), + ComplianceContractError::SetupClosed, + ); + + f.rules.finish_setup(&f.admin); + + refused( + f.rules.try_bind_vault(&Address::generate(&f.e), &f.admin), + ComplianceContractError::SetupClosed, + ); + refused( + f.rules.try_finish_setup(&f.admin), + ComplianceContractError::SetupClosed, + ); + assert_eq!(f.rules.vault(), Some(f.clock.address.clone())); +} + +#[test] +fn an_address_that_reports_no_clock_cannot_be_bound() { + let f = unbound(); + + assert!(f + .rules + .try_bind_vault(&Address::generate(&f.e), &f.admin) + .is_err()); + assert_eq!(f.rules.vault(), None); +} + +fn implements_the_vault_view() {} + +#[test] +fn the_contract_answers_the_view_the_vault_reads() { + implements_the_vault_view::(); + let f = unbound(); + let view = bindings::VaultBindingClient::new(&f.e, &f.rules.address); + assert_eq!(view.vault(), None); + assert!(view.setup_open()); + + f.rules.bind_vault(&f.clock.address, &f.admin); + f.rules.finish_setup(&f.admin); + + assert_eq!(view.vault(), Some(f.clock.address.clone())); + assert!(!view.setup_open()); +} + +#[test] +fn only_the_admin_binds_and_finishes() { + let f = unbound(); + let stranger = Address::generate(&f.e); + f.e.set_auths(&[]); + + assert!(f.rules.try_bind_vault(&f.clock.address, &stranger).is_err()); + assert!(f.rules.try_finish_setup(&stranger).is_err()); + assert_eq!(f.rules.vault(), None); +} + +#[test] +fn after_setup_rule_changes_wait_for_the_timelock() { + let f = finished(); + let token = Address::generate(&f.e); + let module = Address::generate(&f.e); + + refused( + f.rules.try_bind_token(&token, &f.admin), + ComplianceContractError::UseTimelock, + ); + refused( + f.rules.try_unbind_token(&token, &f.admin), + ComplianceContractError::UseTimelock, + ); + refused( + f.rules + .try_add_module_to(&ComplianceHook::CanTransfer, &module, &f.admin), + ComplianceContractError::UseTimelock, + ); + refused( + f.rules + .try_remove_module_from(&ComplianceHook::CanTransfer, &module, &f.admin), + ComplianceContractError::UseTimelock, + ); +} + +#[test] +fn proposals_wait_until_setup_is_finished() { + let f = bound(); + refused( + f.rules.try_propose_change( + &ComplianceChange::BindToken(Address::generate(&f.e)), + &f.admin, + ), + ComplianceContractError::SetupOpen, + ); + assert_eq!(f.rules.pending_changes().len(), 0); +} + +#[test] +fn a_proposed_binding_lands_after_the_delay() { + let f = finished(); + let token = Address::generate(&f.e); + let change = ComplianceChange::BindToken(token.clone()); + + let id = f.rules.propose_change(&change, &f.admin); + + assert_eq!( + f.rules.pending_changes(), + vec![ + &f.e, + CompliancePendingChange { + id, + change, + eta: 10_000 + MIN_DELAY, + } + ] + ); + refused( + f.rules.try_apply_change(&id, &f.admin), + ComplianceContractError::ChangeNotReady, + ); + + f.e.ledger().set_timestamp(10_000 + MIN_DELAY); + f.rules.apply_change(&id, &f.admin); + + assert_eq!(f.rules.linked_tokens(), vec![&f.e, token]); + assert_eq!(f.rules.pending_changes().len(), 0); +} + +#[test] +fn every_rule_change_lands_through_the_timelock() { + let f = bound(); + let token = Address::generate(&f.e); + let early = Address::generate(&f.e); + let late = Address::generate(&f.e); + f.rules.bind_token(&token, &f.admin); + f.rules + .add_module_to(&ComplianceHook::CanTransfer, &early, &f.admin); + f.rules.finish_setup(&f.admin); + + f.land(&ComplianceChange::RemoveModule( + ComplianceHook::CanTransfer, + early.clone(), + )); + f.land(&ComplianceChange::AddModule( + ComplianceHook::CanCreate, + late.clone(), + )); + f.land(&ComplianceChange::UnbindToken(token)); + + assert!(!f + .rules + .is_module_registered(&ComplianceHook::CanTransfer, &early)); + assert!(f + .rules + .is_module_registered(&ComplianceHook::CanCreate, &late)); + assert_eq!(f.rules.linked_tokens().len(), 0); +} + +#[test] +fn nothing_lands_while_the_vault_is_paused_or_winding_down() { + let f = finished(); + let module = Address::generate(&f.e); + let id = f.rules.propose_change( + &ComplianceChange::AddModule(ComplianceHook::CanCreate, module), + &f.admin, + ); + f.e.ledger().set_timestamp(10_000 + MIN_DELAY); + + let mut paused = running(); + paused.paused = true; + f.clock.set(&paused); + refused( + f.rules.try_apply_change(&id, &f.admin), + ComplianceContractError::VaultPaused, + ); + + let mut winding = running(); + winding.winding_down = true; + f.clock.set(&winding); + refused( + f.rules.try_apply_change(&id, &f.admin), + ComplianceContractError::WindDownActive, + ); + refused( + f.rules.try_propose_change( + &ComplianceChange::BindToken(Address::generate(&f.e)), + &f.admin, + ), + ComplianceContractError::WindDownActive, + ); +} + +#[test] +fn a_vault_pause_pushes_the_change_back() { + let f = finished(); + let id = f.propose(&ComplianceChange::BindToken(Address::generate(&f.e))); + let mut resumed = running(); + resumed.paused_secs = 3 * DAY_DURATION; + f.clock.set(&resumed); + let eta = 10_000 + MIN_DELAY + 3 * DAY_DURATION; + + assert_eq!(f.rules.pending_changes().get(0).unwrap().eta, eta); + f.e.ledger().set_timestamp(eta - 1); + refused( + f.rules.try_apply_change(&id, &f.admin), + ComplianceContractError::ChangeNotReady, + ); + + f.e.ledger().set_timestamp(eta); + f.rules.apply_change(&id, &f.admin); +} + +#[test] +fn a_change_shorter_than_a_raised_notice_never_lands() { + let f = finished(); + let id = f.propose(&ComplianceChange::BindToken(Address::generate(&f.e))); + let mut raised = running(); + raised.delay = 30 * DAY_DURATION; + raised.exit_window = 20 * DAY_DURATION; + f.clock.set(&raised); + f.e.ledger().set_timestamp(10_000 + 30 * DAY_DURATION); + + refused( + f.rules.try_apply_change(&id, &f.admin), + ComplianceContractError::DelayBelowNotice, + ); +} + +#[test] +fn a_cancelled_change_never_lands() { + let f = finished(); + let id = f.rules.propose_change( + &ComplianceChange::BindToken(Address::generate(&f.e)), + &f.admin, + ); + + f.rules.cancel_change(&id, &f.admin); + + refused( + f.rules.try_apply_change(&id, &f.admin), + ComplianceContractError::ChangeNotFound, + ); + assert_eq!(f.rules.linked_tokens().len(), 0); +} + +#[test] +fn only_the_admin_proposes_cancels_and_applies() { + let f = finished(); + let stranger = Address::generate(&f.e); + let change = ComplianceChange::BindToken(Address::generate(&f.e)); + let id = f.propose(&change); + f.e.ledger().set_timestamp(10_000 + MIN_DELAY); + + f.e.set_auths(&[]); + assert!(f.rules.try_propose_change(&change, &stranger).is_err()); + assert!(f.rules.try_cancel_change(&id, &stranger).is_err()); + assert!(f.rules.try_apply_change(&id, &stranger).is_err()); + assert_eq!(f.rules.pending_changes().len(), 1); +} + +#[test] +fn proposing_and_applying_read_the_vault_clock_once() { + let f = finished(); + let before = f.clock.reads(); + + let id = f.propose(&ComplianceChange::BindToken(Address::generate(&f.e))); + assert_eq!(f.clock.reads(), before + 1); + + f.e.ledger().set_timestamp(10_000 + MIN_DELAY); + f.rules.apply_change(&id, &f.admin); + assert_eq!(f.clock.reads(), before + 2); +} + +#[test] +fn proposing_and_applying_publish_the_change() { + let f = finished(); + let change = ComplianceChange::BindToken(Address::generate(&f.e)); + + let id = f.propose(&change); + assert_eq!( + f.e.events().all().filter_by_contract(&f.rules.address), + [ChangeProposed { + id, + change: change.clone(), + eta: 10_000 + MIN_DELAY, + } + .to_xdr(&f.e, &f.rules.address)] + ); + + f.e.ledger().set_timestamp(10_000 + MIN_DELAY); + f.rules.apply_change(&id, &f.admin); + let events = f.e.events().all().filter_by_contract(&f.rules.address); + assert_eq!( + events.events().last(), + Some(&ChangeApplied { id, change }.to_xdr(&f.e, &f.rules.address)) + ); +} + +#[test] +fn setup_cannot_finish_while_a_hand_over_is_pending() { + let f = bound(); + let next = Address::generate(&f.e); + f.rules + .transfer_admin_role(&next, &(f.e.ledger().sequence() + 100)); + + refused( + f.rules.try_finish_setup(&f.admin), + ComplianceContractError::AdminTransferPending, + ); + + f.rules.accept_admin_transfer(); + assert_eq!(f.rules.get_admin(), Some(next.clone())); + f.rules.finish_setup(&next); + assert!(!f.rules.setup_open()); +} + +#[test] +fn a_cancelled_hand_over_no_longer_holds_setup_open() { + let f = bound(); + let next = Address::generate(&f.e); + f.rules + .transfer_admin_role(&next, &(f.e.ledger().sequence() + 100)); + f.rules.transfer_admin_role(&next, &0); + + f.rules.finish_setup(&f.admin); + + assert_eq!(f.rules.get_admin(), Some(f.admin.clone())); +} + +#[test] +fn after_setup_the_admin_hands_over_through_the_timelock() { + let f = finished(); + let next = Address::generate(&f.e); + refused( + f.rules + .try_transfer_admin_role(&next, &(f.e.ledger().sequence() + 100)), + ComplianceContractError::UseTimelock, + ); + + let id = f + .rules + .propose_change(&ComplianceChange::Admin(next.clone()), &f.admin); + f.e.ledger().set_timestamp(10_000 + MIN_DELAY); + f.rules.apply_change(&id, &f.admin); + assert_eq!(f.rules.get_admin(), Some(f.admin.clone())); + + f.rules.accept_admin_transfer(); + + assert_eq!(f.rules.get_admin(), Some(next)); +} + +#[test] +fn a_hand_over_after_setup_is_cancelled_at_once() { + let f = finished(); + let next = Address::generate(&f.e); + f.land(&ComplianceChange::Admin(next.clone())); + + f.rules.transfer_admin_role(&next, &0); + + assert!(f.rules.try_accept_admin_transfer().is_err()); + assert_eq!(f.rules.get_admin(), Some(f.admin.clone())); +} + +#[test] +fn the_hand_over_offer_is_open_on_its_last_ledger() { + let f = finished(); + let next = Address::generate(&f.e); + f.land(&ComplianceChange::Admin(next.clone())); + let offered_at = f.e.ledger().sequence(); + + f.e.ledger() + .with_mut(|l| l.sequence_number = offered_at + ADMIN_ACCEPT_LEDGERS); + f.rules.accept_admin_transfer(); + + assert_eq!(f.rules.get_admin(), Some(next)); +} + +#[test] +fn the_hand_over_offer_lasts_seven_days_of_ledgers() { + let f = finished(); + f.land(&ComplianceChange::Admin(Address::generate(&f.e))); + let offered_at = f.e.ledger().sequence(); + + f.e.ledger() + .with_mut(|l| l.sequence_number = offered_at + ADMIN_ACCEPT_LEDGERS + 1); + + assert_eq!( + f.rules.try_accept_admin_transfer(), + Err(Ok(RoleTransferError::NoPendingTransfer.into())) + ); + assert_eq!(f.rules.get_admin(), Some(f.admin.clone())); +} + +#[test] +fn every_admin_call_restores_the_instance_ttl() { + let f = unbound(); + let next = Address::generate(&f.e); + + f.restores_the_instance(|| { + f.rules + .transfer_admin_role(&next, &(f.e.ledger().sequence() + ADMIN_ACCEPT_LEDGERS)) + }); + f.restores_the_instance(|| f.rules.transfer_admin_role(&next, &0)); + f.restores_the_instance(|| f.rules.bind_vault(&f.clock.address, &f.admin)); + f.restores_the_instance(|| f.rules.finish_setup(&f.admin)); + + let change = ComplianceChange::BindToken(Address::generate(&f.e)); + let mut id = 0; + f.restores_the_instance(|| id = f.propose(&change)); + f.restores_the_instance(|| f.rules.cancel_change(&id, &f.admin)); + + let id = f.propose(&ComplianceChange::Admin(next)); + f.e.ledger().with_mut(|l| l.timestamp += MIN_DELAY); + f.restores_the_instance(|| f.rules.apply_change(&id, &f.admin)); + f.restores_the_instance(|| f.rules.accept_admin_transfer()); +} diff --git a/scripts/harness/deploy.ts b/scripts/harness/deploy.ts index 5398ad19..820611bb 100644 --- a/scripts/harness/deploy.ts +++ b/scripts/harness/deploy.ts @@ -202,6 +202,14 @@ async function grantAndVerify( throw new Error("the share token was not bound to the compliance contract") console.log("share_token bound to compliance") + // Binding the token is instant only during setup, so the compliance + // contract's setup closes after it. + await (await compliance.bind_vault({ vault, operator: caller })).signAndSend() + await (await compliance.finish_setup({ operator: caller })).signAndSend() + if ((await compliance.setup_open()).result) + throw new Error("the compliance contract's setup did not finish") + console.log("compliance bound to the vault, setup finished") + const { Client: NavOracle } = (await import( clientEntry("nav_oracle") )) as typeof NavOracleModule diff --git a/scripts/harness/extend.ts b/scripts/harness/extend.ts index 78cc4d1f..aa4bdaf6 100644 --- a/scripts/harness/extend.ts +++ b/scripts/harness/extend.ts @@ -35,6 +35,7 @@ const CONTRACTS_WITH_QUEUES = new Set([ "async_vault", "share_token", "nav_oracle", + "compliance", ]) /** diff --git a/scripts/harness/guards.test.ts b/scripts/harness/guards.test.ts index 36dbeef3..da66313e 100644 --- a/scripts/harness/guards.test.ts +++ b/scripts/harness/guards.test.ts @@ -4,6 +4,7 @@ import { beforeAll, describe, expect, it } from "vitest" import { pay, trustline } from "./lib/accounts.js" import { assetsFor, price, sharesFor, units } from "./lib/amounts.js" import { + complianceContract, connect, identityVerifier, oracle, @@ -301,6 +302,44 @@ describe("the oracle after setup", () => { }) }) +describe("the compliance contract after setup", () => { + const operator = () => governance.publicKey() + const canCreate = { tag: "CanCreate", values: undefined } as const + + it("governance cannot add a module at once", async () => { + const rules = complianceContract(c, governance) + expectRefused( + await rules.add_module_to({ + hook: canCreate, + module: outsider.publicKey(), + operator: operator(), + }), + rules, + "UseTimelock", + ) + expect( + (await rules.get_modules_for_hook({ hook: canCreate })).result, + ).toEqual([]) + }) + + it("queues a module behind the vault's delay", async () => { + const rules = complianceContract(c, governance) + const id = await send( + rules.propose_change({ + change: { tag: "AddModule", values: [canCreate, outsider.publicKey()] }, + operator: operator(), + }), + ) + expectRefused( + await rules.apply_change({ id, operator: operator() }), + rules, + "ChangeNotReady", + ) + await send(rules.cancel_change({ id, operator: operator() })) + expect((await rules.pending_changes()).result).toEqual([]) + }) +}) + describe("the vault after setup", () => { const caller = () => governance.publicKey() diff --git a/scripts/harness/lib/clients.ts b/scripts/harness/lib/clients.ts index d7e0963b..e011f846 100644 --- a/scripts/harness/lib/clients.ts +++ b/scripts/harness/lib/clients.ts @@ -2,6 +2,7 @@ import { Asset, Keypair, rpc } from "@stellar/stellar-sdk" import { basicNodeSigner } from "@stellar/stellar-sdk/contract" import { Client as VaultClient } from "../../../app-lib/clients/async_vault/dist/index.js" +import { Client as ComplianceClient } from "../../../app-lib/clients/compliance/dist/index.js" import { Client as IdentityVerifierClient } from "../../../app-lib/clients/identity_verifier/dist/index.js" import { Client as OracleClient } from "../../../app-lib/clients/nav_oracle/dist/index.js" import { Client as ShareTokenClient } from "../../../app-lib/clients/share_token/dist/index.js" @@ -61,6 +62,8 @@ export const shareToken = (c: Connected, as: Keypair) => new ShareTokenClient(options(c, "share_token", as)) export const identityVerifier = (c: Connected, as: Keypair) => new IdentityVerifierClient(options(c, "identity_verifier", as)) +export const complianceContract = (c: Connected, as: Keypair) => + new ComplianceClient(options(c, "compliance", as)) interface Sendable { simulation?: unknown From 78cfda3e474549cae9eb4772a4b919ae9c1d1a41 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:02:11 -0300 Subject: [PATCH 057/147] docs: put compliance rules behind the timelock --- README.md | 1 + docs/AUTHORIZATION_MATRIX.md | 29 ++++++++++++++--- docs/COMPLIANCE_MATRIX.md | 2 +- docs/TESTING.md | 6 ++-- docs/kit-spec.md | 42 ++++++++++++++++--------- docs/strata-product-and-architecture.md | 2 +- environments.toml | 10 +++--- 7 files changed, 64 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 24a766b2..11c655b8 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,7 @@ vault holds share_token manager role share_token bound to the vault, setup finished vault is allowlisted share_token bound to compliance +compliance bound to the vault, setup finished nav_oracle bound to the vault, setup finished vault setup finished health {"allowlisted":true,"compliance_bound":true,"feed_valid":false,"freshness_covers_notice":true,"oracle_bound":true,"setup_open":false,"sole_token_manager":true,"token_bound":true,"vault_unfrozen":true} diff --git a/docs/AUTHORIZATION_MATRIX.md b/docs/AUTHORIZATION_MATRIX.md index e7645aa2..4661fc24 100644 --- a/docs/AUTHORIZATION_MATRIX.md +++ b/docs/AUTHORIZATION_MATRIX.md @@ -1,13 +1,13 @@ # Authorization & Access Control Matrix -This document provides the complete authorization and role model specification for the Strata Vault Kit across all four contracts (`AsyncVault`, `ShareToken`, `NavOracle`, and `IdentityVerifier`). +This document provides the complete authorization and role model specification for the Strata Vault Kit across all five contracts (`AsyncVault`, `ShareToken`, `NavOracle`, `IdentityVerifier` and `Compliance`). --- ## 1. Architectural Principles 1. **Direct Role Membership Checks**: Role membership is checked directly via `has_role(e, &caller, &role)` or `stellar_macros::only_role` / `only_admin`, never by enumerating members. -2. **Strict Caller Authentication**: No entrypoint takes a caller argument that it does not authenticate. Every function accepting a `caller: Address` executes `caller.require_auth()`. +2. **Strict Caller Authentication**: No entrypoint takes a caller argument that it does not authenticate. Every function accepting a `caller: Address` executes `caller.require_auth()`. The testnet compliance stub is the exception: its admin calls check the stored admin's signature and ignore their `operator` argument (§5.5). 3. **Exit guarantee, with one hold**: A covered redemption claim pays out cash regardless of pause, staleness or allowlist status. A delisted investor exits through the cash path and cannot re-enter share circulation. A frozen investor is held: the vault refuses their requests, cancellations and claims, and pays them only after the freeze lifts. 4. **Five Separate Authorities**: - **Governance (Admin)**: Root authority; upgrades, timelock parameters, custodian settings, unpause, oracle parameters, emergency NAV pause reset. As the share token's admin, it co-signs compliance's `legal_transfer` and `recover_balance`. @@ -37,8 +37,9 @@ 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. That no other account holds it is checked by the vault's `finish_setup` below. - 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). +- It calls `compliance.bind_token({ token, operator: governance })`, then `compliance.bind_vault({ vault, operator: governance })` and `compliance.finish_setup({ operator: governance })`, and stops if `compliance.setup_open()` still reads true. From then on the compliance contract's module, token-binding and admin changes wait for the vault's timelock (§5.5). - 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). -- Last, it calls `vault.set_custodian({ custodian, caller: governance })` and `vault.finish_setup({ caller: governance })`, and stops if `vault.setup_open()` still reads true. `finish_setup` refuses with `SetupIncomplete` unless the token and the oracle report this vault with their setup closed, the vault is the token's only `manager`, the token has no shares outstanding, the vault passes the identity check, its address is not frozen on the token and the token is bound to its compliance contract. From then on the vault takes deposits and its admin changes wait for its timelock (§5.1). +- Last, it calls `vault.set_custodian({ custodian, caller: governance })` and `vault.finish_setup({ caller: governance })`, and stops if `vault.setup_open()` still reads true. `finish_setup` refuses with `SetupIncomplete` unless the token and the oracle report this vault with their setup closed, the vault is the token's only `manager`, the token has no shares outstanding, the vault passes the identity check, its address is not frozen on the token and the token is bound to its compliance contract. A compliance contract that answers `vault()` and `setup_open()` must report this vault with its setup closed; one that does not answer them is an external module and is accepted. From then on the vault takes deposits and its admin changes wait for its timelock (§5.1). --- @@ -72,7 +73,7 @@ The following entrypoints are open to any caller on purpose: --- -## 5. Complete Access Control Matrix Across All 4 Contracts +## 5. Complete Access Control Matrix Across All 5 Contracts ### 5.1 AsyncVault (`contracts/async-vault`) @@ -90,7 +91,7 @@ Tests: [`controls.rs`](../contracts/async-vault/src/test/controls.rs), [`epochs. | `set_notice` | `#[only_admin]`, `caller.require_auth()` | `secs <= MAX_NOTICE_SECS`, `secs <= upgrade_delay`. Raising is instant; lowering is `UseTimelock` after setup | `test::governance::raising_the_notice_is_instant_and_lowering_waits`, `test::notice::setting_the_notice_needs_governance_authorisation` | | `set_wind_down_delay` | `#[only_admin]`, `caller.require_auth()` | `MIN_WIND_DOWN_DELAY <= secs <= MAX_WIND_DOWN_DELAY`. Raising is instant; lowering is `UseTimelock` after setup | `test::governance::raising_the_wind_down_delay_is_instant_and_lowering_waits`, `test::governance::a_wind_down_delay_below_the_minimum_cannot_be_proposed`, `test::wind_down::only_governance_sets_the_delay` | | `set_deposit_cap` | `#[only_admin]`, `caller.require_auth()` | Not negative. Instant both ways, also after setup | `test::governance::the_deposit_cap_stays_instant_both_ways` | -| `finish_setup` | `#[only_admin]`, `caller.require_auth()` | Once. The token and the oracle report this vault with their setup closed, the vault is the token's only `manager`, the token's `total_supply` is zero, the vault passes `verify_identity`, the token's `is_frozen` is false for the vault, and the token is in its compliance contract's `linked_tokens`; no admin hand-over offered during setup is pending | `test::setup::every_wiring_step_is_checked_before_setup_finishes`, `test::setup::setup_cannot_finish_while_a_hand_over_is_pending`, `test::setup::setup_never_finishes_twice`, `test::setup::only_governance_finishes_setup` | +| `finish_setup` | `#[only_admin]`, `caller.require_auth()` | Once. The token and the oracle report this vault with their setup closed, the vault is the token's only `manager`, the token's `total_supply` is zero, the vault passes `verify_identity`, the token's `is_frozen` is false for the vault, and the token is in its compliance contract's `linked_tokens`; a compliance contract that answers `vault()` and `setup_open()` reports this vault with its setup closed (one that does not answer them is an external module and is accepted); no admin hand-over offered during setup is pending | `test::setup::every_wiring_step_is_checked_before_setup_finishes`, `test::setup::a_compliance_contract_from_outside_the_kit_is_accepted`, `test::setup::setup_cannot_finish_while_a_hand_over_is_pending`, `test::setup::setup_never_finishes_twice`, `test::setup::only_governance_finishes_setup` | | `grant_role` / `transfer_admin_role` | Admin | Instant during setup, `UseTimelock` after. A zero `live_until_ledger` cancels instantly | `test::governance::grants_and_manager_revokes_wait_for_the_timelock`, `test::governance::an_admin_hand_over_waits_but_cancelling_one_does_not` | | `revoke_role` | Admin | `treasury`, `guardian`: instant. `manager`: instant during setup, `UseTimelock` after | `test::governance::grants_and_manager_revokes_wait_for_the_timelock` (instant case: `test::governance::revoking_treasury_or_guardian_stays_instant`; caller check: `test::governance::only_the_admin_revokes_a_role`) | | `set_role_admin` | — | Always refused (`UseTimelock`) | `test::governance::no_role_ever_gets_a_role_admin` | @@ -173,3 +174,21 @@ Tests: [`contracts/identity-verifier/src/test.rs`](../contracts/identity-verifie |---|---|---|---| | `allow` | `#[only_admin]` (the compliance key), `caller.require_auth()` | None | `test::only_admin_can_allow` | | `verify_identity` | Open check (Internal hook) | `is_allowed(account) == true` | `test::verify_identity_checks_allowlist` | + +### 5.5 Compliance (`contracts/compliance`) + +A testnet-only stub. Its admin is governance. Module changes, token bindings and admin hand-overs are instant during setup and wait for the vault's timelock after `finish_setup`. Each admin call checks the stored admin's signature; its `operator` argument is not checked. + +Tests: [`contracts/compliance/src/test/changes.rs`](../contracts/compliance/src/test/changes.rs). + +| Entrypoint | Access Control / Caller Auth | State & Precondition Guards | Tested Refusal (Test Name) | +|---|---|---|---| +| `bind_vault` | `#[only_admin]` | During setup, once; the vault must answer `timelock_clock` | `test::changes::a_second_vault_or_a_late_vault_is_refused`, `test::changes::an_address_that_reports_no_clock_cannot_be_bound`, `test::changes::only_the_admin_binds_and_finishes` | +| `finish_setup` | `#[only_admin]` | Needs a bound vault and no pending admin hand-over; once | `test::changes::setup_cannot_finish_without_a_vault`, `test::changes::setup_cannot_finish_while_a_hand_over_is_pending`, `test::changes::a_second_vault_or_a_late_vault_is_refused` | +| `bind_token`, `unbind_token`, `add_module_to`, `remove_module_from` | `#[only_admin]` | Instant during setup; `UseTimelock` after | `test::changes::after_setup_rule_changes_wait_for_the_timelock` | +| `propose_change` | `#[only_admin]` | After setup; vault not winding down | `test::changes::proposals_wait_until_setup_is_finished`, `test::changes::only_the_admin_proposes_cancels_and_applies` | +| `cancel_change` | `#[only_admin]` | The change must be pending | `test::changes::a_cancelled_change_never_lands` | +| `apply_change` | `#[only_admin]` | Delay run on the vault's clock, vault unpaused and not winding down, delay `>=` the vault's notice | `test::changes::a_proposed_binding_lands_after_the_delay`, `test::changes::nothing_lands_while_the_vault_is_paused_or_winding_down`, `test::changes::a_vault_pause_pushes_the_change_back`, `test::changes::a_change_shorter_than_a_raised_notice_never_lands` | +| `pending_changes` | Open view | None | None (view) | +| `transfer_admin_role` | Admin | Instant during setup; `UseTimelock` after. A zero `live_until_ledger` cancels at once | `test::changes::after_setup_the_admin_hands_over_through_the_timelock`, `test::changes::a_cancelled_hand_over_no_longer_holds_setup_open`, `test::changes::a_hand_over_after_setup_is_cancelled_at_once` | +| `accept_admin_transfer` | The pending admin's auth | A hand-over is pending; an applied `Admin` change stays open for seven days of ledgers | `test::changes::setup_cannot_finish_while_a_hand_over_is_pending`, `test::changes::the_hand_over_offer_lasts_seven_days_of_ledgers` | diff --git a/docs/COMPLIANCE_MATRIX.md b/docs/COMPLIANCE_MATRIX.md index 2be50f78..e408cb02 100644 --- a/docs/COMPLIANCE_MATRIX.md +++ b/docs/COMPLIANCE_MATRIX.md @@ -98,7 +98,7 @@ The share token is OpenZeppelin's RWA token. The allowlist and freeze checks sit 0.7.2 has five compliance hooks: `can_transfer` and `can_create` are asked before the state change, and `transferred`, `created` and `destroyed` are told after it. There is no `can_destroy`. -The kit's compliance contract registers no modules, so `can_transfer` and `can_create` always allow today. Until rule modules are added, the allowlist and freeze rules are exactly the token checks in the table above. +The kit's compliance contract registers no modules, so `can_transfer` and `can_create` always allow today. Until rule modules are added, the allowlist and freeze rules are exactly the token checks in the table above. Adding or removing a module waits for the vault's timelock once its setup is finished, so a rule change never lands sooner than an exit completes. --- diff --git a/docs/TESTING.md b/docs/TESTING.md index c10d67e3..1a6a2d47 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -47,7 +47,7 @@ contract extends, who pays, and how to restore an archived one. | Persistent entries round-trip and get the full TTL on write | [`crates/storage/src/test.rs`](../crates/storage/src/test.rs) | `persistent_round_trips`, `writing_sets_the_full_ttl` | | A read restores a decayed entry; a missing entry is not created | [`crates/storage/src/test.rs`](../crates/storage/src/test.rs) | `reading_a_decayed_entry_restores_the_full_ttl`, `reading_a_missing_entry_creates_nothing` | | Instance writes and reads extend the contract instance | [`crates/storage/src/test.rs`](../crates/storage/src/test.rs) | `instance_writes_bump_the_instance`, `reading_the_instance_restores_its_ttl` | -| The share token, identity verifier and compliance contract extend their own instance on every call that does work | [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.rs), [`contracts/share-token/src/test_governance.rs`](../contracts/share-token/src/test_governance.rs), [`contracts/identity-verifier/src/test.rs`](../contracts/identity-verifier/src/test.rs), [`contracts/compliance/src/test.rs`](../contracts/compliance/src/test.rs) | `a_transfer_restores_the_instance_ttl`, `the_vaults_mint_burn_and_escrow_restore_the_instance_ttl`, `every_admin_call_restores_the_instance_ttl`, `allowing_and_checking_restore_the_instance_ttl`, `the_hooks_a_token_calls_after_moving_shares_restore_the_instance_ttl` | +| The share token, identity verifier and compliance contract extend their own instance on every call that does work | [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.rs), [`contracts/share-token/src/test_governance.rs`](../contracts/share-token/src/test_governance.rs), [`contracts/identity-verifier/src/test.rs`](../contracts/identity-verifier/src/test.rs), [`contracts/compliance/src/test.rs`](../contracts/compliance/src/test.rs), [`contracts/compliance/src/test/changes.rs`](../contracts/compliance/src/test/changes.rs) | `a_transfer_restores_the_instance_ttl`, `the_vaults_mint_burn_and_escrow_restore_the_instance_ttl`, `every_admin_call_restores_the_instance_ttl`, `allowing_and_checking_restore_the_instance_ttl`, `the_hooks_a_token_calls_after_moving_shares_restore_the_instance_ttl` | | Vault requests and epochs are written with the full TTL | [`contracts/async-vault/src/test/ttl.rs`](../contracts/async-vault/src/test/ttl.rs) | `a_request_and_its_epoch_are_written_with_the_full_ttl`, `a_redemption_request_is_written_with_the_full_ttl` | | 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` | @@ -82,6 +82,7 @@ nothing. | 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` | +| Compliance contract: only the admin binds the vault, finishes setup and runs the timelock | [`contracts/compliance/src/test/changes.rs`](../contracts/compliance/src/test/changes.rs) | `only_the_admin_binds_and_finishes`, `only_the_admin_proposes_cancels_and_applies` | | Vault: only the admin finishes setup; after it, the custodian, grants, `manager` revokes, hand-overs and a lower notice or wind-down delay go through the timelock, while revoking the treasury or the guardian stays instant | [`contracts/async-vault/src/test/setup.rs`](../contracts/async-vault/src/test/setup.rs), [`contracts/async-vault/src/test/governance.rs`](../contracts/async-vault/src/test/governance.rs) | `only_governance_finishes_setup`, `the_custodian_changes_through_the_timelock`, `grants_and_manager_revokes_wait_for_the_timelock`, `revoking_treasury_or_guardian_stays_instant`, `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` | @@ -99,6 +100,7 @@ so time rules and TTL can be reached in one test. | 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` | +| So do the compliance contract's module, token-binding and admin changes, once its setup is finished | [`contracts/compliance/src/test/changes.rs`](../contracts/compliance/src/test/changes.rs) | `after_setup_rule_changes_wait_for_the_timelock`, `a_proposed_binding_lands_after_the_delay`, `a_vault_pause_pushes_the_change_back`, `nothing_lands_while_the_vault_is_paused_or_winding_down` | | The vault's own changes wait on its clock: a raised notice blocks a shorter change, a pause holds one, a wind-down refuses them, and a wind-down waits at least seven days | [`contracts/async-vault/src/test/governance.rs`](../contracts/async-vault/src/test/governance.rs), [`contracts/async-vault/src/test/wind_down.rs`](../contracts/async-vault/src/test/wind_down.rs) | `a_change_shorter_than_a_raised_notice_never_lands`, `a_pending_role_change_lands_after_a_pause`, `nothing_timelocked_moves_during_a_wind_down_a_manager_revoke_included`, `an_unset_delay_still_waits_the_minimum` | | 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` | @@ -125,7 +127,7 @@ fixture wires them as the deploy does and finishes the vault's setup; | The vault mints and burns shares through its role on the share token | [`contracts/async-vault/src/test/constructor.rs`](../contracts/async-vault/src/test/constructor.rs), [`contracts/async-vault/src/test/redeem.rs`](../contracts/async-vault/src/test/redeem.rs) | `the_vault_holds_the_manager_role_on_the_share_token`, `claim_redeem_pays_assets_and_burns_the_shares` | | 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` | -| The vault finishes setup only once the token and the oracle report it with their own setup closed, it is the token's only `manager`, the token has no shares outstanding, it passes the identity check, its address is not frozen and the token is bound to compliance; before that it takes no deposit | [`contracts/async-vault/src/test/setup.rs`](../contracts/async-vault/src/test/setup.rs) | `every_wiring_step_is_checked_before_setup_finishes`, `a_deposit_before_setup_finishes_is_refused`, `setup_cannot_finish_while_a_hand_over_is_pending` | +| The vault finishes setup only once the token and the oracle report it with their own setup closed, it is the token's only `manager`, the token has no shares outstanding, it passes the identity check, its address is not frozen and the token is bound to compliance, which runs on this vault's clock with its setup closed unless it is an external module that does not answer `VaultBinding`; before that it takes no deposit | [`contracts/async-vault/src/test/setup.rs`](../contracts/async-vault/src/test/setup.rs) | `every_wiring_step_is_checked_before_setup_finishes`, `a_compliance_contract_from_outside_the_kit_is_accepted`, `a_deposit_before_setup_finishes_is_refused`, `setup_cannot_finish_while_a_hand_over_is_pending` | | 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; 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 043c490d..e14f9e7c 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -85,7 +85,7 @@ The contracts enforce a set of mathematical and state-machine invariants across - **Testable Property**: - Upgrade delay $\in [7 \text{ days}, 90 \text{ days}]$ and $\text{delay} \ge \text{notice}$, checked when a delay change is proposed and again when it applies. - Wind-down delay $\in [7 \text{ days}, 90 \text{ days}]$ (`WindDownDelayTooShort`, `WindDownDelayTooLong`); 7 days when none was set. - - The vault takes no deposit until its one-time setup closes (`SetupOpen`). `finish_setup` refuses with `SetupIncomplete` until the share token and the oracle report this vault with their own setup closed, the vault is the token's only `manager`, the token has no shares outstanding, the vault passes the token's identity check, the token has not frozen the vault's address, and the token is bound to its compliance contract. Shares minted before setup closes would redeem against depositors' money. It refuses with `AdminTransferPending` while an admin hand-over offered during setup is pending. Setup never reopens (`SetupClosed`). + - The vault takes no deposit until its one-time setup closes (`SetupOpen`). `finish_setup` refuses with `SetupIncomplete` until the share token and the oracle report this vault with their own setup closed, the vault is the token's only `manager`, the token has no shares outstanding, the vault passes the token's identity check, the token has not frozen the vault's address, and the token is bound to its compliance contract. A compliance contract that answers `vault()` and `setup_open()` must report this vault with its own setup closed; one that does not answer them is an external module and is accepted. Shares minted before setup closes would redeem against depositors' money. It refuses with `AdminTransferPending` while an admin hand-over offered during setup is pending. Setup never reopens (`SetupClosed`). - After setup, the vault's custodian, role grants, revoking `manager`, admin hand-overs and lowering the notice or the wind-down delay go through the queue; a direct call is refused with `UseTimelock`. Raising the notice or the wind-down delay, the deposit cap, `pause`/`unpause` and revoking any other role stay instant. A `Notice` or `WindDownDelay` change is checked against its bounds when proposed and again when it applies. - Every change waits in the queue (`propose_change` → `apply_change`). Applying before its eta reverts with `ChangeNotReady`. - A change whose delay is shorter than the notice standing at apply time reverts with `UpgradeDelayBelowNotice`. @@ -95,6 +95,7 @@ The contracts enforce a set of mathematical and state-machine invariants across - 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. + - Once its one-time setup closes, the compliance contract's module changes, token bindings and admin hand-overs wait on the vault's clock under the same rules; a direct call is refused with `UseTimelock`. ### 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. @@ -175,7 +176,7 @@ The `async_vault` constructor strictly verifies pairwise separation across autho | `sole_token_manager` | This vault is the share token's only `manager`. | | `allowlisted` | This vault passes the share token's identity verifier, so it can hold escrowed shares. A delisted holder's deposit converts to an exit by minting the shares to the vault's own address, so that conversion needs this flag true. | | `vault_unfrozen` | The share token has not frozen this vault's address. A frozen vault could not hand escrowed shares back when a holder cancels a redemption. | -| `compliance_bound` | The share token is in its compliance contract's `linked_tokens`. | +| `compliance_bound` | The share token is in its compliance contract's `linked_tokens`, and a compliance contract that answers `vault()` and `setup_open()` names this vault with its own setup finished. One that does not answer them is an external module and passes; its own governance is a trust assumption. | | `feed_valid` | The oracle reports `Valid`: a report exists, it has not expired and the NAV is not paused. | | `freshness_covers_notice` | The oracle's `freshness_duration` is at least the vault's notice. | @@ -191,7 +192,7 @@ Strata distributes control across five native Stellar multisig authorities and p | Authority | Quorum / Profile | Authorized Capabilities | Anti-Capabilities (What It Cannot Do) | |---|---|---|---| -| **Governance** | High-quorum deliberative multisig | Set parameters (`notice`, `custodian`, `deposit_cap`, `wind_down_delay`), propose/apply upgrades, propose/cancel wind-down, unpause the vault, pause/unpause the share token, transfer admin, finish each contract's one-time setup, co-sign compliance's `legal_transfer` and `recover_balance` as the share token's admin. After setup, these wait for the timelock: role grants, revoking the manager, admin hand-over, the custodian, lowering the notice or the wind-down delay, upgrades, the token's compliance and verifier swaps, and oracle config changes other than a shorter freshness window. Revoking the treasury or the guardian stays instant, because taking a power away moves no value. | **Cannot pause the vault directly**; cannot move funds; cannot initiate custodian transfers; cannot attest prices; cannot bypass timelocks. | +| **Governance** | High-quorum deliberative multisig | Set parameters (`notice`, `custodian`, `deposit_cap`, `wind_down_delay`), propose/apply upgrades, propose/cancel wind-down, unpause the vault, pause/unpause the share token, transfer admin, finish each contract's one-time setup, co-sign compliance's `legal_transfer` and `recover_balance` as the share token's admin. After setup, these wait for the timelock: role grants, revoking the manager, admin hand-over, the custodian, lowering the notice or the wind-down delay, upgrades, the token's compliance and verifier swaps, the compliance contract's module and token-binding changes, and oracle config changes other than a shorter freshness window. Revoking the treasury or the guardian stays instant, because taking a power away moves no value. | **Cannot pause the vault directly**; cannot move funds; cannot initiate custodian transfers; cannot attest prices; cannot bypass timelocks. | | **Guardian** | Low-threshold fast-reaction multisig | `pause` (vault); `pause_nav` (oracle emergency price freeze). | **Cannot unpause**; cannot move funds; cannot block redemption requests; cannot block payable claims or cancellations. | | **Treasury** | Operational capital multisig | `deploy_to_custodian` (moves free reserve); `fund` (returns assets from custodian). | **Cannot deploy while anything is uncovered**; cannot touch escrowed subscription assets; cannot alter parameters or pause. | | **Attester** | High-security attestation multisig | `attest` (submits NAV report with proof reference). | **Cannot price outside deviation band**; cannot violate cooldown; cannot operate on the vault directly. | @@ -270,6 +271,15 @@ Strata distributes control across five native Stellar multisig authorities and p | **`ShareToken`** | `set_role_admin` | None | Always refused (`UseTimelock`) | N/A | | **`IdentityVerifier`** | `allow` | Admin (Compliance) | `#[only_admin]` | No | | **`IdentityVerifier`** | `set_claim_topics_and_issuers`| Admin (Compliance) | `#[only_admin]` | No | +| **`Compliance`** | `bind_token`, `unbind_token`, `add_module_to`, `remove_module_from` | Governance | `#[only_admin]`; instant during setup, then `propose_change` | No | +| **`Compliance`** | `bind_vault` | Governance (compliance admin) | `#[only_admin]`; during setup, once | No | +| **`Compliance`** | `finish_setup` | Governance | `#[only_admin]`; needs a bound vault and no pending admin hand-over | No | +| **`Compliance`** | `propose_change` | Governance | `#[only_admin]`; after setup | No | +| **`Compliance`** | `cancel_change` | Governance | `#[only_admin]` | No | +| **`Compliance`** | `apply_change` | Governance | `#[only_admin]`; after the vault's delay | Refused while the vault is paused or winding down | +| **`Compliance`** | `pending_changes` | Permissionless | None (view) | No | +| **`Compliance`** | `transfer_admin_role` | Governance | Instant during setup, then `propose_change` (a zero `live_until_ledger` cancels at once) | No | +| **`Compliance`** | `accept_admin_transfer` | Proposed Admin | The pending admin's `require_auth()` | No | --- @@ -283,7 +293,7 @@ A clean deployment executes in three sequenced phases: - Call `ShareToken::grant_role("manager", vault_address)` to authorize lazy share minting and burning. - 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 `Compliance::bind_token(share_token_address)` to link SEP-57 rule verification, then `Compliance::bind_vault(vault_address)` and `Compliance::finish_setup()`. Its module and binding changes are instant until then and wait for the vault's timelock afterwards. - 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. - On the vault, call `set_custodian(custodian)`, `set_notice(secs)` and, if initial capital intake is bounded, `set_deposit_cap(cap)`. All three are instant during setup, so deposits never open without the cap. - Then call `finish_setup()` on the vault. It refuses with `SetupIncomplete` until every wiring step above is in place, the token has no shares outstanding and the vault's address is not frozen on it, and with `AdminTransferPending` while an admin hand-over offered during setup is pending. The vault takes no deposit before it. @@ -304,7 +314,7 @@ instance is archived refuses every call. | `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`, `legal_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 | +| `compliance` | Its instance, and each pending governance change it reads or writes | Every hook the token calls (`can_create`, `can_transfer`, `created`, `transferred`, `destroyed`) and its admin calls | Same | The lengths are the constants in `crates/storage` (`PERSISTENT_EXTEND_AMOUNT`, `INSTANCE_EXTEND_AMOUNT`, and thresholds one day @@ -312,7 +322,7 @@ 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`, `share_token` or `nav_oracle` lives as a persistent entry, +`async_vault`, `share_token`, `nav_oracle` or `compliance` 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 @@ -372,14 +382,18 @@ 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 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. - -The vault's `finish_setup` reads both through `VaultBinding` and refuses until -each reports this vault with its own setup closed. It also reads the token's +Two more traits connect the share token, the oracle and the kit's compliance +contract to the vault's timelock. The vault implements `ClockSource` +(`crates/timelock`), and the three read its `timelock_clock` to time their own +changes. Each implements `VaultBinding` (`crates/bindings`): `vault` names the +vault it is bound to, and `setup_open` says whether its admin changes are still +instant. + +The vault's `finish_setup` reads the token and the oracle through +`VaultBinding` and refuses until each reports this vault with its own setup +closed. It reads the compliance contract the same way, but accepts one that +does not answer `VaultBinding`: such an external module keeps its own +governance, which is a trust assumption. It also reads the token's `manager` members (OpenZeppelin `AccessControl`) and its `total_supply` (`FungibleToken`), runs `verify_identity` on its own address, and reads `linked_tokens` from the token's compliance contract (OpenZeppelin diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index 8228d95d..cd2caba9 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -598,7 +598,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 vault's own custodian, role grants, `manager` revocations, admin handovers and lowering of the notice or the wind-down delay wait on that clock once its one-time setup closes, and it takes no deposit before then; revoking its treasury or guardian stays instant. Setup closes only once the token and the oracle run on the vault's clock with their own setup closed, the vault is the token's only minter, no shares exist yet (shares minted earlier would redeem against depositors' money), the vault passes the identity check, its address is not frozen on the token and the token is bound to its compliance contract. 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. | +| 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 vault's own custodian, role grants, `manager` revocations, admin handovers and lowering of the notice or the wind-down delay wait on that clock once its one-time setup closes, and it takes no deposit before then; revoking its treasury or guardian stays instant. Setup closes only once the token and the oracle run on the vault's clock with their own setup closed, the vault is the token's only minter, no shares exist yet (shares minted earlier would redeem against depositors' money), the vault passes the identity check, its address is not frozen on the token and the token is bound to its compliance contract; the kit's own compliance contract must also run on the vault's clock with its setup closed. 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. On the kit's compliance contract, adding or removing a module, binding or unbinding a token and handing over the admin wait on the same clock once its setup closes. | | Mis-wired deployment | A vault goes live without every binding wired | `health()` reports each wiring step; `finish_setup` refuses until all hold, and the vault takes no deposit before that | | 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 | diff --git a/environments.toml b/environments.toml index 72e244f7..8049cff7 100644 --- a/environments.toml +++ b/environments.toml @@ -54,11 +54,11 @@ constructor_args = '--asset share_token --share_token share_token --oracle nav_o # scripts/harness performs and verifies both. # # Setup cannot finish from this file either. The vault's `finish_setup` checks -# the token and oracle bindings, the manager role, the allowlist and the -# compliance binding, which only the harness deploy wires, and that the token -# has no shares outstanding. A deploy made only from this file leaves the -# token's, the oracle's and the vault's setup open, so its vault refuses -# deposits with `SetupOpen`. The harness deploy closes all three. +# the token, oracle and compliance bindings, the manager role and the +# allowlist, which only the harness deploy wires, and that the token has no +# shares outstanding. A deploy made only from this file leaves the compliance +# contract's, the token's, the oracle's and the vault's setup open, so its +# vault refuses deposits with `SetupOpen`. The harness deploy closes all four. ### Staging environment configuration From 89c3f7d40dd25d4e774c885dac8a9f1af08c7cf4 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:31:59 -0300 Subject: [PATCH 058/147] fix(compliance): address timelock review round 1 --- contracts/compliance/src/test/changes.rs | 78 ++++++++++++++++++++++-- docs/AUTHORIZATION_MATRIX.md | 4 +- docs/TESTING.md | 2 +- docs/strata-product-and-architecture.md | 9 ++- scripts/harness/guards.test.ts | 3 - 5 files changed, 82 insertions(+), 14 deletions(-) diff --git a/contracts/compliance/src/test/changes.rs b/contracts/compliance/src/test/changes.rs index 2c5bc55f..31b6e9d1 100644 --- a/contracts/compliance/src/test/changes.rs +++ b/contracts/compliance/src/test/changes.rs @@ -1,7 +1,7 @@ use soroban_sdk::{ contract, contractimpl, symbol_short, - testutils::{Address as _, Events as _, Ledger as _}, - vec, Address, Env, Event as _, + testutils::{Address as _, Events as _, Ledger as _, MockAuth, MockAuthInvoke}, + vec, Address, Env, Event as _, IntoVal, Val, Vec, }; use stellar_access::role_transfer::RoleTransferError; use stellar_tokens::rwa::compliance::ComplianceHook; @@ -69,6 +69,20 @@ impl Fixture<'_> { self.rules.apply_change(&id, &self.admin); } + /// Signs the next call to the compliance contract as `who` and nothing + /// else, so the call's own authorisation check is what refuses it. + fn sign_only_as(&self, who: &Address, fn_name: &str, args: Vec) { + self.e.mock_auths(&[MockAuth { + address: who, + invoke: &MockAuthInvoke { + contract: &self.rules.address, + fn_name, + args, + sub_invokes: &[], + }, + }]); + } + /// 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()) { @@ -192,12 +206,17 @@ fn the_contract_answers_the_view_the_vault_reads() { #[test] fn only_the_admin_binds_and_finishes() { let f = unbound(); - let stranger = Address::generate(&f.e); f.e.set_auths(&[]); - - assert!(f.rules.try_bind_vault(&f.clock.address, &stranger).is_err()); - assert!(f.rules.try_finish_setup(&stranger).is_err()); + assert!(f + .rules + .try_bind_vault(&f.clock.address, &Address::generate(&f.e)) + .is_err()); assert_eq!(f.rules.vault(), None); + + let f = bound(); + f.e.set_auths(&[]); + assert!(f.rules.try_finish_setup(&Address::generate(&f.e)).is_err()); + assert!(f.rules.setup_open()); } #[test] @@ -458,6 +477,53 @@ fn setup_cannot_finish_while_a_hand_over_is_pending() { assert!(!f.rules.setup_open()); } +#[test] +fn only_the_admin_offers_or_cancels_a_hand_over() { + let f = bound(); + let next = Address::generate(&f.e); + let stranger = Address::generate(&f.e); + let live_until = f.e.ledger().sequence() + 100; + + f.sign_only_as( + &stranger, + "transfer_admin_role", + (next.clone(), live_until).into_val(&f.e), + ); + assert!(f.rules.try_transfer_admin_role(&next, &live_until).is_err()); + f.e.mock_all_auths(); + assert!(f.rules.try_accept_admin_transfer().is_err()); + + f.rules.transfer_admin_role(&next, &live_until); + f.sign_only_as( + &stranger, + "transfer_admin_role", + (next.clone(), 0_u32).into_val(&f.e), + ); + assert!(f.rules.try_transfer_admin_role(&next, &0).is_err()); + + f.e.mock_all_auths(); + f.rules.accept_admin_transfer(); + assert_eq!(f.rules.get_admin(), Some(next)); +} + +#[test] +fn only_the_nominee_accepts_a_hand_over() { + let f = bound(); + let next = Address::generate(&f.e); + f.rules + .transfer_admin_role(&next, &(f.e.ledger().sequence() + 100)); + + for other in [Address::generate(&f.e), f.admin.clone()] { + f.sign_only_as(&other, "accept_admin_transfer", Vec::new(&f.e)); + assert!(f.rules.try_accept_admin_transfer().is_err()); + } + assert_eq!(f.rules.get_admin(), Some(f.admin.clone())); + + f.sign_only_as(&next, "accept_admin_transfer", Vec::new(&f.e)); + f.rules.accept_admin_transfer(); + assert_eq!(f.rules.get_admin(), Some(next)); +} + #[test] fn a_cancelled_hand_over_no_longer_holds_setup_open() { let f = bound(); diff --git a/docs/AUTHORIZATION_MATRIX.md b/docs/AUTHORIZATION_MATRIX.md index 4661fc24..2bbbc499 100644 --- a/docs/AUTHORIZATION_MATRIX.md +++ b/docs/AUTHORIZATION_MATRIX.md @@ -190,5 +190,5 @@ Tests: [`contracts/compliance/src/test/changes.rs`](../contracts/compliance/src/ | `cancel_change` | `#[only_admin]` | The change must be pending | `test::changes::a_cancelled_change_never_lands` | | `apply_change` | `#[only_admin]` | Delay run on the vault's clock, vault unpaused and not winding down, delay `>=` the vault's notice | `test::changes::a_proposed_binding_lands_after_the_delay`, `test::changes::nothing_lands_while_the_vault_is_paused_or_winding_down`, `test::changes::a_vault_pause_pushes_the_change_back`, `test::changes::a_change_shorter_than_a_raised_notice_never_lands` | | `pending_changes` | Open view | None | None (view) | -| `transfer_admin_role` | Admin | Instant during setup; `UseTimelock` after. A zero `live_until_ledger` cancels at once | `test::changes::after_setup_the_admin_hands_over_through_the_timelock`, `test::changes::a_cancelled_hand_over_no_longer_holds_setup_open`, `test::changes::a_hand_over_after_setup_is_cancelled_at_once` | -| `accept_admin_transfer` | The pending admin's auth | A hand-over is pending; an applied `Admin` change stays open for seven days of ledgers | `test::changes::setup_cannot_finish_while_a_hand_over_is_pending`, `test::changes::the_hand_over_offer_lasts_seven_days_of_ledgers` | +| `transfer_admin_role` | Admin | Instant during setup; `UseTimelock` after. A zero `live_until_ledger` cancels at once | `test::changes::only_the_admin_offers_or_cancels_a_hand_over`, `test::changes::after_setup_the_admin_hands_over_through_the_timelock`, `test::changes::a_cancelled_hand_over_no_longer_holds_setup_open`, `test::changes::a_hand_over_after_setup_is_cancelled_at_once` | +| `accept_admin_transfer` | The pending admin's auth | A hand-over is pending; an applied `Admin` change stays open for seven days of ledgers | `test::changes::only_the_nominee_accepts_a_hand_over`, `test::changes::setup_cannot_finish_while_a_hand_over_is_pending`, `test::changes::the_hand_over_offer_lasts_seven_days_of_ledgers` | diff --git a/docs/TESTING.md b/docs/TESTING.md index 1a6a2d47..310827f4 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -82,7 +82,7 @@ nothing. | 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` | -| Compliance contract: only the admin binds the vault, finishes setup and runs the timelock | [`contracts/compliance/src/test/changes.rs`](../contracts/compliance/src/test/changes.rs) | `only_the_admin_binds_and_finishes`, `only_the_admin_proposes_cancels_and_applies` | +| Compliance contract: only the admin binds the vault, finishes setup, runs the timelock and offers or cancels a hand-over; only the nominee accepts it | [`contracts/compliance/src/test/changes.rs`](../contracts/compliance/src/test/changes.rs) | `only_the_admin_binds_and_finishes`, `only_the_admin_proposes_cancels_and_applies`, `only_the_admin_offers_or_cancels_a_hand_over`, `only_the_nominee_accepts_a_hand_over` | | Vault: only the admin finishes setup; after it, the custodian, grants, `manager` revokes, hand-overs and a lower notice or wind-down delay go through the timelock, while revoking the treasury or the guardian stays instant | [`contracts/async-vault/src/test/setup.rs`](../contracts/async-vault/src/test/setup.rs), [`contracts/async-vault/src/test/governance.rs`](../contracts/async-vault/src/test/governance.rs) | `only_governance_finishes_setup`, `the_custodian_changes_through_the_timelock`, `grants_and_manager_revokes_wait_for_the_timelock`, `revoking_treasury_or_guardian_stays_instant`, `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` | diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index cd2caba9..7a8aaaab 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -289,7 +289,7 @@ number with a proof reference. | **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 freezes, plus legal transfers and recovery co-signed by the token admin; 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, and makes legal transfers and recoveries co-signed by the token admin | 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 | +| **Compliance module** | Implements the SEP-57 identity and rules interfaces the share token consults, with the allowlist as its only rule | The compliance authority writes the allowlist on the identity verifier. Governance runs the compliance contract as its admin: adding or removing a module and binding or unbinding a token are instant only during its one-time setup, and after it wait for the vault's timelock, cannot land while the vault is paused and never land during a wind-down. Replaceable by OZ's identity verifier and compliance contracts (with RWA Wizard modules); after setup, the token's swap to them waits for the vault's timelock | | **Authorities** | governance (parameters, roles, timelocked upgrades), compliance (allowlist, token interventions), attestation (valuation only), treasury (reserve movements only), guardian (pause; never payable claims) | Each contract grants its own roles; the oracle's, share token's and verifier's admins and the oracle's attester and guardian are constructor parameters independent of the vault's roles, equal to governance, compliance and guardian by deploy only; treasury and guardian distinct, treasury and governance distinct, compliance distinct from governance and treasury; governance and guardian may coincide; the vault's manager role is unconstrained | | **Custodian** | Off-chain party holding the real-world structure; a genesis-configured slot rotatable only by governance | Not an on-chain authority | @@ -459,6 +459,11 @@ interface today. and symmetrically, a redemption priced at zero assets pays nothing, and once the wind-down is active its claim burns the escrowed shares instead of returning them. +- A compliance module can block wind-down claims. A claim that surrenders shares + burns them through the token, which calls the compliance contract's + `destroyed` hook, so a module that starts failing during a wind-down blocks + those claims; it cannot be removed then, because removing a module waits for + the timelock and no timelocked change applies during a wind-down. - Funding a vault that is winding down cannot be undone. What arrives first covers priced exits and refundable escrow; the surplus is taken by the next round and credited to holders, and no entrypoint returns it. Treasury sends @@ -604,7 +609,7 @@ public surface given the vault address. | 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) | | Compliance keys | Wrongful delisting, freeze or transfer | Delisted investors keep the exit-only cash path; freezes require the share token's compliance role and are auditable per operation; moving a holder's shares also needs governance's signature in the same call, lands only on an allowlisted account and never moves shares out of or into the vault's address, which cannot be frozen once the token knows its vault | -| Compliance module | Faulty module blocks transfers | Fail-closed semantics; replaceable by governance without touching the token | +| Compliance module | Faulty module blocks transfers | Fail-closed semantics. Governance, the compliance contract's admin, adds and removes modules: instantly during its one-time setup, and after it only through the vault's timelock, so a change cannot land while the vault is paused and never lands during a wind-down. Removing a faulty module waits the same way, and during a wind-down it cannot be removed at all (§8.7) | | Deposit asset issuer | Freeze or clawback of the vault's reserve | Not mitigated by the kit; declared risk of the chosen asset, verified and reported at genesis (auth flags) | | Custodian / real world | Underlying loss or delay | Reflected through attested NAV; the kit constrains what reaches the chain, it does not verify the world | | Wind-down distribution | Unfinalized rounds stall distribution | Finalizing a round is permissionless: once treasury returns capital, anyone can finalize the round to credit holders; the operator cannot stall payouts after funding | diff --git a/scripts/harness/guards.test.ts b/scripts/harness/guards.test.ts index da66313e..a4c4ffa4 100644 --- a/scripts/harness/guards.test.ts +++ b/scripts/harness/guards.test.ts @@ -317,9 +317,6 @@ describe("the compliance contract after setup", () => { rules, "UseTimelock", ) - expect( - (await rules.get_modules_for_hook({ hook: canCreate })).result, - ).toEqual([]) }) it("queues a module behind the vault's delay", async () => { From 4db73e1a2319e15c467fa59b4d200507b6a43f75 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:14:17 -0300 Subject: [PATCH 059/147] fix(vault): return escrow as manager after a check --- contracts/async-vault/src/error.rs | 4 + contracts/async-vault/src/lib.rs | 5 +- contracts/async-vault/src/redeem.rs | 29 +++- contracts/async-vault/src/test/mod.rs | 5 +- contracts/async-vault/src/test/settlement.rs | 6 +- .../async-vault/src/test/share_returns.rs | 138 ++++++++++++++++++ docs/AUTHORIZATION_MATRIX.md | 6 +- docs/COMPLIANCE_MATRIX.md | 14 +- docs/TESTING.md | 1 + docs/kit-spec.md | 12 +- docs/strata-product-and-architecture.md | 15 +- scripts/harness/deploy.ts | 6 +- 12 files changed, 200 insertions(+), 41 deletions(-) create mode 100644 contracts/async-vault/src/test/share_returns.rs diff --git a/contracts/async-vault/src/error.rs b/contracts/async-vault/src/error.rs index a4cf8c44..771fc592 100644 --- a/contracts/async-vault/src/error.rs +++ b/contracts/async-vault/src/error.rs @@ -142,6 +142,10 @@ pub enum VaultError { /// The proposed feed does not name this vault as its own, or its setup is /// still open. Its admin could then change its limits with no delay. FeedNotBound = 6072, + /// The controller may not hold shares (delisted, frozen or refused by the + /// compliance rules), so the escrowed shares stay with the vault and the + /// request stays open. + HolderNotEligible = 6073, /// 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. diff --git a/contracts/async-vault/src/lib.rs b/contracts/async-vault/src/lib.rs index 2e87cd24..9c0980fe 100644 --- a/contracts/async-vault/src/lib.rs +++ b/contracts/async-vault/src/lib.rs @@ -391,8 +391,9 @@ impl AsyncVault { } /// Cancels an unpriced redemption. Outside a wind-down the shares come - /// back, so a delisted or frozen controller is refused; in one the escrow - /// joins the controller's entitlement, and a frozen controller is held. + /// back, even while the token is paused, unless the controller is delisted, + /// frozen or refused by the compliance rules; in one the escrow joins the + /// controller's entitlement, and a frozen controller is held. pub fn cancel_redeem(e: &Env, from: Address, epoch_id: u64) -> i128 { redeem::cancel(e, &from, epoch_id) } diff --git a/contracts/async-vault/src/redeem.rs b/contracts/async-vault/src/redeem.rs index c38490b1..72721033 100644 --- a/contracts/async-vault/src/redeem.rs +++ b/contracts/async-vault/src/redeem.rs @@ -1,11 +1,14 @@ use soroban_sdk::{panic_with_error, token::TokenClient, Address, Env}; -use stellar_tokens::{fungible::FungibleTokenClient, rwa::RWATokenClient}; +use stellar_tokens::{ + fungible::FungibleTokenClient, + rwa::{compliance::ComplianceClient as RulesClient, RWATokenClient}, +}; use crate::epoch; use crate::error::VaultError; use crate::event::{RedeemCancelled, RedeemClaimed, RedeemRequested, RedemptionSurrendered}; use crate::keys::DataKey; -use crate::lock_policy::{self, Decision, Exit}; +use crate::lock_policy::{self, Decision, Exit, Standing}; use crate::pricing::{Pricing, PricingScheme}; use crate::settlement; use crate::state::{self, EpochStatus, RedeemRequest}; @@ -182,9 +185,7 @@ pub(crate) fn queue_escrowed(e: &Env, controller: &Address, shares: i128) -> u64 epoch_id } -/// Moves escrowed shares back to the controller and clears the request. Both -/// callers ask the lock policy first; a delisted controller still meets the -/// token's own checked transfer here. +/// Moves escrowed shares back to the controller and clears the request. fn return_shares(e: &Env, controller: &Address, epoch_id: u64, shares: i128) { let mut epoch = state::get_epoch(e, epoch_id) .unwrap_or_else(|| panic_with_error!(e, VaultError::EpochNotFound)); @@ -192,9 +193,7 @@ fn return_shares(e: &Env, controller: &Address, epoch_id: u64, shares: i128) { state::set_epoch(e, epoch_id, &epoch); state::remove_redeem_request(e, epoch_id, controller); - let vault = e.current_contract_address(); - FungibleTokenClient::new(e, &state::get_addr(e, &DataKey::ShareToken)) - .transfer(&vault, controller, &shares); + deliver(e, controller, shares); RedeemCancelled { controller: controller.clone(), @@ -204,6 +203,20 @@ fn return_shares(e: &Env, controller: &Address, epoch_id: u64, shares: i128) { .publish(e); } +/// Checks the receiver, then moves as manager, so the vault's listing and token pause never apply. +fn deliver(e: &Env, controller: &Address, shares: i128) { + let vault = e.current_contract_address(); + let token = state::get_addr(e, &DataKey::ShareToken); + let share = RWATokenClient::new(e, &token); + let rules = RulesClient::new(e, &share.compliance()); + if lock_policy::standing(e, controller) != Standing::Listed + || !rules.can_transfer(&vault, controller, &shares, &token) + { + panic_with_error!(e, VaultError::HolderNotEligible); + } + share.forced_transfer(&vault, controller, &shares, &vault); +} + pub(crate) fn cancel(e: &Env, from: &Address, epoch_id: u64) -> i128 { from.require_auth(); diff --git a/contracts/async-vault/src/test/mod.rs b/contracts/async-vault/src/test/mod.rs index 6f3c899a..b95c7a16 100644 --- a/contracts/async-vault/src/test/mod.rs +++ b/contracts/async-vault/src/test/mod.rs @@ -19,6 +19,7 @@ mod pricing; mod redeem; mod settlement; mod setup; +mod share_returns; mod supply; mod timing; mod treasury; @@ -370,9 +371,7 @@ fn build<'a>( share.mint(&holder, &100, &admin); share.revoke_role(&admin, &symbol_short!("manager"), &admin); } - // The vault holds escrowed shares, so returning them on cancellation goes - // through the token's checked transfer. That verifies both sides, so the - // vault has to be a recognised holder itself. + // Converting a delisted holder's deposit mints into the vault's own escrow. if miswire != Miswire::NotAllowlisted { identity.allow(&contract_id, &true, &compliance_authority); } diff --git a/contracts/async-vault/src/test/settlement.rs b/contracts/async-vault/src/test/settlement.rs index 71e44408..aded2f31 100644 --- a/contracts/async-vault/src/test/settlement.rs +++ b/contracts/async-vault/src/test/settlement.rs @@ -166,9 +166,9 @@ fn a_request_that_cannot_settle_keeps_its_epoch_open() { f.fulfill_epoch(wad(1) / 2); f.identity.allow(&a, &false, &f.compliance); - assert_eq!( - f.vault.try_claim_redeem(&a, &epoch).err(), - Some(Ok(soroban_sdk::Error::from_contract_error(304))) + refused( + f.vault.try_claim_redeem(&a, &epoch), + VaultError::HolderNotEligible, ); assert_eq!(f.vault.claim_redeem(&b, &epoch), 1); diff --git a/contracts/async-vault/src/test/share_returns.rs b/contracts/async-vault/src/test/share_returns.rs new file mode 100644 index 00000000..92cadfa5 --- /dev/null +++ b/contracts/async-vault/src/test/share_returns.rs @@ -0,0 +1,138 @@ +use soroban_sdk::{contract, contractimpl}; +use stellar_contract_utils::pausable::PausableError; +use stellar_tokens::rwa::compliance::ComplianceHook; + +use super::*; + +#[contract] +struct RefusesEveryTransfer; + +#[contractimpl] +impl RefusesEveryTransfer { + pub fn can_transfer( + _e: Env, + _from: Address, + _to: Address, + _amount: i128, + _token: Address, + ) -> bool { + false + } +} + +#[test] +fn a_delisted_vault_still_returns_escrowed_shares() { + let f = setup(); + let holder = f.holder(500); + let epoch = f.vault.request_redeem(&holder, &200); + + f.identity.allow(&f.vault.address, &false, &f.compliance); + assert!(!f.vault.health().allowlisted); + + assert_eq!(f.vault.cancel_redeem(&holder, &epoch), 200); + assert_eq!(f.shares(&holder), 500); +} + +#[test] +fn a_delisted_controller_cannot_take_shares_back() { + let f = setup(); + let holder = f.holder(500); + let epoch = f.vault.request_redeem(&holder, &200); + f.identity.allow(&holder, &false, &f.compliance); + + refused( + f.vault.try_cancel_redeem(&holder, &epoch), + VaultError::NotAllowlisted, + ); + assert_eq!(f.shares(&holder), 300); + assert!(f.vault.get_redeem_request(&epoch, &holder).is_some()); +} + +#[test] +fn a_frozen_controller_cannot_take_shares_back() { + let f = setup(); + let holder = f.holder(500); + let epoch = f.vault.request_redeem(&holder, &200); + f.share.set_address_frozen(&holder, &true, &f.compliance); + + refused( + f.vault.try_cancel_redeem(&holder, &epoch), + VaultError::Frozen, + ); + assert_eq!(f.shares(&holder), 300); +} + +#[test] +fn a_return_the_compliance_rules_refuse_stays_escrowed() { + let f = setup(); + let holder = f.holder(500); + let epoch = f.vault.request_redeem(&holder, &200); + let module = f.e.register(RefusesEveryTransfer, ()); + f.add_compliance_module(ComplianceHook::CanTransfer, &module); + + refused( + f.vault.try_cancel_redeem(&holder, &epoch), + VaultError::HolderNotEligible, + ); + assert_eq!(f.shares(&f.vault.address), 200); + assert!(f.vault.get_redeem_request(&epoch, &holder).is_some()); +} + +#[test] +fn a_token_pause_stops_trading_but_not_a_share_return() { + let f = setup(); + let holder = f.holder(500); + let other = f.holder(10); + let epoch = f.vault.request_redeem(&holder, &200); + f.share.pause(&f.admin); + + assert_eq!(f.vault.cancel_redeem(&holder, &epoch), 200); + assert_eq!(f.shares(&holder), 500); + assert_eq!( + f.share.try_transfer(&holder, &other, &1), + Err(Ok(PausableError::EnforcedPause.into())) + ); + assert_eq!(f.shares(&holder), 500); +} + +#[test] +fn a_token_pause_does_not_stop_a_worthless_exit_coming_back() { + let f = setup(); + let holder = f.holder(10); + let epoch = f.vault.request_redeem(&holder, &1); + // Priced at half a unit per share, one share pays nothing. + f.fulfill_epoch(WAD_SCALE / 2); + f.share.pause(&f.admin); + + assert_eq!(f.vault.claim_redeem(&holder, &epoch), 0); + assert_eq!(f.shares(&holder), 10); +} + +#[test] +fn a_partial_freeze_does_not_stop_a_return() { + let f = setup(); + let holder = f.holder(500); + let epoch = f.vault.request_redeem(&holder, &200); + f.share.freeze_partial_tokens(&holder, &100, &f.compliance); + + assert_eq!(f.vault.cancel_redeem(&holder, &epoch), 200); + assert_eq!(f.shares(&holder), 500); + assert_eq!(f.share.get_frozen_tokens(&holder), 100); +} + +#[test] +fn a_delisted_controller_with_a_worthless_exit_keeps_the_request() { + let f = setup(); + let holder = f.holder(10); + let epoch = f.vault.request_redeem(&holder, &1); + // Priced at half a unit per share, one share pays nothing. + f.fulfill_epoch(WAD_SCALE / 2); + f.identity.allow(&holder, &false, &f.compliance); + + refused( + f.vault.try_claim_redeem(&holder, &epoch), + VaultError::HolderNotEligible, + ); + assert_eq!(f.vault.pending_burn_shares(), 1); + assert!(f.vault.get_redeem_request(&epoch, &holder).is_some()); +} diff --git a/docs/AUTHORIZATION_MATRIX.md b/docs/AUTHORIZATION_MATRIX.md index 2bbbc499..9d30973b 100644 --- a/docs/AUTHORIZATION_MATRIX.md +++ b/docs/AUTHORIZATION_MATRIX.md @@ -26,9 +26,9 @@ The symbol `"manager"` names two completely distinct roles living on separate co | Contract | Role Symbol | Held By | Scope & Privileges | |---|---|---|---| | **`AsyncVault`** | `symbol_short!("manager")` | Operator Multisig / Automation Bot | Strictly calls `close_epoch` to freeze orders for pricing. Holds **no** token minting or burning power. | -| **`ShareToken`** | `symbol_short!("manager")` | `AsyncVault` Contract Instance | Authorized to call `mint` (claim deposits), `burn` (claim redemptions) and `forced_transfer` (escrow redemptions). Holds no freeze or recovery power. | +| **`ShareToken`** | `symbol_short!("manager")` | `AsyncVault` Contract Instance | Authorized to call `mint` (claim deposits), `burn` (claim redemptions) and `forced_transfer` (escrow redemptions and return escrowed shares). Holds no freeze or recovery power. | -The share token has a third role, `"compliance"`, held by the compliance authority: freezes and partial freezes, plus `legal_transfer` and `recover_balance` with governance's co-signature. Only `manager` reaches `forced_transfer`, which the vault uses to escrow redemptions. +The share token has a third role, `"compliance"`, held by the compliance authority: freezes and partial freezes, plus `legal_transfer` and `recover_balance` with governance's co-signature. Only `manager` reaches `forced_transfer`, which the vault uses to escrow redemptions and to return escrowed shares after its own eligibility check. ### Deployment Script Safety In `scripts/harness/deploy.ts` and automated deployment scripts, the separation is enforced structurally: @@ -107,7 +107,7 @@ Tests: [`controls.rs`](../contracts/async-vault/src/test/controls.rs), [`epochs. ### 5.2 ShareToken (`contracts/share-token`) -The token's `admin` is governance; the guardian holds no role on the token. Until `finish_setup`, admin changes are instant, so the deploy can grant the vault the `manager` role. 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, revoking `manager`, admin hand-over, `set_compliance`, `set_identity_verifier` and upgrades. Revoking `compliance` and `pause`/`unpause` stay instant. The `manager` role is held by the vault and the `compliance` role by the compliance authority. +The token's `admin` is governance; the guardian holds no role on the token. Until `finish_setup`, admin changes are instant, so the deploy can grant the vault the `manager` role. 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, revoking `manager`, admin hand-over, `set_compliance`, `set_identity_verifier` and upgrades. Revoking `compliance` and `pause`/`unpause` stay instant. The `manager` role is held by the vault and the `compliance` role by the compliance authority. The token's `pause` refuses SEP-41 transfers between holders only. The vault's own escrow moves (`forced_transfer`), mint and burn ignore it, so a token pause stops neither entry nor exit. Tests: [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.rs) and [`contracts/share-token/src/test_governance.rs`](../contracts/share-token/src/test_governance.rs). diff --git a/docs/COMPLIANCE_MATRIX.md b/docs/COMPLIANCE_MATRIX.md index e408cb02..cb0b8dba 100644 --- a/docs/COMPLIANCE_MATRIX.md +++ b/docs/COMPLIANCE_MATRIX.md @@ -35,8 +35,8 @@ Every investor account in the vault ecosystem exists in one of three primary com | **`cancel_deposit`** | ✅ Allowed | ✅ **Allowed** | ❌ **Refused** (`Frozen`); the escrow stays | **Escape Hatch**: Calls `refund_deposit`. Escrowed settlement asset (USDC) is refunded directly to `controller`. The vault checks `is_frozen` on the share token before it refunds anything; once past that, cash refunds bypass share token compliance checks. A partial freeze is not checked. | | **`claim_deposit`** | ✅ Allowed | ↪ **Moved to the exit queue** | ⛔ **Held** | A receiver off the allowlist gets no shares: the vault mints them into its own escrow and adds them to the holder's redemption in the open epoch (`DepositConvertedToExit`), paid in cash at that epoch's price. In a wind-down they join the holder's wind-down entitlement. A compliance module that refuses the mint (`can_create`) still blocks the claim. A receiver whose address is frozen, listed or not, is refused with `Frozen` (6071) and keeps the priced deposit until the freeze is lifted. A partial freeze is not checked. | | **`request_redeem`** | ✅ Allowed | ✅ **Allowed** | ❌ **Refused** (`Frozen`); a partial freeze caps the request to the free balance | **Exit Right**: the vault checks `is_frozen` and `get_frozen_tokens` on the share token before it escrows anything with `RWATokenClient::forced_transfer`. A de-listed, non-frozen holder can still start an exit; a frozen one cannot, and a partial freeze caps the request to the shares `get_frozen_tokens` leaves free. | -| **`cancel_redeem`** | ✅ Allowed | ❌ **Refused**; ↪ **surrendered** in a wind-down | ⛔ **Held** | Outside a wind-down the vault refuses a de-listed controller (`NotAllowlisted`) and holds one whose address is frozen (`Frozen`); anyone else gets the shares back through the token's checked `transfer`. That transfer is refused with `EnforcedPause` while the share-token transfer pause is active; the request stays pending until the pause is lifted or the epoch prices. In an active wind-down no shares come back: the escrow is burned into the controller's wind-down entitlement (`RedemptionSurrendered`), allowlisted or not, so no token pause can hold it. A frozen controller's escrow stays until the freeze is lifted. | -| **`claim_redeem`** | ✅ Allowed | ✅ **Allowed** | ❌ **Refused** (`Frozen`); the cash stays in `committed` | **Exit-Only Cash Path**: the settlement asset pays out from the liquid reserve and the escrowed shares are burned from the vault. The cash claim checks `is_frozen` on the share token first; it does not check the allowlist. A partial freeze is not checked. | +| **`cancel_redeem`** | ✅ Allowed | ❌ **Refused**; ↪ **surrendered** in a wind-down | ⛔ **Held**; a partial freeze does not stop it: the shares return and stay partly frozen | Outside a wind-down the vault refuses a de-listed controller (`NotAllowlisted`) and holds one whose address is frozen (`Frozen`). For anyone else it checks the receiver itself (passes the identity check, not frozen, accepted by the compliance rules' `can_transfer`) and returns the shares with the manager `forced_transfer`; a move the rules refuse gets `HolderNotEligible`. Neither the vault's own allowlist place nor the share-token pause stops a return. A refused controller stays in the queue and exits in cash once the epoch is priced. In an active wind-down no shares come back: the escrow is burned into the controller's wind-down entitlement (`RedemptionSurrendered`), allowlisted or not. A frozen controller's escrow stays until the freeze is lifted. | +| **`claim_redeem`** | ✅ Allowed | ✅ **Allowed** | ❌ **Refused** (`Frozen`); the cash stays in `committed` | **Exit-Only Cash Path**: the settlement asset pays out from the liquid reserve and the escrowed shares are burned from the vault. The cash claim checks `is_frozen` on the share token first; it does not check the allowlist. A partial freeze is not checked. A claim that pays nothing returns the escrowed shares instead, under the same checks as `cancel_redeem`: a de-listed holder's is refused with `HolderNotEligible` and the request stays. | | **`claim_wind_down`** | ✅ Allowed | ✅ **Allowed** | ❌ **Refused** (`Frozen`); the slice stays in `wind_down_owed` | **Wind-Down Payout**: pays the holder's pro-rata entitlement and burns the free shares it surrenders. The vault checks `is_frozen` and `get_frozen_tokens` on the share token first; a partial freeze surrenders only the free shares — the rest keeps earning every round and is paid once unfrozen. | | **`transfer`** (Secondary) | ✅ Allowed | ❌ **Refused** | ❌ **Refused** | Standard peer-to-peer share transfer via SEP-57. Requires both sender and receiver to be allowlisted and neither to be frozen. | | **`legal_transfer`** (compliance + governance) | ✅ Allowed | ✅ Allowed | ✅ Allowed | Moves shares in the holder's wallet under a legal order; the destination must pass `verify_identity`; never into or out of the vault. It cannot reach value the vault holds for a frozen holder; only an unfreeze releases that. | @@ -77,10 +77,9 @@ An address that was never allowlisted cannot reach this state: `request_deposit` ### 3.4 Redemption Cancellation Refusal When an investor requests redemption, their shares are transferred to the vault. If the investor becomes de-listed or frozen before pricing: -- Calling `cancel_redeem` would transfer shares from the vault back to the investor's account (`return_shares`). -- The vault asks the share token first and refuses a receiver off the allowlist (`NotAllowlisted`) or holds a frozen one (`Frozen`), so returning the shares is refused. -- The investor is not allowed to re-enter share possession. They remain in the queue and exit into cash upon epoch fulfillment. -- In an active wind-down nothing is priced again, so a de-listed investor's cancel burns the escrow into their wind-down entitlement instead. A frozen investor's escrow stays until the freeze is lifted. +- The vault checks the controller itself before it returns escrowed shares. A de-listed controller is refused (`NotAllowlisted`), and so is a move the compliance rules refuse (`HolderNotEligible`); either way the investor stays in the queue and exits in cash once the epoch is priced. A frozen controller is held (`Frozen`) until the freeze is lifted. +- The vault moves the shares as the token's `manager` (`forced_transfer`), so removing the vault's own address from the allowlist does not stop returns. A share-token pause does not stop them either: it stops trading between holders, not the vault's escrow. A partial freeze does not stop them: the shares return and stay partly frozen. +- During a wind-down, every cancel burns the escrowed shares into the controller's wind-down entitlement (`RedemptionSurrendered`), listed or not, and they collect pro rata with `claim_wind_down`. A frozen controller's escrow stays until the freeze is lifted. --- @@ -98,6 +97,8 @@ The share token is OpenZeppelin's RWA token. The allowlist and freeze checks sit 0.7.2 has five compliance hooks: `can_transfer` and `can_create` are asked before the state change, and `transferred`, `created` and `destroyed` are told after it. There is no `can_destroy`. +When the vault returns escrowed shares with `forced_transfer`, it makes the receiver checks itself first: the freeze, `verify_identity` and `can_transfer`. It does not check the token pause. + The kit's compliance contract registers no modules, so `can_transfer` and `can_create` always allow today. Until rule modules are added, the allowlist and freeze rules are exactly the token checks in the table above. Adding or removing a module waits for the vault's timelock once its setup is finished, so a rule change never lands sooner than an exit completes. --- @@ -116,6 +117,7 @@ The behavior specified in this matrix is verified by on-chain automated test sui * `a_delisted_holder_can_still_claim_their_exit`: Verifies de-listed holders always receive cash payout. * `a_delisted_investor_can_cancel_deposit_and_receive_asset`: Verifies de-listed depositors can cancel and receive cash back. * `a_delisted_holder_cannot_cancel_redemption_back_to_shares`: Verifies de-listed holders cannot abort redemption back into shares. +* `share_returns::*` ([`test/share_returns.rs`](../contracts/async-vault/src/test/share_returns.rs)): Verifies a cancel returns the shares while the vault's own address is delisted or the token is paused, keeps a partial freeze on the returned shares, and refuses a move the compliance rules refuse or a delisted holder's zero-asset claim with `HolderNotEligible`. * `a_frozen_holder_is_paid_only_after_the_freeze_lifts`: Verifies a frozen holder's cancel and claim are both held, and paid only once the freeze lifts. * `a_frozen_or_delisted_holder_cannot_transfer_shares`: Verifies secondary market transfers are strictly refused for de-listed and frozen accounts. * `a_frozen_investor_cannot_request_a_deposit`: Verifies a frozen depositor is refused at request time. diff --git a/docs/TESTING.md b/docs/TESTING.md index 310827f4..d1a67816 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -126,6 +126,7 @@ fixture wires them as the deploy does and finishes the vault's setup; | Deposit asset (Stellar Asset Contract) moves in and out | [`contracts/async-vault/src/test/deposit.rs`](../contracts/async-vault/src/test/deposit.rs), [`contracts/async-vault/src/test/treasury.rs`](../contracts/async-vault/src/test/treasury.rs) | `request_deposit_updates_epoch_and_user_state`, `capital_makes_the_round_trip_to_the_custodian` | | The vault mints and burns shares through its role on the share token | [`contracts/async-vault/src/test/constructor.rs`](../contracts/async-vault/src/test/constructor.rs), [`contracts/async-vault/src/test/redeem.rs`](../contracts/async-vault/src/test/redeem.rs) | `the_vault_holds_the_manager_role_on_the_share_token`, `claim_redeem_pays_assets_and_burns_the_shares` | | 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 returns escrowed shares as the token's `manager` after checking the receiver itself, including the compliance contract's `can_transfer`; neither its own allowlist place nor the token pause stops a return | [`contracts/async-vault/src/test/share_returns.rs`](../contracts/async-vault/src/test/share_returns.rs) | `a_delisted_vault_still_returns_escrowed_shares`, `a_token_pause_stops_trading_but_not_a_share_return`, `a_return_the_compliance_rules_refuse_stays_escrowed` | | 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` | | The vault finishes setup only once the token and the oracle report it with their own setup closed, it is the token's only `manager`, the token has no shares outstanding, it passes the identity check, its address is not frozen and the token is bound to compliance, which runs on this vault's clock with its setup closed unless it is an external module that does not answer `VaultBinding`; before that it takes no deposit | [`contracts/async-vault/src/test/setup.rs`](../contracts/async-vault/src/test/setup.rs) | `every_wiring_step_is_checked_before_setup_finishes`, `a_compliance_contract_from_outside_the_kit_is_accepted`, `a_deposit_before_setup_finishes_is_refused`, `setup_cannot_finish_while_a_hand_over_is_pending` | | 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` | diff --git a/docs/kit-spec.md b/docs/kit-spec.md index e14f9e7c..49ecc9d3 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -50,7 +50,7 @@ The contracts enforce a set of mathematical and state-machine invariants across - `request_deposit` is refused by `#[when_not_paused]` (`PausableError::EnforcedPause`). - `request_redeem` succeeds and locks shares into escrow. - `claim_redeem` and `claim_deposit` remain open. - - `cancel_redeem` remains open; outside a wind-down only the share token's own transfer pause can refuse it (Invariant 6). + - `cancel_redeem` remains open; a delisted controller exits through the cash claim instead, and a frozen one is held (Invariant 6). - `cancel_deposit` and `cancel_redeem` stay open for a sealed epoch while the vault is paused, because a paused vault cannot price it (Invariant 6). ### Invariant 6: Single-Step Atomic Cancellation @@ -62,7 +62,7 @@ The contracts enforce a set of mathematical and state-machine invariants across - While an epoch is `Open`, or `Pending` with no readable price or with the vault paused, `cancel_deposit` and `cancel_redeem` return 100% of the escrow. - A readable price refuses cancellation with `PriceAvailable`, even before `priceable_at`; once that report expires, cancellation works again (`cancellation_reopens_when_the_price_that_closed_it_expires`). - Pausing the vault reopens cancellation; unpausing closes it again while the price is still readable (`a_paused_vault_reopens_cancellation_of_an_epoch_it_could_price`). - - Outside a wind-down, `cancel_redeem` is refused with `PausableError::EnforcedPause` while the share token's transfer pause is active; the request stays pending and the cash claim remains open. + - Outside a wind-down, `cancel_redeem` refuses a delisted controller (`NotAllowlisted`), holds a frozen one (`Frozen`) and refuses a move the compliance rules refuse (`HolderNotEligible`); the request stays pending and the cash claim remains open. Neither the share token's transfer pause nor the vault's own allowlist place stops a cancellation (`a_token_pause_stops_trading_but_not_a_share_return`, `a_delisted_vault_still_returns_escrowed_shares`). - In an active wind-down, `cancel_redeem` never returns shares: the escrow joins the holder's wind-down entitlement (`RedemptionSurrendered`), whether the holder is allowlisted or not. A frozen holder's escrow stays until the freeze is lifted. - Once fulfilled, cancellation is refused with `AlreadyPriced`. - **Operating note**: keep `freshness_duration` longer than the notice, so a normal epoch's price does not lapse and reopen the window on its own. @@ -76,7 +76,7 @@ The contracts enforce a set of mathematical and state-machine invariants across - **Testable Property**: For all values of $a, s \in [1, 10^{30}]$ and $\text{NAV} \in [\text{min\_answer}, \text{max\_answer}]$, a round-trip conversion never creates value. Remainder satisfies: $$(a \cdot \text{SCALE}) - (s \cdot \text{NAV}) < \text{NAV}$$ - **Settlement**: pricing records what each epoch promised (`shares_promised`, `assets_promised`, see `get_epoch_settlement`). Claims floor one by one, so together they pay at most that. When the last request of a priced epoch settles, the remainder leaves `pending_mint_shares` and `committed` and the vault publishes `EpochReconciled { epoch, share_dust, cash_dust }`. The cash stays in the reserve, owed to nobody, and reaches every holder through the NAV. A running total that would go below zero fails with `AccountingMismatch` (6070) instead of clamping. -- **A request that can never settle** keeps its epoch open, and the two cases leave different amounts behind. A zero-asset redemption whose holder the share token refuses (`a_request_that_cannot_settle_keeps_its_epoch_open`) leaves only the epoch's ordinary rounding remainder in `committed` — fewer units than the epoch has open requests of that kind — because that request was never owed any cash. A priced deposit whose mint a compliance module refuses (`a_refused_mint_keeps_a_priced_deposit_epoch_open`) is not rounding dust: the mint reverts, so that deposit's whole share entitlement stays in `pending_mint_shares`, on top of the epoch's own remainder, until the mint is allowed. Either way it is still owed to nobody. +- **A request that can never settle** keeps its epoch open, and the two cases leave different amounts behind. A zero-asset redemption whose holder may not take the shares back (`HolderNotEligible`, `a_request_that_cannot_settle_keeps_its_epoch_open`) leaves only the epoch's ordinary rounding remainder in `committed` — fewer units than the epoch has open requests of that kind — because that request was never owed any cash. A priced deposit whose mint a compliance module refuses (`a_refused_mint_keeps_a_priced_deposit_epoch_open`) is not rounding dust: the mint reverts, so that deposit's whole share entitlement stays in `pending_mint_shares`, on top of the epoch's own remainder, until the mint is allowed. Either way it is still owed to nobody. **Settlement and upgrades.** The settlement record counts a request only from the moment it exists. A vault upgraded in place to this version while any request is open would under-count its open epochs: claims in epochs priced before the upgrade would revert, and a mixed epoch could reconcile early. This version must therefore be deployed fresh, or reached by upgrade only once no request is open anywhere in the vault. @@ -174,8 +174,8 @@ The `async_vault` constructor strictly verifies pairwise separation across autho | `token_bound` | The share token names this vault through `vault()` and its own setup is finished. | | `oracle_bound` | The oracle names this vault through `vault()` and its own setup is finished. | | `sole_token_manager` | This vault is the share token's only `manager`. | -| `allowlisted` | This vault passes the share token's identity verifier, so it can hold escrowed shares. A delisted holder's deposit converts to an exit by minting the shares to the vault's own address, so that conversion needs this flag true. | -| `vault_unfrozen` | The share token has not frozen this vault's address. A frozen vault could not hand escrowed shares back when a holder cancels a redemption. | +| `allowlisted` | This vault passes the share token's identity verifier. Escrowing and returning shares do not need it. Only a delisted holder's conversion does: it mints the shares into the vault's own escrow. While this flag is false, those conversions wait until the vault is allowlisted again, and nothing is lost. | +| `vault_unfrozen` | The share token has not frozen this vault's address. The vault's own share moves ignore a freeze, so a frozen vault would look stopped while stopping nothing; `finish_setup` refuses it all the same. | | `compliance_bound` | The share token is in its compliance contract's `linked_tokens`, and a compliance contract that answers `vault()` and `setup_open()` names this vault with its own setup finished. One that does not answer them is an external module and passes; its own governance is a trust assumption. | | `feed_valid` | The oracle reports `Valid`: a report exists, it has not expired and the NAV is not paused. | | `freshness_covers_notice` | The oracle's `freshness_duration` is at least the vault's notice. | @@ -292,7 +292,7 @@ A clean deployment executes in three sequenced phases: 2. **Phase 2: Post-Deployment Role Wiring** - Call `ShareToken::grant_role("manager", vault_address)` to authorize lazy share minting and burning. - 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 `IdentityVerifier::allow(vault_address, true)`, signed by the compliance key, so a delisted holder's priced deposit can be minted into the vault's escrow; `finish_setup` requires it. - Call `Compliance::bind_token(share_token_address)` to link SEP-57 rule verification, then `Compliance::bind_vault(vault_address)` and `Compliance::finish_setup()`. Its module and binding changes are instant until then and wait for the vault's timelock afterwards. - 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. - On the vault, call `set_custodian(custodian)`, `set_notice(secs)` and, if initial capital intake is bounded, `set_deposit_cap(cap)`. All three are instant during setup, so deposits never open without the cap. diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index 7a8aaaab..ef0e05c5 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -396,12 +396,12 @@ and stale are independent: freshness lapses on its own, the pause is an administrative decision. **The share token's pause is separate.** The token has its own transfer pause, -gated on the token's admin (governance by deploy); the token has no guardian -role. While active it refuses SEP-41 transfers and the vault's escrowed-share -return (`cancel_redeem`); pricing, share and cash claims, mint, burn and forced -transfer are unaffected. It is a transfer-restriction control, not an emergency -stop for the vault, and only the token admin lifts it: pending cancellations -resume when it does. +controlled by the token's admin (governance) and never by the guardian. While +active it refuses SEP-41 transfers between holders. The vault's own escrow +moves (escrowing a redemption, returning escrowed shares on `cancel_redeem`), +pricing, share and cash claims, mint, burn and forced transfer are unaffected. +It is a trading restriction, not an emergency stop for the vault, and only +governance lifts it. **Integrator surface:** the oracle exposes the attested share price directly, as the latest report with its proof reference and acceptance time (`nav_per_share`). @@ -607,8 +607,9 @@ public surface given the vault address. | Mis-wired deployment | A vault goes live without every binding wired | `health()` reports each wiring step; `finish_setup` refuses until all hold, and the vault takes no deposit before that | | 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) | +| Share-token pause | Secondary transfers halted | Controlled by governance (the token admin), never the guardian; it refuses SEP-41 transfers between holders only, never the vault's escrow moves, pricing, claims, mint, burn or forced transfer; only governance lifts it (§8.5) | | Compliance keys | Wrongful delisting, freeze or transfer | Delisted investors keep the exit-only cash path; freezes require the share token's compliance role and are auditable per operation; moving a holder's shares also needs governance's signature in the same call, lands only on an allowlisted account and never moves shares out of or into the vault's address, which cannot be frozen once the token knows its vault | +| Vault's own listing | Compliance delists the vault's address | Share returns do not depend on it: the vault checks the holder itself and moves the shares as the token's manager. A delisted holder's conversion, which mints into the vault's escrow, waits until the vault is allowlisted again; nothing is lost, and `health().allowlisted` turns false | | Compliance module | Faulty module blocks transfers | Fail-closed semantics. Governance, the compliance contract's admin, adds and removes modules: instantly during its one-time setup, and after it only through the vault's timelock, so a change cannot land while the vault is paused and never lands during a wind-down. Removing a faulty module waits the same way, and during a wind-down it cannot be removed at all (§8.7) | | Deposit asset issuer | Freeze or clawback of the vault's reserve | Not mitigated by the kit; declared risk of the chosen asset, verified and reported at genesis (auth flags) | | Custodian / real world | Underlying loss or delay | Reflected through attested NAV; the kit constrains what reaches the chain, it does not verify the world | diff --git a/scripts/harness/deploy.ts b/scripts/harness/deploy.ts index 820611bb..9f161ea9 100644 --- a/scripts/harness/deploy.ts +++ b/scripts/harness/deploy.ts @@ -131,9 +131,9 @@ async function deployContracts( } } -// The vault mints and burns, so it needs the token's manager role; it returns -// escrowed shares through the checked transfer, so it must itself be -// allowed; and compliance only runs its hooks for a bound token, so the +// The vault mints and burns, so it needs the token's manager role; it mints a +// delisted holder's converted deposit into its own escrow, so it must itself +// be allowed; and compliance only runs its hooks for a bound token, so the // share token must be bound to it. async function grantAndVerify( net: Network, From a058425d934d9e9e79674387e0d9f26dbaa40b33 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 19:33:55 -0300 Subject: [PATCH 060/147] test: make admin and upgrade checks able to fail --- contracts/async-vault/src/test/upgrade.rs | 28 +++++++++--- contracts/nav-oracle/src/test_governance.rs | 45 +++++++++++++++++++- contracts/share-token/src/test_governance.rs | 45 +++++++++++++++++++- 3 files changed, 108 insertions(+), 10 deletions(-) diff --git a/contracts/async-vault/src/test/upgrade.rs b/contracts/async-vault/src/test/upgrade.rs index 5a106bd6..dd01ca0c 100644 --- a/contracts/async-vault/src/test/upgrade.rs +++ b/contracts/async-vault/src/test/upgrade.rs @@ -293,16 +293,32 @@ fn only_governance_applies() { assert_eq!(f.vault.pending_changes().len(), 1); } +/// Whether the host refused a call to `function` because the vault has no +/// function of that name. The reason is only in the diagnostic events. +fn has_no_function(e: &Env, function: &str) -> bool { + use soroban_sdk::xdr::{ContractEventBody, ScError, ScErrorCode, ScSymbol, ScVal}; + let missing = ScVal::Error(ScError::Context(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_vault_exposes_no_immediate_upgrade() { let f = setup(); - let hash = wasm(&f, 7); - - // If the Upgradeable trait were implemented, this client would work - // against the vault and replace the code with no delay at all. - let client = + let upgrader = stellar_contract_utils::upgradeable::UpgradeableClient::new(&f.e, &f.vault.address); - assert!(client.try_upgrade(&hash, &f.admin).is_err()); + + assert!(upgrader.try_upgrade(&wasm(&f, 7), &f.admin).is_err()); + assert!(has_no_function(&f.e, "upgrade")); } #[test] diff --git a/contracts/nav-oracle/src/test_governance.rs b/contracts/nav-oracle/src/test_governance.rs index 3cdbcb5e..38ed905e 100644 --- a/contracts/nav-oracle/src/test_governance.rs +++ b/contracts/nav-oracle/src/test_governance.rs @@ -109,6 +109,20 @@ impl Fixture<'_> { call(); assert_eq!(self.instance_ttl(), INSTANCE_EXTEND_AMOUNT); } + + /// Signs one call to the oracle as `who` and nothing else, so the call's + /// admin check is the only thing that can refuse it. + fn sign_only_as(&self, who: &Address, fn_name: &str, args: soroban_sdk::Vec) { + self.e.mock_auths(&[MockAuth { + address: who, + invoke: &MockAuthInvoke { + contract: &self.oracle.address, + fn_name, + args, + sub_invokes: &[], + }, + }]); + } } fn config() -> OracleConfig { @@ -465,12 +479,25 @@ fn an_address_that_reports_no_clock_cannot_be_bound() { fn only_the_admin_binds_and_finishes() { let f = setup(); let stranger = Address::generate(&f.e); - f.e.set_auths(&[]); + f.sign_only_as( + &stranger, + "bind_vault", + (f.vault.address.clone(), stranger.clone()).into_val(&f.e), + ); assert!(f .oracle .try_bind_vault(&f.vault.address, &stranger) .is_err()); + assert_eq!(f.oracle.vault(), None); + + f.e.mock_all_auths(); + f.oracle.bind_vault(&f.vault.address, &f.admin); + f.sign_only_as( + &stranger, + "finish_setup", + (stranger.clone(),).into_val(&f.e), + ); assert!(f.oracle.try_finish_setup(&stranger).is_err()); assert!(f.oracle.setup_open()); } @@ -820,9 +847,23 @@ fn only_the_admin_proposes_cancels_and_applies() { let id = f.propose(change.clone()); f.advance(MIN_DELAY); - f.e.set_auths(&[]); + f.sign_only_as( + &stranger, + "propose_change", + (change.clone(), stranger.clone()).into_val(&f.e), + ); assert!(f.oracle.try_propose_change(&change, &stranger).is_err()); + f.sign_only_as( + &stranger, + "cancel_change", + (id, stranger.clone()).into_val(&f.e), + ); assert!(f.oracle.try_cancel_change(&id, &stranger).is_err()); + f.sign_only_as( + &stranger, + "apply_change", + (id, stranger.clone()).into_val(&f.e), + ); assert!(f.oracle.try_apply_change(&id, &stranger).is_err()); assert_eq!(f.oracle.pending_changes().len(), 1); } diff --git a/contracts/share-token/src/test_governance.rs b/contracts/share-token/src/test_governance.rs index 2e5e07e6..01f17b25 100644 --- a/contracts/share-token/src/test_governance.rs +++ b/contracts/share-token/src/test_governance.rs @@ -98,6 +98,20 @@ impl Fixture<'_> { call(); assert_eq!(self.instance_ttl(), INSTANCE_EXTEND_AMOUNT); } + + /// Signs one call to the token as `who` and nothing else, so the call's + /// admin check is the only thing that can refuse it. + fn sign_only_as(&self, who: &Address, fn_name: &str, args: soroban_sdk::Vec) { + self.e.mock_auths(&[MockAuth { + address: who, + invoke: &MockAuthInvoke { + contract: &self.token.address, + fn_name, + args, + sub_invokes: &[], + }, + }]); + } } fn setup<'a>() -> Fixture<'a> { @@ -197,9 +211,22 @@ fn an_address_that_reports_no_clock_cannot_be_bound() { fn only_the_admin_binds_and_finishes() { let f = setup(); let stranger = Address::generate(&f.e); - f.e.set_auths(&[]); + f.sign_only_as( + &stranger, + "bind_vault", + (f.vault.address.clone(), stranger.clone()).into_val(&f.e), + ); assert!(f.token.try_bind_vault(&f.vault.address, &stranger).is_err()); + assert_eq!(f.token.vault(), None); + + f.e.mock_all_auths(); + f.token.bind_vault(&f.vault.address, &f.admin); + f.sign_only_as( + &stranger, + "finish_setup", + (stranger.clone(),).into_val(&f.e), + ); assert!(f.token.try_finish_setup(&stranger).is_err()); assert!(f.token.setup_open()); } @@ -481,9 +508,23 @@ fn only_the_admin_proposes_cancels_and_applies() { let id = f.propose(change.clone()); f.advance(MIN_DELAY); - f.e.set_auths(&[]); + f.sign_only_as( + &stranger, + "propose_change", + (change.clone(), stranger.clone()).into_val(&f.e), + ); assert!(f.token.try_propose_change(&change, &stranger).is_err()); + f.sign_only_as( + &stranger, + "cancel_change", + (id, stranger.clone()).into_val(&f.e), + ); assert!(f.token.try_cancel_change(&id, &stranger).is_err()); + f.sign_only_as( + &stranger, + "apply_change", + (id, stranger.clone()).into_val(&f.e), + ); assert!(f.token.try_apply_change(&id, &stranger).is_err()); assert_eq!(f.token.pending_changes().len(), 1); } From 6735839abd791fbc92151808c556f2a2dcc4f6cf Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 19:34:13 -0300 Subject: [PATCH 061/147] test(harness): assert every wind-down outcome --- scripts/harness/wind-down.test.ts | 138 ++++++++++++++++++++++-------- 1 file changed, 100 insertions(+), 38 deletions(-) diff --git a/scripts/harness/wind-down.test.ts b/scripts/harness/wind-down.test.ts index 66355822..8bb27fea 100644 --- a/scripts/harness/wind-down.test.ts +++ b/scripts/harness/wind-down.test.ts @@ -155,7 +155,7 @@ describe("a wind-down on a throwaway vault", () => { }) it("reads the economic supply after the claim", async () => { - await call("total_economic_supply", "outsider") + expect(await call("total_economic_supply", "outsider")).toBe("accepted") }) it("every other vault function answers through its binding", async () => { @@ -196,75 +196,137 @@ describe("a wind-down on a throwaway vault", () => { "get_existing_roles", "paused", ] - for (const name of views) await call(name, "outsider") - await call("wind_down_claimable", "outsider", { holder: holder() }) - await call("get_epoch", "outsider", { epoch_id: 1n }) - await call("get_epoch_settlement", "outsider", { epoch_id: 1n }) - await call("get_deposit_request", "outsider", { - epoch_id: 1n, - controller: holder(), - }) - await call("get_redeem_request", "outsider", { - epoch_id: 2n, - controller: holder(), - }) - await call("has_role", "outsider", { account: gov, role: "manager" }) - await call("get_role_member_count", "outsider", { role: "manager" }) - await call("get_role_member", "outsider", { role: "manager", index: 0 }) - await call("get_role_admin", "outsider", { role: "manager" }) + for (const name of views) + expect({ [name]: await call(name, "outsider") }).toEqual({ + [name]: "accepted", + }) + const reads: Array<[string, Record]> = [ + ["wind_down_claimable", { holder: holder() }], + ["get_epoch", { epoch_id: 1n }], + ["get_epoch_settlement", { epoch_id: 1n }], + ["get_deposit_request", { epoch_id: 1n, controller: holder() }], + ["get_redeem_request", { epoch_id: 2n, controller: holder() }], + ["has_role", { account: gov, role: "manager" }], + ["get_role_member_count", { role: "manager" }], + ["get_role_member", { role: "manager", index: 0 }], + ["get_role_admin", { role: "manager" }], + ] + for (const [name, args] of reads) + expect({ [name]: await call(name, "outsider", args) }).toEqual({ + [name]: "accepted", + }) - // Writes the wind-down or the timelock refuses: the call still has to - // encode and reach the contract. - const refusedWrites: Array<[string, Signer, Record]> = [ - ["set_deposit_cap", "governance", { cap: 1n, caller: gov }], - ["set_custodian", "governance", { custodian: someone, caller: gov }], - ["set_wind_down_delay", "governance", { secs: 1n, caller: gov }], - ["cancel_wind_down_proposal", "governance", { caller: gov }], - ["set_notice", "governance", { secs: 1n, caller: gov }], - ["finish_setup", "governance", { caller: gov }], + // Simulated, never sent: each write still has to encode, reach the + // contract and get the answer an active wind-down gives it. + const writes: Array<[string, Signer, Record, string]> = [ + ["set_deposit_cap", "governance", { cap: 1n, caller: gov }, "accepted"], + [ + "set_custodian", + "governance", + { custodian: someone, caller: gov }, + "refused: UseTimelock", + ], + [ + "set_wind_down_delay", + "governance", + { secs: 1n, caller: gov }, + "accepted", + ], + [ + "cancel_wind_down_proposal", + "governance", + { caller: gov }, + "refused: WindDownActive", + ], + ["set_notice", "governance", { secs: 1n, caller: gov }, "accepted"], + ["finish_setup", "governance", { caller: gov }, "refused: SetupClosed"], [ "propose_change", "governance", { change: { tag: "Custodian", values: [someone] }, caller: gov }, + "refused: WindDownActive", + ], + [ + "cancel_change", + "governance", + { id: 1, caller: gov }, + "refused: ChangeNotFound", + ], + [ + "apply_change", + "governance", + { id: 1, caller: gov }, + "refused: ChangeNotFound", ], - ["cancel_change", "governance", { id: 1, caller: gov }], - ["apply_change", "governance", { id: 1, caller: gov }], [ "deploy_to_custodian", "treasury", { caller: c.keys.treasury.publicKey(), assets: 1n }, + "refused: WindDownActive", + ], + ["fund", "holder-1", { from: holder(), assets: 1n }, "accepted"], + [ + "cancel_deposit", + "holder-1", + { from: holder(), epoch_id: 1n }, + "refused: AlreadyPriced", + ], + [ + "cancel_redeem", + "holder-1", + { from: holder(), epoch_id: 2n }, + "accepted", + ], + [ + "claim_redeem", + "holder-1", + { caller: holder(), epoch_id: 2n }, + "refused: EpochNotFulfilled", ], - ["fund", "outsider", { from: someone, assets: 1n }], - ["cancel_deposit", "holder-1", { from: holder(), epoch_id: 1n }], - ["cancel_redeem", "holder-1", { from: holder(), epoch_id: 2n }], - ["claim_redeem", "holder-1", { caller: holder(), epoch_id: 2n }], [ "grant_role", "governance", { account: someone, role: "manager", caller: gov }, + "refused: UseTimelock", ], [ "revoke_role", "governance", { account: someone, role: "manager", caller: gov }, + "refused: UseTimelock", + ], + [ + "renounce_role", + "outsider", + { role: "manager", caller: someone }, + "refused: RoleNotHeld", ], - ["renounce_role", "outsider", { role: "manager", caller: someone }], [ "transfer_admin_role", "governance", { new_admin: someone, live_until_ledger: 1 }, + "refused: UseTimelock", ], - ["accept_admin_transfer", "outsider", {}], + ["accept_admin_transfer", "outsider", {}, "refused: NoPendingTransfer"], [ "set_role_admin", "governance", { role: "manager", admin_role: "guardian" }, + "refused: UseTimelock", + ], + ["renounce_admin", "governance", {}, "refused: AdminRequired"], + [ + "pause", + "guardian", + { caller: c.keys.guardian.publicKey() }, + "accepted", ], - ["renounce_admin", "governance", {}], - ["pause", "guardian", { caller: c.keys.guardian.publicKey() }], - ["unpause", "governance", { caller: gov }], + ["unpause", "governance", { caller: gov }, "refused: ExpectedPause"], ] - for (const [name, as, args] of refusedWrites) await call(name, as, args) + for (const [name, as, args, expected] of writes) + expect({ [name]: await call(name, as, args) }).toEqual({ + [name]: expected, + }) const spec = vault(c, c.keys.outsider).spec const every = spec From c0dac1a0640c56192fc1b2370c8b5e376c15c635 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 19:34:18 -0300 Subject: [PATCH 062/147] test(vault): use DAY_DURATION for day constants --- contracts/async-vault/src/test/ttl.rs | 4 ++-- contracts/async-vault/src/test/wind_down.rs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/contracts/async-vault/src/test/ttl.rs b/contracts/async-vault/src/test/ttl.rs index 3e508440..06203925 100644 --- a/contracts/async-vault/src/test/ttl.rs +++ b/contracts/async-vault/src/test/ttl.rs @@ -4,7 +4,7 @@ use storage::{INSTANCE_EXTEND_AMOUNT, PERSISTENT_EXTEND_AMOUNT}; use super::*; use crate::keys::DataKey; -const TWO_DAYS: u64 = 2 * 24 * 60 * 60; +const TWO_DAYS: u64 = 2 * DAY_DURATION; fn persistent_ttl(f: &Fixture, key: &DataKey) -> u32 { f.e.as_contract(&f.vault.address, || f.e.storage().persistent().get_ttl(key)) @@ -93,7 +93,7 @@ fn a_pending_request_still_claims_after_most_of_a_month() { f.vault.request_deposit(&user, &400); let epoch = f.close_epoch(); - f.advance(29 * 24 * 60 * 60); + f.advance(29 * DAY_DURATION); f.oracle.attest( &NavReport { nav_per_share: wad(2), diff --git a/contracts/async-vault/src/test/wind_down.rs b/contracts/async-vault/src/test/wind_down.rs index 87b5499d..811f8bb8 100644 --- a/contracts/async-vault/src/test/wind_down.rs +++ b/contracts/async-vault/src/test/wind_down.rs @@ -48,7 +48,7 @@ fn a_fresh_vault_is_not_winding_down() { assert_eq!(f.vault.wind_down(), None); } -const WEEK: u64 = 7 * 24 * 60 * 60; +const WEEK: u64 = 7 * DAY_DURATION; #[test] fn governance_proposes_and_anyone_activates_after_the_delay() { @@ -593,7 +593,7 @@ fn a_position_survives_the_ledger_advancing() { f.vault.finalize_wind_down_round(); f.vault.claim_wind_down(&user); - f.advance(29 * 24 * 60 * 60); + f.advance(29 * DAY_DURATION); f.vault.fund(&backer, &800); f.vault.finalize_wind_down_round(); From 1704bcf904870c451eb58a8c892c889e51951dcb Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 19:34:24 -0300 Subject: [PATCH 063/147] refactor(vault): check standing before rules --- contracts/async-vault/src/redeem.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/async-vault/src/redeem.rs b/contracts/async-vault/src/redeem.rs index 72721033..bf4ec6fb 100644 --- a/contracts/async-vault/src/redeem.rs +++ b/contracts/async-vault/src/redeem.rs @@ -208,9 +208,9 @@ fn deliver(e: &Env, controller: &Address, shares: i128) { let vault = e.current_contract_address(); let token = state::get_addr(e, &DataKey::ShareToken); let share = RWATokenClient::new(e, &token); - let rules = RulesClient::new(e, &share.compliance()); if lock_policy::standing(e, controller) != Standing::Listed - || !rules.can_transfer(&vault, controller, &shares, &token) + || !RulesClient::new(e, &share.compliance()) + .can_transfer(&vault, controller, &shares, &token) { panic_with_error!(e, VaultError::HolderNotEligible); } From a011edd438d35bf7cb6cb71b44ffe91be6f31acb Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 19:34:41 -0300 Subject: [PATCH 064/147] docs: correct what the review found --- AGENTS.md | 6 ++++-- contracts/async-vault/src/error.rs | 6 +++--- contracts/async-vault/src/governance.rs | 3 ++- contracts/async-vault/src/lib.rs | 8 ++++---- contracts/nav-oracle/src/lib.rs | 3 ++- docs/AUTHORIZATION_MATRIX.md | 9 ++++++--- docs/COMPLIANCE_MATRIX.md | 4 ++-- docs/kit-spec.md | 20 +++++++++++--------- docs/strata-product-and-architecture.md | 7 +++++-- environments.toml | 9 +++++---- 10 files changed, 44 insertions(+), 31 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7b1d7c50..a7528957 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,8 +28,10 @@ What the contracts enforce: allowlist place. Priced claims are never re-priced and never identity-gated; a delisted, non-frozen investor leaves through the exit-only cash path, including a deposit that was priced before the delisting. -3. **Cancellation is atomic and single-step**, open only until the attestation - that prices the request is accepted. There is no instant exit. +3. **Cancellation is atomic and single-step**, open only while the vault cannot + price the request: the attestation that prices it closes cancellation, and + cancellation reopens if that report expires, the NAV is paused or the vault + is paused. There is no instant exit. Five authorities, each a native Stellar multisig: governance, compliance, attestation, treasury, guardian. Testnet only. Not audited. diff --git a/contracts/async-vault/src/error.rs b/contracts/async-vault/src/error.rs index 771fc592..36faee2d 100644 --- a/contracts/async-vault/src/error.rs +++ b/contracts/async-vault/src/error.rs @@ -123,9 +123,9 @@ pub enum VaultError { UseTimelock = 6065, /// Setup has finished, and it never reopens. SetupClosed = 6066, - /// The token, the oracle, the manager role, the allowlist or the - /// compliance binding is not wired to this vault yet, or the token already - /// has shares outstanding, so setup cannot finish. + /// A wiring check `health()` reports is false (token, oracle and compliance + /// bound, sole token manager, allowlisted, not frozen) or the token has + /// shares outstanding, so setup cannot finish. SetupIncomplete = 6067, /// The holder fails the share token's identity check, so the vault takes no /// deposit request from them and returns no escrowed shares to them. diff --git a/contracts/async-vault/src/governance.rs b/contracts/async-vault/src/governance.rs index 8cf8c8cc..89179d4c 100644 --- a/contracts/async-vault/src/governance.rs +++ b/contracts/async-vault/src/governance.rs @@ -60,7 +60,8 @@ pub struct VaultPendingChange { const TOKEN_MANAGER_ROLE: Symbol = symbol_short!("manager"); /// What `finish_setup` needs to see before deposits open. `health()` reports -/// the same checks. +/// every check but `no_shares_outstanding`: a healthy vault has shares +/// outstanding from its first claim on. pub(crate) struct Wiring { pub token_bound: bool, pub oracle_bound: bool, diff --git a/contracts/async-vault/src/lib.rs b/contracts/async-vault/src/lib.rs index 9c0980fe..6fb97fe7 100644 --- a/contracts/async-vault/src/lib.rs +++ b/contracts/async-vault/src/lib.rs @@ -286,10 +286,10 @@ impl AsyncVault { governance::set_notice(e, secs); } - /// Ends the window in which admin changes are instant, once the token, - /// the oracle, the manager role, the allowlist and the compliance binding - /// are wired to this vault and the token has no shares outstanding. - /// Deposits open from here on, and it never reopens. + /// Ends the window in which admin changes are instant. Needs every wiring + /// check `health()` reports (token, oracle and compliance bound, sole token + /// manager, allowlisted, not frozen) and no shares outstanding. Deposits + /// open from here on, and it never reopens. #[only_admin] pub fn finish_setup(e: &Env, caller: Address) { caller.require_auth(); diff --git a/contracts/nav-oracle/src/lib.rs b/contracts/nav-oracle/src/lib.rs index bf10ba75..cbe84820 100644 --- a/contracts/nav-oracle/src/lib.rs +++ b/contracts/nav-oracle/src/lib.rs @@ -39,7 +39,8 @@ pub enum OracleError { NavOutOfBand = 3002, /// Attested before `cooldown_secs` elapsed since the last record. CooldownActive = 3003, - /// Per-share move exceeds `max_deviation_bps`. + /// The move from the last record exceeds `max_up_bps` (a rise) or + /// `max_down_bps` (a fall). DeviationExceeded = 3004, /// `expires_at` not in the future — the record would be born stale. ExpiresInPast = 3005, diff --git a/docs/AUTHORIZATION_MATRIX.md b/docs/AUTHORIZATION_MATRIX.md index 9d30973b..ca034bdc 100644 --- a/docs/AUTHORIZATION_MATRIX.md +++ b/docs/AUTHORIZATION_MATRIX.md @@ -65,11 +65,11 @@ The following entrypoints are open to any caller on purpose: | **`AsyncVault`** | `finalize_wind_down_round` | Open (Any caller) | Distributing returned capital pro-rata across the supply snapshot is deterministic. Making finalisation open ensures investors or keepers can trigger payout rounds immediately when funds arrive. | | **`AsyncVault`** | `claim_wind_down` | `holder.require_auth()` | The holder signs their own claim; a keeper cannot claim for them. The payout goes to `holder`. | | **`AsyncVault`** | `request_deposit` | `from.require_auth()` | Investor entrypoint. Deposits escrowed cash; guarded by `when_not_paused`, not winding down, and refused with `SetupOpen` until the vault's setup finishes, so every deposit is covered by the timelock. | -| **`AsyncVault`** | `claim_deposit` | `caller.require_auth()` | Investor entrypoint. Claims shares once epoch fulfilled; gated by SEP-57 receiver check on `mint`. | +| **`AsyncVault`** | `claim_deposit` | `caller.require_auth()` | Investor entrypoint. Mints the shares once the epoch is priced; the token checks the receiver on `mint`. A delisted holder's shares become an exit instead (`DepositConvertedToExit`): they join the holder's redemption in the open epoch, or the holder's wind-down entitlement during a wind-down. A frozen holder's claim is held (`Frozen`). | | **`AsyncVault`** | `cancel_deposit` | `from.require_auth()` | Investor exit before epoch pricing. Single-step refund. | | **`AsyncVault`** | `request_redeem` | `from.require_auth()` | Investor exit. Always open, even during pause. | | **`AsyncVault`** | `claim_redeem` | `caller.require_auth()` | Investor exit. Pays settlement asset once covered. | -| **`AsyncVault`** | `cancel_redeem` | `from.require_auth()` | Investor exit before epoch pricing. Returns escrowed shares. | +| **`AsyncVault`** | `cancel_redeem` | `from.require_auth()` | Investor exit while the epoch cannot be priced. Outside a wind-down the vault checks the controller itself and returns the escrowed shares with the manager `forced_transfer`; it refuses a delisted controller (`NotAllowlisted`) and a move the compliance rules refuse (`HolderNotEligible`). In an active wind-down the escrow is surrendered into the controller's wind-down entitlement instead (`RedemptionSurrendered`). A frozen controller is held (`Frozen`) either way. | --- @@ -93,6 +93,7 @@ Tests: [`controls.rs`](../contracts/async-vault/src/test/controls.rs), [`epochs. | `set_deposit_cap` | `#[only_admin]`, `caller.require_auth()` | Not negative. Instant both ways, also after setup | `test::governance::the_deposit_cap_stays_instant_both_ways` | | `finish_setup` | `#[only_admin]`, `caller.require_auth()` | Once. The token and the oracle report this vault with their setup closed, the vault is the token's only `manager`, the token's `total_supply` is zero, the vault passes `verify_identity`, the token's `is_frozen` is false for the vault, and the token is in its compliance contract's `linked_tokens`; a compliance contract that answers `vault()` and `setup_open()` reports this vault with its setup closed (one that does not answer them is an external module and is accepted); no admin hand-over offered during setup is pending | `test::setup::every_wiring_step_is_checked_before_setup_finishes`, `test::setup::a_compliance_contract_from_outside_the_kit_is_accepted`, `test::setup::setup_cannot_finish_while_a_hand_over_is_pending`, `test::setup::setup_never_finishes_twice`, `test::setup::only_governance_finishes_setup` | | `grant_role` / `transfer_admin_role` | Admin | Instant during setup, `UseTimelock` after. A zero `live_until_ledger` cancels instantly | `test::governance::grants_and_manager_revokes_wait_for_the_timelock`, `test::governance::an_admin_hand_over_waits_but_cancelling_one_does_not` | +| `accept_admin_transfer` | The pending admin's auth | A hand-over is pending; an applied `Admin` change stays open for seven days of ledgers | `test::governance::an_applied_admin_change_starts_a_hand_over_the_next_admin_accepts`, `test::governance::the_hand_over_offer_lasts_seven_days_of_ledgers`, `test::governance::an_admin_hand_over_waits_but_cancelling_one_does_not`, `test::setup::setup_cannot_finish_while_a_hand_over_is_pending` | | `revoke_role` | Admin | `treasury`, `guardian`: instant. `manager`: instant during setup, `UseTimelock` after | `test::governance::grants_and_manager_revokes_wait_for_the_timelock` (instant case: `test::governance::revoking_treasury_or_guardian_stays_instant`; caller check: `test::governance::only_the_admin_revokes_a_role`) | | `set_role_admin` | — | Always refused (`UseTimelock`) | `test::governance::no_role_ever_gets_a_role_admin` | | `propose_wind_down` | `#[only_admin]`, `caller.require_auth()` | No proposal standing, not active | `test::wind_down::only_governance_proposes` | @@ -135,6 +136,7 @@ Tests: [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.r | `grant_role` | Admin, `caller.require_auth()` | Instant during setup; `UseTimelock` after | `test_governance::grants_and_manager_revokes_wait_after_setup` | | `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` | +| `accept_admin_transfer` | The pending admin's auth | A hand-over is pending; an applied `Admin` change stays open for seven days of ledgers | `test_governance::an_applied_admin_change_starts_a_hand_over_the_next_admin_accepts`, `test_governance::the_hand_over_offer_lasts_seven_days_of_ledgers`, `test_governance::an_admin_hand_over_waits_but_cancelling_one_does_not`, `test_governance::setup_cannot_finish_while_a_hand_over_is_pending` | | `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::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 | @@ -161,12 +163,13 @@ Tests: [`contracts/nav-oracle/src/test.rs`](../contracts/nav-oracle/src/test.rs) | `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` | +| `accept_admin_transfer` | The pending admin's auth | A hand-over is pending; an applied `Admin` change stays open for seven days of ledgers | `test_governance::an_applied_admin_change_starts_a_hand_over_the_next_admin_accepts`, `test_governance::the_hand_over_offer_lasts_seven_days_of_ledgers`, `test_governance::an_admin_hand_over_waits_but_cancelling_one_does_not`, `test_governance::setup_cannot_finish_while_a_hand_over_is_pending` | | `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::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`) -A testnet-only stub. Its admin is the compliance key and cannot be transferred: the stub exposes no access-control interface, so replacing the compliance key on it means redeploying. +A testnet-only stub. Its admin is the compliance key and cannot be transferred: the stub exposes no access-control interface, so replacing the compliance key on it means redeploying. So the compliance key loses its power at once only on the share token, where its `compliance` role can be revoked instantly. On this stub it can still allowlist and delist addresses until the token's verifier is swapped for a new deployment; after setup that swap waits for the vault's timelock, at least seven days. Tests: [`contracts/identity-verifier/src/test.rs`](../contracts/identity-verifier/src/test.rs). diff --git a/docs/COMPLIANCE_MATRIX.md b/docs/COMPLIANCE_MATRIX.md index cb0b8dba..3038a909 100644 --- a/docs/COMPLIANCE_MATRIX.md +++ b/docs/COMPLIANCE_MATRIX.md @@ -21,7 +21,7 @@ Every investor account in the vault ecosystem exists in one of three primary com 3. **Frozen (`Frozen`)**: - The account is subject to sanctions, legal freezing, or security hold (`is_frozen(account) == true` via `ShareToken::set_address_frozen`). - The share token refuses a `transfer` from or to a frozen address. Minting does not check freezes, and `forced_transfer` ignores them. - - What the vault owes a frozen investor is **held**: not paid, not returned, not moved. Priced cash stays in `committed` and a wind-down slice stays in `wind_down_owed`, where no other holder can take it. Only an unfreeze releases it. A legal-order transfer (`legal_transfer`), which compliance and governance sign together, can move shares in the investor's own wallet, but not value the vault holds for them: deposit escrow, priced but unminted shares, shares escrowed in a redemption, priced cash, or a wind-down slice. A partial freeze limits exits to the free balance. + - What the vault owes a frozen investor is **held**: not paid, not returned, not moved. Priced cash stays in `committed` and a wind-down slice stays in `wind_down_owed`, where no other holder can take it; only an unfreeze releases it. A refund for a deposit priced at zero shares is the exception: it is not tracked as owed, so it waits in the free reserve, where a wind-down round can credit it to other holders (see the limits in the design doc's [§8.7](./strata-product-and-architecture.md#87-treasury-and-custodian)). A legal-order transfer (`legal_transfer`), which compliance and governance sign together, can move shares in the investor's own wallet, but not value the vault holds for them: deposit escrow, priced but unminted shares, shares escrowed in a redemption, priced cash, or a wind-down slice. A partial freeze limits exits to the free balance. This follows how asset-freeze and blocking rules generally treat frozen property: held, not released, not returned to the owner and not confiscated, with the entitlement kept and recorded. It is not legal advice; counsel must confirm it for each jurisdiction. @@ -36,7 +36,7 @@ Every investor account in the vault ecosystem exists in one of three primary com | **`claim_deposit`** | ✅ Allowed | ↪ **Moved to the exit queue** | ⛔ **Held** | A receiver off the allowlist gets no shares: the vault mints them into its own escrow and adds them to the holder's redemption in the open epoch (`DepositConvertedToExit`), paid in cash at that epoch's price. In a wind-down they join the holder's wind-down entitlement. A compliance module that refuses the mint (`can_create`) still blocks the claim. A receiver whose address is frozen, listed or not, is refused with `Frozen` (6071) and keeps the priced deposit until the freeze is lifted. A partial freeze is not checked. | | **`request_redeem`** | ✅ Allowed | ✅ **Allowed** | ❌ **Refused** (`Frozen`); a partial freeze caps the request to the free balance | **Exit Right**: the vault checks `is_frozen` and `get_frozen_tokens` on the share token before it escrows anything with `RWATokenClient::forced_transfer`. A de-listed, non-frozen holder can still start an exit; a frozen one cannot, and a partial freeze caps the request to the shares `get_frozen_tokens` leaves free. | | **`cancel_redeem`** | ✅ Allowed | ❌ **Refused**; ↪ **surrendered** in a wind-down | ⛔ **Held**; a partial freeze does not stop it: the shares return and stay partly frozen | Outside a wind-down the vault refuses a de-listed controller (`NotAllowlisted`) and holds one whose address is frozen (`Frozen`). For anyone else it checks the receiver itself (passes the identity check, not frozen, accepted by the compliance rules' `can_transfer`) and returns the shares with the manager `forced_transfer`; a move the rules refuse gets `HolderNotEligible`. Neither the vault's own allowlist place nor the share-token pause stops a return. A refused controller stays in the queue and exits in cash once the epoch is priced. In an active wind-down no shares come back: the escrow is burned into the controller's wind-down entitlement (`RedemptionSurrendered`), allowlisted or not. A frozen controller's escrow stays until the freeze is lifted. | -| **`claim_redeem`** | ✅ Allowed | ✅ **Allowed** | ❌ **Refused** (`Frozen`); the cash stays in `committed` | **Exit-Only Cash Path**: the settlement asset pays out from the liquid reserve and the escrowed shares are burned from the vault. The cash claim checks `is_frozen` on the share token first; it does not check the allowlist. A partial freeze is not checked. A claim that pays nothing returns the escrowed shares instead, under the same checks as `cancel_redeem`: a de-listed holder's is refused with `HolderNotEligible` and the request stays. | +| **`claim_redeem`** | ✅ Allowed | ✅ **Allowed** | ❌ **Refused** (`Frozen`); the cash stays in `committed` | **Exit-Only Cash Path**: the settlement asset pays out from the liquid reserve and the escrowed shares are burned from the vault. The cash claim checks `is_frozen` on the share token first; it does not check the allowlist. A partial freeze is not checked. Outside a wind-down, a claim that pays nothing returns the escrowed shares instead, under the same checks as `cancel_redeem`: a de-listed holder's is refused with `HolderNotEligible` and the request stays. In a wind-down such a claim burns the escrowed shares. | | **`claim_wind_down`** | ✅ Allowed | ✅ **Allowed** | ❌ **Refused** (`Frozen`); the slice stays in `wind_down_owed` | **Wind-Down Payout**: pays the holder's pro-rata entitlement and burns the free shares it surrenders. The vault checks `is_frozen` and `get_frozen_tokens` on the share token first; a partial freeze surrenders only the free shares — the rest keeps earning every round and is paid once unfrozen. | | **`transfer`** (Secondary) | ✅ Allowed | ❌ **Refused** | ❌ **Refused** | Standard peer-to-peer share transfer via SEP-57. Requires both sender and receiver to be allowlisted and neither to be frozen. | | **`legal_transfer`** (compliance + governance) | ✅ Allowed | ✅ Allowed | ✅ Allowed | Moves shares in the holder's wallet under a legal order; the destination must pass `verify_identity`; never into or out of the vault. It cannot reach value the vault holds for a frozen holder; only an unfreeze releases that. | diff --git a/docs/kit-spec.md b/docs/kit-spec.md index 49ecc9d3..3f37f49c 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -42,7 +42,7 @@ The contracts enforce a set of mathematical and state-machine invariants across 1. The vault is paused (`paused == true`). 2. The oracle feed has expired (`is_stale == true`) or the NAV is paused. 3. The caller has been de-listed in the `IdentityVerifier`. - Priced claims are never re-priced and never identity-gated. A frozen investor's covered claim is held until the freeze is lifted (`VaultError::Frozen`); the cash stays in `committed`. + Priced claims are never re-priced, and a cash payout never needs the identity check. A frozen investor's covered claim is held until the freeze is lifted (`VaultError::Frozen`); the cash stays in `committed`. ### Invariant 5: Exit Rights Never Gated or Pausable - **Definition**: Investors cannot be trapped in the vault by a vault pause. @@ -62,7 +62,7 @@ The contracts enforce a set of mathematical and state-machine invariants across - While an epoch is `Open`, or `Pending` with no readable price or with the vault paused, `cancel_deposit` and `cancel_redeem` return 100% of the escrow. - A readable price refuses cancellation with `PriceAvailable`, even before `priceable_at`; once that report expires, cancellation works again (`cancellation_reopens_when_the_price_that_closed_it_expires`). - Pausing the vault reopens cancellation; unpausing closes it again while the price is still readable (`a_paused_vault_reopens_cancellation_of_an_epoch_it_could_price`). - - Outside a wind-down, `cancel_redeem` refuses a delisted controller (`NotAllowlisted`), holds a frozen one (`Frozen`) and refuses a move the compliance rules refuse (`HolderNotEligible`); the request stays pending and the cash claim remains open. Neither the share token's transfer pause nor the vault's own allowlist place stops a cancellation (`a_token_pause_stops_trading_but_not_a_share_return`, `a_delisted_vault_still_returns_escrowed_shares`). + - Outside a wind-down, `cancel_redeem` refuses a delisted controller (`NotAllowlisted`) and a move the compliance rules refuse (`HolderNotEligible`); the request stays pending and the cash claim remains open. It holds a frozen controller (`Frozen`): the request stays pending, and its cash claim is held too until the freeze is lifted. Neither the share token's transfer pause nor the vault's own allowlist place stops a cancellation (`a_token_pause_stops_trading_but_not_a_share_return`, `a_delisted_vault_still_returns_escrowed_shares`). - In an active wind-down, `cancel_redeem` never returns shares: the escrow joins the holder's wind-down entitlement (`RedemptionSurrendered`), whether the holder is allowlisted or not. A frozen holder's escrow stays until the freeze is lifted. - Once fulfilled, cancellation is refused with `AlreadyPriced`. - **Operating note**: keep `freshness_duration` longer than the notice, so a normal epoch's price does not lapse and reopen the window on its own. @@ -76,7 +76,7 @@ The contracts enforce a set of mathematical and state-machine invariants across - **Testable Property**: For all values of $a, s \in [1, 10^{30}]$ and $\text{NAV} \in [\text{min\_answer}, \text{max\_answer}]$, a round-trip conversion never creates value. Remainder satisfies: $$(a \cdot \text{SCALE}) - (s \cdot \text{NAV}) < \text{NAV}$$ - **Settlement**: pricing records what each epoch promised (`shares_promised`, `assets_promised`, see `get_epoch_settlement`). Claims floor one by one, so together they pay at most that. When the last request of a priced epoch settles, the remainder leaves `pending_mint_shares` and `committed` and the vault publishes `EpochReconciled { epoch, share_dust, cash_dust }`. The cash stays in the reserve, owed to nobody, and reaches every holder through the NAV. A running total that would go below zero fails with `AccountingMismatch` (6070) instead of clamping. -- **A request that can never settle** keeps its epoch open, and the two cases leave different amounts behind. A zero-asset redemption whose holder may not take the shares back (`HolderNotEligible`, `a_request_that_cannot_settle_keeps_its_epoch_open`) leaves only the epoch's ordinary rounding remainder in `committed` — fewer units than the epoch has open requests of that kind — because that request was never owed any cash. A priced deposit whose mint a compliance module refuses (`a_refused_mint_keeps_a_priced_deposit_epoch_open`) is not rounding dust: the mint reverts, so that deposit's whole share entitlement stays in `pending_mint_shares`, on top of the epoch's own remainder, until the mint is allowed. Either way it is still owed to nobody. +- **A request that can never settle** keeps its epoch open, and the two cases leave different amounts behind. A zero-asset redemption whose holder may not take the shares back (`HolderNotEligible`, `a_request_that_cannot_settle_keeps_its_epoch_open`) leaves only the epoch's ordinary rounding remainder in `committed` — fewer units than the epoch had redemption requests — because that request was never owed any cash. A priced deposit whose mint a compliance module refuses (`a_refused_mint_keeps_a_priced_deposit_epoch_open`) is not rounding dust: the mint reverts, so that deposit's whole share entitlement stays in `pending_mint_shares`, on top of the epoch's own remainder, until the mint is allowed. Either way it is still owed to nobody. **Settlement and upgrades.** The settlement record counts a request only from the moment it exists. A vault upgraded in place to this version while any request is open would under-count its open epochs: claims in epochs priced before the upgrade would revert, and a mixed epoch could reconcile early. This version must therefore be deployed fresh, or reached by upgrade only once no request is open anywhere in the vault. @@ -166,7 +166,7 @@ The `async_vault` constructor strictly verifies pairwise separation across autho ### Health View -`health()` returns one flag per check. A deployment is ready when `setup_open` is false and every other flag is true. +`health()` returns one flag per check. A deployment is ready when `setup_open` is false and every other flag is true. `health()` leaves out one `finish_setup` check on purpose, that the share token has no shares outstanding, because a healthy vault has shares outstanding from its first claim on. If `finish_setup` refuses with `SetupIncomplete` while `token_bound`, `oracle_bound`, `sole_token_manager`, `allowlisted`, `vault_unfrozen` and `compliance_bound` are all true, shares were minted before setup; the token's `total_supply()` shows them. | Field | True when | |---|---| @@ -175,7 +175,7 @@ The `async_vault` constructor strictly verifies pairwise separation across autho | `oracle_bound` | The oracle names this vault through `vault()` and its own setup is finished. | | `sole_token_manager` | This vault is the share token's only `manager`. | | `allowlisted` | This vault passes the share token's identity verifier. Escrowing and returning shares do not need it. Only a delisted holder's conversion does: it mints the shares into the vault's own escrow. While this flag is false, those conversions wait until the vault is allowlisted again, and nothing is lost. | -| `vault_unfrozen` | The share token has not frozen this vault's address. The vault's own share moves ignore a freeze, so a frozen vault would look stopped while stopping nothing; `finish_setup` refuses it all the same. | +| `vault_unfrozen` | The share token has not frozen this vault's address. The vault's own share moves ignore an address freeze, so freezing the vault stops nothing; `finish_setup` still refuses a frozen vault. | | `compliance_bound` | The share token is in its compliance contract's `linked_tokens`, and a compliance contract that answers `vault()` and `setup_open()` names this vault with its own setup finished. One that does not answer them is an external module and passes; its own governance is a trust assumption. | | `feed_valid` | The oracle reports `Valid`: a report exists, it has not expired and the NAV is not paused. | | `freshness_covers_notice` | The oracle's `freshness_duration` is at least the vault's notice. | @@ -346,10 +346,12 @@ at most 30 days, so no caller pays for more than 30 days of any entry. **Idle deployments.** A deployment nobody calls for 30 days archives. Run `STELLAR_ACCOUNT= npm run extend -w scripts/harness` after -every deploy and before a long quiet period. It restores anything archived -and extends every instance and its Wasm by 30 days. It reads the contracts -in `environments.toml [staging.contracts]`; `--env ` picks another -environment, and `--config ` reads a deployment record instead. +every deploy and before a long quiet period. It restores, when archived, and +extends by 30 days three kinds of entry only: each contract instance, the Wasm +it runs, and each pending timelock change. Other entries, such as balances and +roles, are restored by the call that needs them (see **Restoring**). It reads +the contracts in `environments.toml [staging.contracts]`; `--env ` picks +another environment, and `--config ` reads a deployment record instead. Extending needs no authority, so any funded account can pay. **Restoring.** Since Protocol 23 an invocation can restore archived entries diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index ef0e05c5..f1b7f4e4 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -287,7 +287,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 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 freezes, plus legal transfers and recovery co-signed by the token admin; swapping the compliance or identity-verifier contract needs the admin and, after a one-time setup, waits for the vault's timelock | +| **Share token** | OZ SEP-41 + SEP-57 RWA extensions: freeze, forced transfer, recovery, identity and compliance checks on every transfer, independent transfer pause (set only by the admin; governance by deploy) | Manager role held only by the vault, for mint, burn and escrow; compliance role held by the compliance authority, for freezes, plus legal transfers and recovery co-signed by the token admin; 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, and makes legal transfers and recoveries co-signed by the token admin | 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 | The compliance authority writes the allowlist on the identity verifier. Governance runs the compliance contract as its admin: adding or removing a module and binding or unbinding a token are instant only during its one-time setup, and after it wait for the vault's timelock, cannot land while the vault is paused and never land during a wind-down. Replaceable by OZ's identity verifier and compliance contracts (with RWA Wizard modules); after setup, the token's swap to them waits for the vault's timelock | | **Authorities** | governance (parameters, roles, timelocked upgrades), compliance (allowlist, token interventions), attestation (valuation only), treasury (reserve movements only), guardian (pause; never payable claims) | Each contract grants its own roles; the oracle's, share token's and verifier's admins and the oracle's attester and guardian are constructor parameters independent of the vault's roles, equal to governance, compliance and guardian by deploy only; treasury and guardian distinct, treasury and governance distinct, compliance distinct from governance and treasury; governance and guardian may coincide; the vault's manager role is unconstrained | @@ -368,10 +368,13 @@ and exposes it with the liquidity figures it owns: ```text liquid_reserve = reserve - cancellable_deposit_escrow -free_reserve = max(liquid_reserve - committed, 0) +free_reserve = max(liquid_reserve - committed - wind_down_owed, 0) uncovered = max(committed - liquid_reserve, 0) ``` +`wind_down_owed` is what the wind-down rounds have credited to holders and they +have not claimed yet. It is zero outside a wind-down. + **Attestation guardrails:** the reporter is a multisig, never a single key. Each attestation carries the share price and a proof reference; attestations are ordered by their acceptance time on the ledger. The price must stay within diff --git a/environments.toml b/environments.toml index 8049cff7..9a8d1e4d 100644 --- a/environments.toml +++ b/environments.toml @@ -34,10 +34,11 @@ constructor_args = "--admin me" client = true constructor_args = '--name "Strata Vault USDC" --symbol bvUSDC --admin me --compliance_authority me --compliance compliance --identity_verifier identity_verifier' -# `--config` takes the OracleConfig struct as JSON. The values mirror the -# contract's own tests: a one-hour freshness window, a one-minute cooldown, a -# 10% deviation cap and a 0.5–100 band at the 18-decimal scale. The two i128 -# fields are strings because they do not survive a JSON number round trip. +# `--config` takes the OracleConfig struct as JSON. The values match the +# harness's testnet oracle: a one-hour freshness window, a one-minute cooldown, +# a 10% cap on a rise, no cap on a fall (10,000 bps lets a loss of any size +# land at once) and a 0.5–100 band at the 18-decimal scale. The two i128 fields +# are strings because they do not survive a JSON number round trip. [development.contracts.nav_oracle] client = true constructor_args = "--admin me --attester me --guardian me --config '{\"freshness_duration\":3600,\"cooldown_secs\":60,\"max_up_bps\":1000,\"max_down_bps\":10000,\"min_answer\":\"500000000000000000\",\"max_answer\":\"100000000000000000000\"}'" From 08689a29f2fbbd47ee2ed9f58b71b683df11587d Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:10:55 -0300 Subject: [PATCH 065/147] docs: fix the last review sentences --- contracts/async-vault/src/health.rs | 3 ++- contracts/async-vault/src/lib.rs | 9 +++++---- contracts/nav-oracle/src/test_governance.rs | 4 ++-- contracts/share-token/src/test_governance.rs | 4 ++-- docs/AUTHORIZATION_MATRIX.md | 2 +- docs/kit-spec.md | 4 ++-- scripts/harness/extend.ts | 6 +++--- 7 files changed, 17 insertions(+), 15 deletions(-) diff --git a/contracts/async-vault/src/health.rs b/contracts/async-vault/src/health.rs index 5b6ec76e..894f5290 100644 --- a/contracts/async-vault/src/health.rs +++ b/contracts/async-vault/src/health.rs @@ -6,7 +6,8 @@ use crate::keys::DataKey; use crate::state; /// What a deployment checks before it trusts the vault, and what monitoring -/// keeps checking after. Every field is true on a healthy vault. +/// keeps checking after. On a healthy vault `setup_open` is false and every +/// other field is true. #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct Health { diff --git a/contracts/async-vault/src/lib.rs b/contracts/async-vault/src/lib.rs index 6fb97fe7..fcdb44e7 100644 --- a/contracts/async-vault/src/lib.rs +++ b/contracts/async-vault/src/lib.rs @@ -288,8 +288,8 @@ impl AsyncVault { /// Ends the window in which admin changes are instant. Needs every wiring /// check `health()` reports (token, oracle and compliance bound, sole token - /// manager, allowlisted, not frozen) and no shares outstanding. Deposits - /// open from here on, and it never reopens. + /// manager, allowlisted, not frozen), no shares outstanding and no admin + /// hand-over pending. Deposits open from here on, and it never reopens. #[only_admin] pub fn finish_setup(e: &Env, caller: Address) { caller.require_auth(); @@ -300,8 +300,9 @@ impl AsyncVault { timelock::setup::is_open(e) } - /// The wiring `finish_setup` checks, plus the feed: whether it can price - /// now, and whether its reports outlive the notice. + /// Whether setup is open, the wiring `finish_setup` checks except the share + /// supply, and the feed: whether it can price now, and whether its reports + /// outlive the notice. pub fn health(e: &Env) -> Health { health::health(e) } diff --git a/contracts/nav-oracle/src/test_governance.rs b/contracts/nav-oracle/src/test_governance.rs index 38ed905e..dd265fcd 100644 --- a/contracts/nav-oracle/src/test_governance.rs +++ b/contracts/nav-oracle/src/test_governance.rs @@ -110,8 +110,8 @@ impl Fixture<'_> { assert_eq!(self.instance_ttl(), INSTANCE_EXTEND_AMOUNT); } - /// Signs one call to the oracle as `who` and nothing else, so the call's - /// admin check is the only thing that can refuse it. + /// Signs one call to the oracle as `who` and nothing else, so the call + /// carries the caller's signature but not the admin's. fn sign_only_as(&self, who: &Address, fn_name: &str, args: soroban_sdk::Vec) { self.e.mock_auths(&[MockAuth { address: who, diff --git a/contracts/share-token/src/test_governance.rs b/contracts/share-token/src/test_governance.rs index 01f17b25..f394bba3 100644 --- a/contracts/share-token/src/test_governance.rs +++ b/contracts/share-token/src/test_governance.rs @@ -99,8 +99,8 @@ impl Fixture<'_> { assert_eq!(self.instance_ttl(), INSTANCE_EXTEND_AMOUNT); } - /// Signs one call to the token as `who` and nothing else, so the call's - /// admin check is the only thing that can refuse it. + /// Signs one call to the token as `who` and nothing else, so the call + /// carries the caller's signature but not the admin's. fn sign_only_as(&self, who: &Address, fn_name: &str, args: soroban_sdk::Vec) { self.e.mock_auths(&[MockAuth { address: who, diff --git a/docs/AUTHORIZATION_MATRIX.md b/docs/AUTHORIZATION_MATRIX.md index ca034bdc..e1de3f84 100644 --- a/docs/AUTHORIZATION_MATRIX.md +++ b/docs/AUTHORIZATION_MATRIX.md @@ -66,7 +66,7 @@ The following entrypoints are open to any caller on purpose: | **`AsyncVault`** | `claim_wind_down` | `holder.require_auth()` | The holder signs their own claim; a keeper cannot claim for them. The payout goes to `holder`. | | **`AsyncVault`** | `request_deposit` | `from.require_auth()` | Investor entrypoint. Deposits escrowed cash; guarded by `when_not_paused`, not winding down, and refused with `SetupOpen` until the vault's setup finishes, so every deposit is covered by the timelock. | | **`AsyncVault`** | `claim_deposit` | `caller.require_auth()` | Investor entrypoint. Mints the shares once the epoch is priced; the token checks the receiver on `mint`. A delisted holder's shares become an exit instead (`DepositConvertedToExit`): they join the holder's redemption in the open epoch, or the holder's wind-down entitlement during a wind-down. A frozen holder's claim is held (`Frozen`). | -| **`AsyncVault`** | `cancel_deposit` | `from.require_auth()` | Investor exit before epoch pricing. Single-step refund. | +| **`AsyncVault`** | `cancel_deposit` | `from.require_auth()` | Investor exit while the epoch cannot be priced. Refunds the whole escrowed deposit in one step, delisted or not. A frozen holder is held (`Frozen`). | | **`AsyncVault`** | `request_redeem` | `from.require_auth()` | Investor exit. Always open, even during pause. | | **`AsyncVault`** | `claim_redeem` | `caller.require_auth()` | Investor exit. Pays settlement asset once covered. | | **`AsyncVault`** | `cancel_redeem` | `from.require_auth()` | Investor exit while the epoch cannot be priced. Outside a wind-down the vault checks the controller itself and returns the escrowed shares with the manager `forced_transfer`; it refuses a delisted controller (`NotAllowlisted`) and a move the compliance rules refuse (`HolderNotEligible`). In an active wind-down the escrow is surrendered into the controller's wind-down entitlement instead (`RedemptionSurrendered`). A frozen controller is held (`Frozen`) either way. | diff --git a/docs/kit-spec.md b/docs/kit-spec.md index 3f37f49c..b97e12a8 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -50,7 +50,7 @@ The contracts enforce a set of mathematical and state-machine invariants across - `request_deposit` is refused by `#[when_not_paused]` (`PausableError::EnforcedPause`). - `request_redeem` succeeds and locks shares into escrow. - `claim_redeem` and `claim_deposit` remain open. - - `cancel_redeem` remains open; a delisted controller exits through the cash claim instead, and a frozen one is held (Invariant 6). + - `cancel_redeem` remains open. Outside a wind-down, a delisted controller, or one the compliance rules refuse, exits through the cash claim instead; in a wind-down the escrow is surrendered. A frozen controller is held (Invariant 6). - `cancel_deposit` and `cancel_redeem` stay open for a sealed epoch while the vault is paused, because a paused vault cannot price it (Invariant 6). ### Invariant 6: Single-Step Atomic Cancellation @@ -166,7 +166,7 @@ The `async_vault` constructor strictly verifies pairwise separation across autho ### Health View -`health()` returns one flag per check. A deployment is ready when `setup_open` is false and every other flag is true. `health()` leaves out one `finish_setup` check on purpose, that the share token has no shares outstanding, because a healthy vault has shares outstanding from its first claim on. If `finish_setup` refuses with `SetupIncomplete` while `token_bound`, `oracle_bound`, `sole_token_manager`, `allowlisted`, `vault_unfrozen` and `compliance_bound` are all true, shares were minted before setup; the token's `total_supply()` shows them. +`health()` returns one flag per check. A deployment is ready when `setup_open` is false and every other flag is true. `health()` leaves out two `finish_setup` checks: that the share token has no shares outstanding, which stops being true once the vault has holders, and that no admin hand-over offered during setup is pending (`AdminTransferPending`). If `finish_setup` refuses with `SetupIncomplete` while `token_bound`, `oracle_bound`, `sole_token_manager`, `allowlisted`, `vault_unfrozen` and `compliance_bound` are all true, shares were minted before setup; the token's `total_supply()` shows them. | Field | True when | |---|---| diff --git a/scripts/harness/extend.ts b/scripts/harness/extend.ts index aa4bdaf6..97b6fd85 100644 --- a/scripts/harness/extend.ts +++ b/scripts/harness/extend.ts @@ -20,9 +20,9 @@ import { type Entry, } from "./lib/ttl.js" -// Restores whatever has been archived, then extends every contract instance -// and the Wasm it runs, so an idle deployment stays callable. Extending needs -// no authority: any funded account pays. +// Restores, when archived, and extends by 30 days each contract instance, the +// Wasm it runs and each pending timelock change, so an idle deployment stays +// callable. Extending needs no authority: any funded account pays. interface DeploymentRecord { contracts: Record From 037d896f242ee82f9e77b205ef552b11428a2b82 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:10:11 -0300 Subject: [PATCH 066/147] refactor: prefix the shared crates with strata The timelock, storage and bindings crates become strata-timelock, strata-storage and strata-bindings, so a path such as strata_timelock::DAY_DURATION names the kit's own code. The contract interfaces are unchanged. --- Cargo.lock | 60 +++++++++---------- contracts/async-vault/Cargo.toml | 6 +- contracts/async-vault/src/epoch.rs | 2 +- contracts/async-vault/src/governance.rs | 6 +- contracts/async-vault/src/health.rs | 4 +- contracts/async-vault/src/lib.rs | 8 +-- contracts/async-vault/src/pricing.rs | 2 +- contracts/async-vault/src/settlement.rs | 4 +- contracts/async-vault/src/state.rs | 50 ++++++++-------- contracts/async-vault/src/test/mod.rs | 2 +- contracts/async-vault/src/test/oracle_swap.rs | 6 +- contracts/async-vault/src/test/ttl.rs | 2 +- contracts/async-vault/src/test/upgrade.rs | 4 +- contracts/async-vault/src/upgrade.rs | 8 +-- contracts/async-vault/src/wind_down.rs | 40 ++++++------- contracts/compliance/Cargo.toml | 6 +- contracts/compliance/src/governance.rs | 4 +- contracts/compliance/src/lib.rs | 40 ++++++------- contracts/compliance/src/test.rs | 2 +- contracts/compliance/src/test/changes.rs | 10 ++-- contracts/identity-verifier/Cargo.toml | 2 +- contracts/identity-verifier/src/lib.rs | 14 ++--- contracts/identity-verifier/src/test.rs | 2 +- contracts/nav-oracle/Cargo.toml | 6 +- contracts/nav-oracle/src/governance.rs | 4 +- contracts/nav-oracle/src/lib.rs | 29 ++++----- contracts/nav-oracle/src/state.rs | 14 ++--- contracts/nav-oracle/src/test.rs | 8 +-- contracts/nav-oracle/src/test_governance.rs | 19 +++--- contracts/share-token/Cargo.toml | 6 +- contracts/share-token/src/contract.rs | 58 +++++++++--------- contracts/share-token/src/governance.rs | 4 +- contracts/share-token/src/test.rs | 6 +- contracts/share-token/src/test_governance.rs | 12 ++-- .../{bindings => strata-bindings}/Cargo.toml | 2 +- .../{bindings => strata-bindings}/src/lib.rs | 0 crates/{storage => strata-storage}/Cargo.toml | 2 +- crates/{storage => strata-storage}/src/lib.rs | 0 .../{storage => strata-storage}/src/test.rs | 0 .../{timelock => strata-timelock}/Cargo.toml | 4 +- .../src/clock.rs | 0 .../src/delay.rs | 0 .../{timelock => strata-timelock}/src/keys.rs | 0 .../{timelock => strata-timelock}/src/lib.rs | 0 .../src/pause.rs | 12 ++-- .../src/queue.rs | 14 ++--- .../src/refusal.rs | 0 .../src/schedule.rs | 0 .../src/setup.rs | 8 +-- .../src/test/clock.rs | 0 .../src/test/delay.rs | 0 .../src/test/mod.rs | 0 .../src/test/pause.rs | 0 .../src/test/queue.rs | 0 .../src/test/schedule.rs | 0 .../src/test/setup.rs | 0 docs/TESTING.md | 8 +-- docs/kit-spec.md | 10 ++-- scripts/harness/extend.ts | 2 +- 59 files changed, 258 insertions(+), 254 deletions(-) rename crates/{bindings => strata-bindings}/Cargo.toml (83%) rename crates/{bindings => strata-bindings}/src/lib.rs (100%) rename crates/{storage => strata-storage}/Cargo.toml (89%) rename crates/{storage => strata-storage}/src/lib.rs (100%) rename crates/{storage => strata-storage}/src/test.rs (100%) rename crates/{timelock => strata-timelock}/Cargo.toml (79%) rename crates/{timelock => strata-timelock}/src/clock.rs (100%) rename crates/{timelock => strata-timelock}/src/delay.rs (100%) rename crates/{timelock => strata-timelock}/src/keys.rs (100%) rename crates/{timelock => strata-timelock}/src/lib.rs (100%) rename crates/{timelock => strata-timelock}/src/pause.rs (62%) rename crates/{timelock => strata-timelock}/src/queue.rs (78%) rename crates/{timelock => strata-timelock}/src/refusal.rs (100%) rename crates/{timelock => strata-timelock}/src/schedule.rs (100%) rename crates/{timelock => strata-timelock}/src/setup.rs (78%) rename crates/{timelock => strata-timelock}/src/test/clock.rs (100%) rename crates/{timelock => strata-timelock}/src/test/delay.rs (100%) rename crates/{timelock => strata-timelock}/src/test/mod.rs (100%) rename crates/{timelock => strata-timelock}/src/test/pause.rs (100%) rename crates/{timelock => strata-timelock}/src/test/queue.rs (100%) rename crates/{timelock => strata-timelock}/src/test/schedule.rs (100%) rename crates/{timelock => strata-timelock}/src/test/setup.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index 6e8107a6..983faeda 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -184,7 +184,6 @@ checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" name = "async-vault" version = "0.1.0" dependencies = [ - "bindings", "compliance", "identity-verifier", "nav-oracle", @@ -195,8 +194,9 @@ dependencies = [ "stellar-contract-utils", "stellar-macros", "stellar-tokens", - "storage", - "timelock", + "strata-bindings", + "strata-storage", + "strata-timelock", ] [[package]] @@ -223,13 +223,6 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" -[[package]] -name = "bindings" -version = "0.1.0" -dependencies = [ - "soroban-sdk", -] - [[package]] name = "bit-set" version = "0.8.0" @@ -351,13 +344,13 @@ dependencies = [ name = "compliance" version = "0.1.0" dependencies = [ - "bindings", "soroban-sdk", "stellar-access", "stellar-macros", "stellar-tokens", - "storage", - "timelock", + "strata-bindings", + "strata-storage", + "strata-timelock", ] [[package]] @@ -1036,7 +1029,7 @@ dependencies = [ "stellar-access", "stellar-macros", "stellar-tokens", - "storage", + "strata-storage", ] [[package]] @@ -1213,13 +1206,13 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" name = "nav-oracle" version = "0.1.0" dependencies = [ - "bindings", "soroban-sdk", "stellar-access", "stellar-contract-utils", "stellar-macros", - "storage", - "timelock", + "strata-bindings", + "strata-storage", + "strata-timelock", ] [[package]] @@ -1711,7 +1704,6 @@ dependencies = [ name = "share-token" version = "0.1.0" dependencies = [ - "bindings", "compliance", "identity-verifier", "soroban-sdk", @@ -1719,8 +1711,9 @@ dependencies = [ "stellar-contract-utils", "stellar-macros", "stellar-tokens", - "storage", - "timelock", + "strata-bindings", + "strata-storage", + "strata-timelock", ] [[package]] @@ -2062,10 +2055,26 @@ dependencies = [ ] [[package]] -name = "storage" +name = "strata-bindings" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + +[[package]] +name = "strata-storage" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + +[[package]] +name = "strata-timelock" version = "0.1.0" dependencies = [ + "proptest", "soroban-sdk", + "strata-storage", ] [[package]] @@ -2185,15 +2194,6 @@ dependencies = [ "time-core", ] -[[package]] -name = "timelock" -version = "0.1.0" -dependencies = [ - "proptest", - "soroban-sdk", - "storage", -] - [[package]] name = "tinyvec" version = "1.12.0" diff --git a/contracts/async-vault/Cargo.toml b/contracts/async-vault/Cargo.toml index ecd0b862..11e869e8 100644 --- a/contracts/async-vault/Cargo.toml +++ b/contracts/async-vault/Cargo.toml @@ -11,13 +11,13 @@ doctest = false [dependencies] soroban-sdk = { workspace = true } -storage = { path = "../../crates/storage" } +strata-storage = { path = "../../crates/strata-storage" } stellar-access = { workspace = true } stellar-macros = { workspace = true } stellar-contract-utils = { workspace = true } stellar-tokens = { workspace = true } -bindings = { path = "../../crates/bindings" } -timelock = { path = "../../crates/timelock" } +strata-bindings = { path = "../../crates/strata-bindings" } +strata-timelock = { path = "../../crates/strata-timelock" } [dev-dependencies] diff --git a/contracts/async-vault/src/epoch.rs b/contracts/async-vault/src/epoch.rs index 2f5293b7..96ad4b03 100644 --- a/contracts/async-vault/src/epoch.rs +++ b/contracts/async-vault/src/epoch.rs @@ -1,6 +1,6 @@ -use bindings::{OracleFeedClient, OracleState}; use soroban_sdk::{panic_with_error, Env}; use stellar_contract_utils::pausable; +use strata_bindings::{OracleFeedClient, OracleState}; use crate::error::VaultError; use crate::event::{EpochClosed, EpochFulfilled}; diff --git a/contracts/async-vault/src/governance.rs b/contracts/async-vault/src/governance.rs index 89179d4c..78f3c649 100644 --- a/contracts/async-vault/src/governance.rs +++ b/contracts/async-vault/src/governance.rs @@ -1,4 +1,3 @@ -use bindings::{OracleFeedClient, VaultBindingClient}; use soroban_sdk::{ contracttype, panic_with_error, symbol_short, Address, BytesN, Env, Symbol, Vec, }; @@ -17,7 +16,8 @@ use stellar_tokens::{ RWATokenClient, }, }; -use timelock::{queue, setup, Clock, Refusal}; +use strata_bindings::{OracleFeedClient, VaultBindingClient}; +use strata_timelock::{queue, setup, Clock, Refusal}; use crate::error::VaultError; use crate::event::{ChangeApplied, ChangeCancelled, ChangeProposed, NoticeSet, SetupFinished}; @@ -91,7 +91,7 @@ pub(crate) fn clock(e: &Env) -> Clock { Clock { delay: upgrade::delay(e), exit_window: state::notice(e), - paused_secs: timelock::pause::paused_secs(e), + paused_secs: strata_timelock::pause::paused_secs(e), paused: pausable::paused(e), winding_down: wind_down::is_active(e), } diff --git a/contracts/async-vault/src/health.rs b/contracts/async-vault/src/health.rs index 894f5290..8f7884c7 100644 --- a/contracts/async-vault/src/health.rs +++ b/contracts/async-vault/src/health.rs @@ -1,5 +1,5 @@ -use bindings::{OracleFeedClient, OracleState}; use soroban_sdk::{contracttype, Env}; +use strata_bindings::{OracleFeedClient, OracleState}; use crate::governance; use crate::keys::DataKey; @@ -31,7 +31,7 @@ pub(crate) fn health(e: &Env) -> Health { let feed = OracleFeedClient::new(e, &state::get_addr(e, &DataKey::Oracle)); let notice = state::notice(e); Health { - setup_open: timelock::setup::is_open(e), + setup_open: strata_timelock::setup::is_open(e), token_bound: wiring.token_bound, oracle_bound: wiring.oracle_bound, sole_token_manager: wiring.sole_token_manager, diff --git a/contracts/async-vault/src/lib.rs b/contracts/async-vault/src/lib.rs index fcdb44e7..fa2ca093 100644 --- a/contracts/async-vault/src/lib.rs +++ b/contracts/async-vault/src/lib.rs @@ -24,7 +24,7 @@ use stellar_contract_utils::pausable::{self as pausable, Pausable}; use stellar_contract_utils::upgradeable; use stellar_macros::{only_admin, only_role, when_not_paused}; use stellar_tokens::fungible::FungibleTokenClient; -use timelock::{Clock, ClockSource, Refusal, TimelockHost, DAY_DURATION}; +use strata_timelock::{Clock, ClockSource, Refusal, TimelockHost, DAY_DURATION}; use keys::DataKey; use roles::{GUARDIAN_ROLE, MANAGER_ROLE, TREASURY_ROLE}; @@ -297,7 +297,7 @@ impl AsyncVault { } pub fn setup_open(e: &Env) -> bool { - timelock::setup::is_open(e) + strata_timelock::setup::is_open(e) } /// Whether setup is open, the wiring `finish_setup` checks except the share @@ -471,14 +471,14 @@ impl Pausable for AsyncVault { #[only_role(caller, "guardian")] fn pause(e: &Env, caller: Address) { pausable::pause(e); - timelock::pause::start(e); + strata_timelock::pause::start(e); } #[only_admin] fn unpause(e: &Env, caller: Address) { caller.require_auth(); pausable::unpause(e); - timelock::pause::end(e); + strata_timelock::pause::end(e); } } diff --git a/contracts/async-vault/src/pricing.rs b/contracts/async-vault/src/pricing.rs index e9ca7849..bd08416a 100644 --- a/contracts/async-vault/src/pricing.rs +++ b/contracts/async-vault/src/pricing.rs @@ -1,6 +1,6 @@ -use bindings::OracleFeedClient; use soroban_sdk::Env; use stellar_contract_utils::math::{i128_fixed_point::checked_mul_div_floor, wad::WAD_SCALE}; +use strata_bindings::OracleFeedClient; use crate::error::VaultError; use crate::keys::DataKey; diff --git a/contracts/async-vault/src/settlement.rs b/contracts/async-vault/src/settlement.rs index 6baeb408..24ab22cb 100644 --- a/contracts/async-vault/src/settlement.rs +++ b/contracts/async-vault/src/settlement.rs @@ -22,7 +22,7 @@ pub struct EpochSettlement { } pub(crate) fn get(e: &Env, epoch: u64) -> Option { - storage::get_persistent(e, &DataKey::Settlement(epoch)) + strata_storage::get_persistent(e, &DataKey::Settlement(epoch)) } fn load(e: &Env, epoch: u64) -> EpochSettlement { @@ -30,7 +30,7 @@ fn load(e: &Env, epoch: u64) -> EpochSettlement { } fn save(e: &Env, epoch: u64, s: &EpochSettlement) { - storage::set_persistent(e, &DataKey::Settlement(epoch), s); + strata_storage::set_persistent(e, &DataKey::Settlement(epoch), s); } fn close_one(e: &Env, open: u32) -> u32 { diff --git a/contracts/async-vault/src/state.rs b/contracts/async-vault/src/state.rs index 64a1419f..71195975 100644 --- a/contracts/async-vault/src/state.rs +++ b/contracts/async-vault/src/state.rs @@ -52,51 +52,51 @@ pub struct RedeemRequest { } pub(crate) fn set_addr(e: &Env, key: &DataKey, addr: &Address) { - storage::set_instance(e, key, addr); + strata_storage::set_instance(e, key, addr); } pub(crate) fn get_addr(e: &Env, key: &DataKey) -> Address { - storage::get_instance(e, key) + strata_storage::get_instance(e, key) .unwrap_or_else(|| panic_with_error!(e, VaultError::NotInitialized)) } pub(crate) fn get_addr_opt(e: &Env, key: &DataKey) -> Option
{ - storage::get_instance(e, key) + strata_storage::get_instance(e, key) } pub(crate) fn set_net_deployed(e: &Env, assets: i128) { - storage::set_instance(e, &DataKey::NetDeployed, &assets); + strata_storage::set_instance(e, &DataKey::NetDeployed, &assets); } pub(crate) fn net_deployed(e: &Env) -> i128 { - storage::get_instance(e, &DataKey::NetDeployed).unwrap_or(0) + strata_storage::get_instance(e, &DataKey::NetDeployed).unwrap_or(0) } pub(crate) fn set_current_epoch(e: &Env, id: u64) { - storage::set_instance(e, &DataKey::CurrentEpoch, &id); + strata_storage::set_instance(e, &DataKey::CurrentEpoch, &id); } pub(crate) fn current_epoch(e: &Env) -> u64 { - storage::get_instance(e, &DataKey::CurrentEpoch) + strata_storage::get_instance(e, &DataKey::CurrentEpoch) .unwrap_or_else(|| panic_with_error!(e, VaultError::NotInitialized)) } pub(crate) fn set_notice(e: &Env, secs: u64) { - storage::set_instance(e, &DataKey::Notice, &secs); + strata_storage::set_instance(e, &DataKey::Notice, &secs); } /// Seconds an epoch waits after closing before it can be priced. Absent means /// none, so a vault deployed without one behaves as it always did. pub(crate) fn notice(e: &Env) -> u64 { - storage::get_instance(e, &DataKey::Notice).unwrap_or(0) + strata_storage::get_instance(e, &DataKey::Notice).unwrap_or(0) } pub(crate) fn set_epoch(e: &Env, id: u64, epoch: &EpochInfo) { - storage::set_persistent(e, &DataKey::Epoch(id), epoch); + strata_storage::set_persistent(e, &DataKey::Epoch(id), epoch); } pub(crate) fn get_epoch(e: &Env, id: u64) -> Option { - storage::get_persistent(e, &DataKey::Epoch(id)) + strata_storage::get_persistent(e, &DataKey::Epoch(id)) } pub(crate) fn set_deposit_request( @@ -105,7 +105,7 @@ pub(crate) fn set_deposit_request( controller: &Address, request: &DepositRequest, ) { - storage::set_persistent(e, &DataKey::UserDeposit(epoch, controller.clone()), request); + strata_storage::set_persistent(e, &DataKey::UserDeposit(epoch, controller.clone()), request); } pub(crate) fn get_deposit_request( @@ -113,7 +113,7 @@ pub(crate) fn get_deposit_request( epoch: u64, controller: &Address, ) -> Option { - storage::get_persistent(e, &DataKey::UserDeposit(epoch, controller.clone())) + strata_storage::get_persistent(e, &DataKey::UserDeposit(epoch, controller.clone())) } pub(crate) fn set_redeem_request( @@ -122,7 +122,7 @@ pub(crate) fn set_redeem_request( controller: &Address, request: &RedeemRequest, ) { - storage::set_persistent(e, &DataKey::UserRedeem(epoch, controller.clone()), request); + strata_storage::set_persistent(e, &DataKey::UserRedeem(epoch, controller.clone()), request); } pub(crate) fn get_redeem_request( @@ -130,26 +130,26 @@ pub(crate) fn get_redeem_request( epoch: u64, controller: &Address, ) -> Option { - storage::get_persistent(e, &DataKey::UserRedeem(epoch, controller.clone())) + strata_storage::get_persistent(e, &DataKey::UserRedeem(epoch, controller.clone())) } /// Cash owed to holders whose exit is priced but not yet claimed. pub(crate) fn set_committed(e: &Env, assets: i128) { - storage::set_instance(e, &DataKey::Committed, &assets); + strata_storage::set_instance(e, &DataKey::Committed, &assets); } pub(crate) fn committed(e: &Env) -> i128 { - storage::get_instance(e, &DataKey::Committed).unwrap_or(0) + strata_storage::get_instance(e, &DataKey::Committed).unwrap_or(0) } /// Deposit escrow across every unpriced epoch, still cancellable by the /// investor. Pricing an epoch moves its share of this into the reserve. pub(crate) fn set_cancellable_escrow(e: &Env, assets: i128) { - storage::set_instance(e, &DataKey::CancellableEscrow, &assets); + strata_storage::set_instance(e, &DataKey::CancellableEscrow, &assets); } pub(crate) fn cancellable_escrow(e: &Env) -> i128 { - storage::get_instance(e, &DataKey::CancellableEscrow).unwrap_or(0) + strata_storage::get_instance(e, &DataKey::CancellableEscrow).unwrap_or(0) } pub(crate) fn remove_deposit_request(e: &Env, epoch: u64, controller: &Address) { @@ -165,28 +165,28 @@ pub(crate) fn remove_redeem_request(e: &Env, epoch: u64, controller: &Address) { } pub(crate) fn set_pending_mint_shares(e: &Env, shares: i128) { - storage::set_instance(e, &DataKey::PendingMintShares, &shares); + strata_storage::set_instance(e, &DataKey::PendingMintShares, &shares); } pub(crate) fn pending_mint_shares(e: &Env) -> i128 { - storage::get_instance(e, &DataKey::PendingMintShares).unwrap_or(0) + strata_storage::get_instance(e, &DataKey::PendingMintShares).unwrap_or(0) } pub(crate) fn set_pending_burn_shares(e: &Env, shares: i128) { - storage::set_instance(e, &DataKey::PendingBurnShares, &shares); + strata_storage::set_instance(e, &DataKey::PendingBurnShares, &shares); } pub(crate) fn pending_burn_shares(e: &Env) -> i128 { - storage::get_instance(e, &DataKey::PendingBurnShares).unwrap_or(0) + strata_storage::get_instance(e, &DataKey::PendingBurnShares).unwrap_or(0) } pub(crate) fn deposit_cap(e: &Env) -> Option { - storage::get_instance(e, &DataKey::DepositCap) + strata_storage::get_instance(e, &DataKey::DepositCap) } pub(crate) fn set_deposit_cap(e: &Env, cap: Option) { match cap { - Some(val) => storage::set_instance(e, &DataKey::DepositCap, &val), + Some(val) => strata_storage::set_instance(e, &DataKey::DepositCap, &val), None => e.storage().instance().remove(&DataKey::DepositCap), } } diff --git a/contracts/async-vault/src/test/mod.rs b/contracts/async-vault/src/test/mod.rs index b95c7a16..7b719b32 100644 --- a/contracts/async-vault/src/test/mod.rs +++ b/contracts/async-vault/src/test/mod.rs @@ -43,7 +43,7 @@ pub(crate) use share_token::{ShareToken, ShareTokenClient}; pub(crate) use stellar_contract_utils::math::wad::WAD_SCALE; use stellar_tokens::rwa::compliance::ComplianceHook; -pub(crate) use timelock::DAY_DURATION; +pub(crate) use strata_timelock::DAY_DURATION; pub(crate) use crate::{ AsyncVault, AsyncVaultClient, DirectUnitPricing, EpochStatus, PricingScheme, VaultChange, diff --git a/contracts/async-vault/src/test/oracle_swap.rs b/contracts/async-vault/src/test/oracle_swap.rs index ef5966f7..a383343c 100644 --- a/contracts/async-vault/src/test/oracle_swap.rs +++ b/contracts/async-vault/src/test/oracle_swap.rs @@ -12,13 +12,13 @@ impl FeedStub { e.storage().instance().set(&symbol_short!("vault"), &vault); } - pub fn state(_e: Env) -> bindings::OracleState { - bindings::OracleState::Valid + pub fn state(_e: Env) -> strata_bindings::OracleState { + strata_bindings::OracleState::Valid } } #[contractimpl(contracttrait)] -impl bindings::VaultBinding for FeedStub { +impl strata_bindings::VaultBinding for FeedStub { fn vault(e: &Env) -> Option
{ e.storage() .instance() diff --git a/contracts/async-vault/src/test/ttl.rs b/contracts/async-vault/src/test/ttl.rs index 06203925..9bc2ab65 100644 --- a/contracts/async-vault/src/test/ttl.rs +++ b/contracts/async-vault/src/test/ttl.rs @@ -1,5 +1,5 @@ use soroban_sdk::testutils::storage::{Instance as _, Persistent as _}; -use storage::{INSTANCE_EXTEND_AMOUNT, PERSISTENT_EXTEND_AMOUNT}; +use strata_storage::{INSTANCE_EXTEND_AMOUNT, PERSISTENT_EXTEND_AMOUNT}; use super::*; use crate::keys::DataKey; diff --git a/contracts/async-vault/src/test/upgrade.rs b/contracts/async-vault/src/test/upgrade.rs index dd01ca0c..5c4ed548 100644 --- a/contracts/async-vault/src/test/upgrade.rs +++ b/contracts/async-vault/src/test/upgrade.rs @@ -413,11 +413,11 @@ fn the_vault_reports_its_clock_to_the_token_and_the_oracle() { f.vault.pause(&f.guardian); f.advance(DAY_DURATION); - let clock = timelock::ClockSourceClient::new(&f.e, &f.vault.address).timelock_clock(); + let clock = strata_timelock::ClockSourceClient::new(&f.e, &f.vault.address).timelock_clock(); assert_eq!( clock, - timelock::Clock { + strata_timelock::Clock { delay: MIN_UPGRADE_DELAY, exit_window: 0, paused_secs: DAY_DURATION, diff --git a/contracts/async-vault/src/upgrade.rs b/contracts/async-vault/src/upgrade.rs index ff532e71..28000229 100644 --- a/contracts/async-vault/src/upgrade.rs +++ b/contracts/async-vault/src/upgrade.rs @@ -6,17 +6,17 @@ use crate::state; /// Seven days. Short enough to fix a defect in a week, long enough that an /// investor who dislikes the change has time to act on it. -pub const MIN_UPGRADE_DELAY: u64 = timelock::MIN_DELAY; +pub const MIN_UPGRADE_DELAY: u64 = strata_timelock::MIN_DELAY; /// Ninety days. Prevents setting a delay that effectively brick-walls the vault /// against future upgrades. -pub const MAX_UPGRADE_DELAY: u64 = timelock::MAX_DELAY; +pub const MAX_UPGRADE_DELAY: u64 = strata_timelock::MAX_DELAY; pub(crate) fn delay(e: &Env) -> u64 { - storage::get_instance(e, &DataKey::UpgradeDelay).unwrap_or(MIN_UPGRADE_DELAY) + strata_storage::get_instance(e, &DataKey::UpgradeDelay).unwrap_or(MIN_UPGRADE_DELAY) } pub(crate) fn set_delay(e: &Env, secs: u64) { - storage::set_instance(e, &DataKey::UpgradeDelay, &secs); + strata_storage::set_instance(e, &DataKey::UpgradeDelay, &secs); } /// An exit takes a full notice, so a delay shorter than one is not a window. diff --git a/contracts/async-vault/src/wind_down.rs b/contracts/async-vault/src/wind_down.rs index 651ab517..6667abe9 100644 --- a/contracts/async-vault/src/wind_down.rs +++ b/contracts/async-vault/src/wind_down.rs @@ -14,12 +14,12 @@ use crate::treasury; /// Ninety days. Long enough for a real announcement period, short enough that /// governance cannot set a delay the vault would never outlive. -pub const MAX_WIND_DOWN_DELAY: u64 = 90 * timelock::DAY_DURATION; +pub const MAX_WIND_DOWN_DELAY: u64 = 90 * strata_timelock::DAY_DURATION; /// Seven days: holders always get a week's warning before a wind-down starts. /// It is also the delay when none was set. #[cfg(not(feature = "short_wind_down"))] -pub const MIN_WIND_DOWN_DELAY: u64 = 7 * timelock::DAY_DURATION; +pub const MIN_WIND_DOWN_DELAY: u64 = 7 * strata_timelock::DAY_DURATION; /// The local harness build activates a wind-down at once. #[cfg(feature = "short_wind_down")] pub const MIN_WIND_DOWN_DELAY: u64 = 0; @@ -50,7 +50,7 @@ pub struct WindDownPosition { } pub(crate) fn delay(e: &Env) -> u64 { - storage::get_instance(e, &DataKey::WindDownDelay).unwrap_or(MIN_WIND_DOWN_DELAY) + strata_storage::get_instance(e, &DataKey::WindDownDelay).unwrap_or(MIN_WIND_DOWN_DELAY) } #[cfg_attr(feature = "short_wind_down", allow(clippy::absurd_extreme_comparisons))] @@ -65,12 +65,12 @@ pub(crate) fn check_delay(e: &Env, secs: u64) { pub(crate) fn set_delay(e: &Env, secs: u64) { check_delay(e, secs); - storage::set_instance(e, &DataKey::WindDownDelay, &secs); + strata_storage::set_instance(e, &DataKey::WindDownDelay, &secs); WindDownDelaySet { secs }.publish(e); } pub(crate) fn info(e: &Env) -> Option { - storage::get_instance(e, &DataKey::WindDown) + strata_storage::get_instance(e, &DataKey::WindDown) } pub(crate) fn is_active(e: &Env) -> bool { @@ -108,7 +108,7 @@ pub(crate) fn propose(e: &Env) { } let active_at = e.ledger().timestamp().saturating_add(delay(e)); - storage::set_instance( + strata_storage::set_instance( e, &DataKey::WindDown, &WindDownInfo { @@ -149,25 +149,25 @@ pub(crate) fn activate(e: &Env) { } wd.status = WindDownStatus::Active; - storage::set_instance(e, &DataKey::WindDown, &wd); + strata_storage::set_instance(e, &DataKey::WindDown, &wd); WindDownActivated {}.publish(e); } pub(crate) fn owed(e: &Env) -> i128 { - storage::get_instance(e, &DataKey::WindDownOwed).unwrap_or(0) + strata_storage::get_instance(e, &DataKey::WindDownOwed).unwrap_or(0) } pub(crate) fn acc(e: &Env) -> i128 { - storage::get_instance(e, &DataKey::WindDownAcc).unwrap_or(0) + strata_storage::get_instance(e, &DataKey::WindDownAcc).unwrap_or(0) } pub(crate) fn supply_snapshot(e: &Env) -> i128 { - storage::get_instance(e, &DataKey::WindDownSupply).unwrap_or(0) + strata_storage::get_instance(e, &DataKey::WindDownSupply).unwrap_or(0) } fn credited_before_snapshot(e: &Env) -> i128 { - storage::get_instance(e, &DataKey::WindDownCredited).unwrap_or(0) + strata_storage::get_instance(e, &DataKey::WindDownCredited).unwrap_or(0) } /// Shares that exist or are owed: circulating, escrowed against an unpriced @@ -190,13 +190,13 @@ pub(crate) fn credit(e: &Env, holder: &Address, shares: i128) { .entitlement .checked_add(shares) .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)); - storage::set_persistent(e, &DataKey::WindDownPosition(holder.clone()), &p); + strata_storage::set_persistent(e, &DataKey::WindDownPosition(holder.clone()), &p); if info(e).map(|wd| wd.round).unwrap_or(0) == 0 { let credited = credited_before_snapshot(e) .checked_add(shares) .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)); - storage::set_instance(e, &DataKey::WindDownCredited, &credited); + strata_storage::set_instance(e, &DataKey::WindDownCredited, &credited); } } @@ -208,7 +208,7 @@ pub(crate) fn finalize_round(e: &Env) -> i128 { let snapshot = if wd.round == 0 { let s = snapshot_supply(e); - storage::set_instance(e, &DataKey::WindDownSupply, &s); + strata_storage::set_instance(e, &DataKey::WindDownSupply, &s); s } else { supply_snapshot(e) @@ -242,11 +242,11 @@ pub(crate) fn finalize_round(e: &Env) -> i128 { let owed_total = owed(e) .checked_add(credited) .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)); - storage::set_instance(e, &DataKey::WindDownAcc, &acc_per_share); - storage::set_instance(e, &DataKey::WindDownOwed, &owed_total); + strata_storage::set_instance(e, &DataKey::WindDownAcc, &acc_per_share); + strata_storage::set_instance(e, &DataKey::WindDownOwed, &owed_total); wd.round += 1; - storage::set_instance(e, &DataKey::WindDown, &wd); + strata_storage::set_instance(e, &DataKey::WindDown, &wd); WindDownRoundFinalized { round: wd.round, @@ -260,7 +260,7 @@ pub(crate) fn finalize_round(e: &Env) -> i128 { } pub(crate) fn position(e: &Env, holder: &Address) -> WindDownPosition { - storage::get_persistent(e, &DataKey::WindDownPosition(holder.clone())).unwrap_or( + strata_storage::get_persistent(e, &DataKey::WindDownPosition(holder.clone())).unwrap_or( WindDownPosition { entitlement: 0, paid: 0, @@ -324,14 +324,14 @@ pub(crate) fn claim(e: &Env, holder: &Address) -> i128 { } p.paid = earned; - storage::set_persistent(e, &DataKey::WindDownPosition(holder.clone()), &p); + strata_storage::set_persistent(e, &DataKey::WindDownPosition(holder.clone()), &p); if surrendered > 0 { RWATokenClient::new(e, &share_token).burn(holder, &surrendered, &vault); } if assets > 0 { - storage::set_instance( + strata_storage::set_instance( e, &DataKey::WindDownOwed, &state::reduce(e, owed(e), assets), diff --git a/contracts/compliance/Cargo.toml b/contracts/compliance/Cargo.toml index 6ebfdf51..aa79d1b2 100644 --- a/contracts/compliance/Cargo.toml +++ b/contracts/compliance/Cargo.toml @@ -14,9 +14,9 @@ soroban-sdk = { workspace = true } stellar-access = { workspace = true } stellar-macros = { workspace = true } stellar-tokens = { workspace = true } -storage = { path = "../../crates/storage" } -timelock = { path = "../../crates/timelock" } -bindings = { path = "../../crates/bindings" } +strata-storage = { path = "../../crates/strata-storage" } +strata-timelock = { path = "../../crates/strata-timelock" } +strata-bindings = { path = "../../crates/strata-bindings" } [dev-dependencies] soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/compliance/src/governance.rs b/contracts/compliance/src/governance.rs index ee70a2a1..657f157c 100644 --- a/contracts/compliance/src/governance.rs +++ b/contracts/compliance/src/governance.rs @@ -4,7 +4,7 @@ use stellar_access::{ role_transfer, }; use stellar_tokens::rwa::{compliance, compliance::ComplianceHook, utils::token_binder}; -use timelock::{queue, setup, Clock, Refusal, TimelockHost}; +use strata_timelock::{queue, setup, Clock, Refusal, TimelockHost}; use crate::{ ChangeApplied, ChangeCancelled, ChangeProposed, Compliance, ComplianceContractError, @@ -37,7 +37,7 @@ pub struct CompliancePendingChange { /// The compliance contract keeps no clock of its own: it reads the vault's. impl TimelockHost for Compliance { fn clock(e: &Env) -> Result { - timelock::clock::from_source(e) + strata_timelock::clock::from_source(e) } } diff --git a/contracts/compliance/src/lib.rs b/contracts/compliance/src/lib.rs index f738634f..3dd695db 100644 --- a/contracts/compliance/src/lib.rs +++ b/contracts/compliance/src/lib.rs @@ -12,10 +12,10 @@ contractmeta!( key = "stub", val = "testnet-only; prod = external compliance module" ); -use bindings::VaultBinding; use stellar_access::access_control; use stellar_macros::only_admin; use stellar_tokens::rwa::{compliance, compliance::ComplianceHook, utils::token_binder}; +use strata_bindings::VaultBinding; pub use governance::{ComplianceChange, CompliancePendingChange, ADMIN_ACCEPT_LEDGERS}; @@ -90,14 +90,14 @@ pub struct Compliance; impl Compliance { pub fn __constructor(e: &Env, admin: Address) { access_control::set_admin(e, &admin); - storage::bump_instance(e); + strata_storage::bump_instance(e); } /// Names the vault whose clock this contract's timelock reads. Once, and /// only during setup. #[only_admin] pub fn bind_vault(e: &Env, vault: Address, _operator: Address) { - storage::bump_instance(e); + strata_storage::bump_instance(e); governance::bind_vault(e, &vault); } @@ -105,20 +105,20 @@ impl Compliance { /// never reopens. #[only_admin] pub fn finish_setup(e: &Env, _operator: Address) { - storage::bump_instance(e); + strata_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: ComplianceChange, _operator: Address) -> u32 { - storage::bump_instance(e); + strata_storage::bump_instance(e); governance::propose(e, change) } #[only_admin] pub fn cancel_change(e: &Env, id: u32, _operator: Address) { - storage::bump_instance(e); + strata_storage::bump_instance(e); governance::cancel(e, id); } @@ -126,7 +126,7 @@ impl Compliance { /// or winding down. #[only_admin] pub fn apply_change(e: &Env, id: u32, _operator: Address) { - storage::bump_instance(e); + strata_storage::bump_instance(e); governance::apply(e, id); } @@ -141,12 +141,12 @@ impl Compliance { if live_until_ledger != 0 { governance::require_setup_open(e); } - storage::bump_instance(e); + strata_storage::bump_instance(e); access_control::transfer_admin_role(e, &new_admin, live_until_ledger); } pub fn accept_admin_transfer(e: &Env) { - storage::bump_instance(e); + strata_storage::bump_instance(e); access_control::accept_admin_transfer(e); } @@ -158,11 +158,11 @@ impl Compliance { #[contractimpl(contracttrait)] impl VaultBinding for Compliance { fn vault(e: &Env) -> Option
{ - timelock::setup::source(e) + strata_timelock::setup::source(e) } fn setup_open(e: &Env) -> bool { - timelock::setup::is_open(e) + strata_timelock::setup::is_open(e) } } @@ -173,14 +173,14 @@ impl token_binder::TokenBinder for Compliance { #[only_admin] fn bind_token(e: &Env, token: Address, _operator: Address) { governance::require_setup_open(e); - storage::bump_instance(e); + strata_storage::bump_instance(e); token_binder::bind_token(e, &token); } #[only_admin] fn unbind_token(e: &Env, token: Address, _operator: Address) { governance::require_setup_open(e); - storage::bump_instance(e); + strata_storage::bump_instance(e); token_binder::unbind_token(e, &token); } } @@ -194,39 +194,39 @@ impl compliance::Compliance for Compliance { #[only_admin] fn add_module_to(e: &Env, hook: ComplianceHook, module: Address, _operator: Address) { governance::require_setup_open(e); - storage::bump_instance(e); + strata_storage::bump_instance(e); compliance::storage::add_module_to(e, hook, module); } #[only_admin] fn remove_module_from(e: &Env, hook: ComplianceHook, module: Address, _operator: Address) { governance::require_setup_open(e); - storage::bump_instance(e); + strata_storage::bump_instance(e); compliance::storage::remove_module_from(e, hook, module); } fn transferred(e: &Env, from: Address, to: Address, amount: i128, token: Address) { - storage::bump_instance(e); + strata_storage::bump_instance(e); compliance::storage::transferred(e, from, to, amount, token); } fn created(e: &Env, to: Address, amount: i128, token: Address) { - storage::bump_instance(e); + strata_storage::bump_instance(e); compliance::storage::created(e, to, amount, token); } fn destroyed(e: &Env, from: Address, amount: i128, token: Address) { - storage::bump_instance(e); + strata_storage::bump_instance(e); compliance::storage::destroyed(e, from, amount, token); } fn can_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) -> bool { - storage::bump_instance(e); + strata_storage::bump_instance(e); compliance::storage::can_transfer(e, from, to, amount, token) } fn can_create(e: &Env, to: Address, amount: i128, token: Address) -> bool { - storage::bump_instance(e); + strata_storage::bump_instance(e); compliance::storage::can_create(e, to, amount, token) } } diff --git a/contracts/compliance/src/test.rs b/contracts/compliance/src/test.rs index 14200161..07de0b74 100644 --- a/contracts/compliance/src/test.rs +++ b/contracts/compliance/src/test.rs @@ -4,7 +4,7 @@ use soroban_sdk::{ testutils::{storage::Instance as _, Address as _, Ledger as _}, Address, Env, }; -use storage::INSTANCE_EXTEND_AMOUNT; +use strata_storage::INSTANCE_EXTEND_AMOUNT; use crate::{Compliance, ComplianceClient}; diff --git a/contracts/compliance/src/test/changes.rs b/contracts/compliance/src/test/changes.rs index 31b6e9d1..89734b4f 100644 --- a/contracts/compliance/src/test/changes.rs +++ b/contracts/compliance/src/test/changes.rs @@ -5,8 +5,8 @@ use soroban_sdk::{ }; use stellar_access::role_transfer::RoleTransferError; use stellar_tokens::rwa::compliance::ComplianceHook; -use storage::INSTANCE_EXTEND_AMOUNT; -use timelock::{Clock, DAY_DURATION, MIN_DELAY}; +use strata_storage::INSTANCE_EXTEND_AMOUNT; +use strata_timelock::{Clock, DAY_DURATION, MIN_DELAY}; use super::{age, instance_ttl}; use crate::{ @@ -33,7 +33,7 @@ impl ClockStub { } #[contractimpl(contracttrait)] -impl timelock::ClockSource for ClockStub { +impl strata_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); @@ -186,13 +186,13 @@ fn an_address_that_reports_no_clock_cannot_be_bound() { assert_eq!(f.rules.vault(), None); } -fn implements_the_vault_view() {} +fn implements_the_vault_view() {} #[test] fn the_contract_answers_the_view_the_vault_reads() { implements_the_vault_view::(); let f = unbound(); - let view = bindings::VaultBindingClient::new(&f.e, &f.rules.address); + let view = strata_bindings::VaultBindingClient::new(&f.e, &f.rules.address); assert_eq!(view.vault(), None); assert!(view.setup_open()); diff --git a/contracts/identity-verifier/Cargo.toml b/contracts/identity-verifier/Cargo.toml index 86550d5b..7eb6f83c 100644 --- a/contracts/identity-verifier/Cargo.toml +++ b/contracts/identity-verifier/Cargo.toml @@ -14,7 +14,7 @@ soroban-sdk = { workspace = true } stellar-access = { workspace = true } stellar-macros = { workspace = true } stellar-tokens = { workspace = true } -storage = { path = "../../crates/storage" } +strata-storage = { path = "../../crates/strata-storage" } [dev-dependencies] soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/identity-verifier/src/lib.rs b/contracts/identity-verifier/src/lib.rs index 4206c639..83a677c1 100644 --- a/contracts/identity-verifier/src/lib.rs +++ b/contracts/identity-verifier/src/lib.rs @@ -26,20 +26,20 @@ pub struct IdentityVerifier; impl IdentityVerifier { pub fn __constructor(e: &Env, admin: Address) { access_control::set_admin(e, &admin); - storage::bump_instance(e); + strata_storage::bump_instance(e); } /// Adds or removes an account from the allowlist. #[only_admin] pub fn allow(e: &Env, account: Address, allowed: bool, caller: Address) { caller.require_auth(); - storage::bump_instance(e); - storage::set_persistent(e, &DataKey::Allowed(account), &allowed); + strata_storage::bump_instance(e); + strata_storage::set_persistent(e, &DataKey::Allowed(account), &allowed); } pub fn is_allowed(e: &Env, account: Address) -> bool { - storage::bump_instance(e); - storage::get_persistent(e, &DataKey::Allowed(account)).unwrap_or(false) + strata_storage::bump_instance(e); + strata_storage::get_persistent(e, &DataKey::Allowed(account)).unwrap_or(false) } } @@ -48,9 +48,9 @@ impl identity_verification::IdentityVerifier for IdentityVerifier { /// The share token calls this on every mint and transfer, so it is what /// keeps the instance alive. fn verify_identity(e: &Env, account: &Address) { - storage::bump_instance(e); + strata_storage::bump_instance(e); let allowed: bool = - storage::get_persistent(e, &DataKey::Allowed(account.clone())).unwrap_or(false); + strata_storage::get_persistent(e, &DataKey::Allowed(account.clone())).unwrap_or(false); if !allowed { panic_with_error!(e, RWAError::IdentityVerificationFailed); } diff --git a/contracts/identity-verifier/src/test.rs b/contracts/identity-verifier/src/test.rs index 221bf836..7b9190a1 100644 --- a/contracts/identity-verifier/src/test.rs +++ b/contracts/identity-verifier/src/test.rs @@ -3,7 +3,7 @@ use soroban_sdk::{ testutils::{storage::Instance as _, storage::Persistent as _, Address as _, Ledger as _}, Address, Env, }; -use storage::{INSTANCE_EXTEND_AMOUNT, PERSISTENT_EXTEND_AMOUNT}; +use strata_storage::{INSTANCE_EXTEND_AMOUNT, PERSISTENT_EXTEND_AMOUNT}; const DAY_IN_LEDGERS: u32 = 17_280; use stellar_tokens::rwa::identity_verification::IdentityVerifierClient as IdClient; diff --git a/contracts/nav-oracle/Cargo.toml b/contracts/nav-oracle/Cargo.toml index 5061ab97..3835900c 100644 --- a/contracts/nav-oracle/Cargo.toml +++ b/contracts/nav-oracle/Cargo.toml @@ -11,11 +11,11 @@ doctest = false [dependencies] soroban-sdk = { workspace = true } -storage = { path = "../../crates/storage" } +strata-storage = { path = "../../crates/strata-storage" } stellar-access = { workspace = true } stellar-macros = { workspace = true } -bindings = { path = "../../crates/bindings" } -timelock = { path = "../../crates/timelock" } +strata-bindings = { path = "../../crates/strata-bindings" } +strata-timelock = { path = "../../crates/strata-timelock" } stellar-contract-utils = { workspace = true } [dev-dependencies] diff --git a/contracts/nav-oracle/src/governance.rs b/contracts/nav-oracle/src/governance.rs index 553af765..aafa501d 100644 --- a/contracts/nav-oracle/src/governance.rs +++ b/contracts/nav-oracle/src/governance.rs @@ -4,7 +4,7 @@ use stellar_access::{ role_transfer, }; use stellar_contract_utils::upgradeable; -use timelock::{queue, setup, Clock, Refusal, TimelockHost}; +use strata_timelock::{queue, setup, Clock, Refusal, TimelockHost}; use crate::state::OracleConfig; use crate::{ @@ -51,7 +51,7 @@ pub fn shortens_freshness_only(current: &OracleConfig, next: &OracleConfig) -> b /// 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) + strata_timelock::clock::from_source(e) } } diff --git a/contracts/nav-oracle/src/lib.rs b/contracts/nav-oracle/src/lib.rs index cbe84820..12eecb99 100644 --- a/contracts/nav-oracle/src/lib.rs +++ b/contracts/nav-oracle/src/lib.rs @@ -3,7 +3,6 @@ mod governance; mod state; -use bindings::{OracleFeed, VaultBinding}; use soroban_sdk::{ contract, contracterror, contractevent, contractimpl, panic_with_error, Address, Env, Symbol, Vec, @@ -11,6 +10,7 @@ use soroban_sdk::{ use stellar_access::access_control::{self, AccessControl}; use stellar_contract_utils::upgradeable; use stellar_macros::{only_admin, only_role}; +use strata_bindings::{OracleFeed, VaultBinding}; use state::{get_config, get_latest, nav_paused, ATTESTER_ROLE, GUARDIAN_ROLE}; @@ -25,7 +25,7 @@ const WAD: i128 = 1_000_000_000_000_000_000; /// One report per ledger at most. A deployment sets a longer cooldown. pub const MIN_COOLDOWN_SECS: u64 = 1; /// Thirty-five days: a monthly valuation with slack, and no longer. -pub const MAX_FRESHNESS_SECS: u64 = 35 * timelock::DAY_DURATION; +pub const MAX_FRESHNESS_SECS: u64 = 35 * strata_timelock::DAY_DURATION; /// A million units of the asset per share. Far above any real price, and far /// below where the deviation arithmetic could overflow. pub const MAX_ANSWER: i128 = 1_000_000 * WAD; @@ -202,7 +202,7 @@ impl AccessControl for NavOracleContract { /// value. fn grant_role(e: &Env, account: Address, role: Symbol, caller: Address) { governance::require_setup_open(e); - storage::bump_instance(e); + strata_storage::bump_instance(e); access_control::grant_role(e, &account, &role, &caller); } @@ -212,7 +212,7 @@ impl AccessControl for NavOracleContract { if live_until_ledger != 0 { governance::require_setup_open(e); } - storage::bump_instance(e); + strata_storage::bump_instance(e); access_control::transfer_admin_role(e, &new_admin, live_until_ledger); } @@ -233,11 +233,11 @@ impl AccessControl for NavOracleContract { #[contractimpl(contracttrait)] impl VaultBinding for NavOracleContract { fn vault(e: &Env) -> Option
{ - timelock::setup::source(e) + strata_timelock::setup::source(e) } fn setup_open(e: &Env) -> bool { - timelock::setup::is_open(e) + strata_timelock::setup::is_open(e) } } @@ -269,7 +269,7 @@ impl NavOracleContract { #[only_admin] pub fn bind_vault(e: &Env, vault: Address, caller: Address) { caller.require_auth(); - storage::bump_instance(e); + strata_storage::bump_instance(e); governance::bind_vault(e, &vault); } @@ -277,7 +277,7 @@ impl NavOracleContract { #[only_admin] pub fn finish_setup(e: &Env, caller: Address) { caller.require_auth(); - storage::bump_instance(e); + strata_storage::bump_instance(e); governance::finish_setup(e); } @@ -285,14 +285,14 @@ impl NavOracleContract { #[only_admin] pub fn propose_change(e: &Env, change: OracleChange, caller: Address) -> u32 { caller.require_auth(); - storage::bump_instance(e); + strata_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); + strata_storage::bump_instance(e); governance::cancel(e, id); } @@ -301,7 +301,7 @@ impl NavOracleContract { #[only_admin] pub fn apply_change(e: &Env, id: u32, caller: Address) { caller.require_auth(); - storage::bump_instance(e); + strata_storage::bump_instance(e); governance::apply(e, id); } @@ -328,7 +328,7 @@ impl NavOracleContract { #[only_role(caller, "attester")] pub fn attest(e: &Env, report: NavReport, caller: Address) { - storage::bump_instance(e); + strata_storage::bump_instance(e); if nav_paused(e) { panic_with_error!(e, OracleError::NavPaused); } @@ -412,7 +412,8 @@ impl NavOracleContract { #[only_admin] pub fn set_config(e: &Env, config: OracleConfig) { validate_config(e, &config); - if !timelock::setup::is_open(e) && !shortens_freshness_only(&get_config(e), &config) { + if !strata_timelock::setup::is_open(e) && !shortens_freshness_only(&get_config(e), &config) + { panic_with_error!(e, OracleError::UseTimelock); } store_config(e, &config); @@ -455,7 +456,7 @@ impl NavOracleContract { #[only_admin] pub fn migrate(e: &Env, caller: Address) { caller.require_auth(); - storage::bump_instance(e); + strata_storage::bump_instance(e); if upgradeable::get_schema_version(e) >= 2 { panic_with_error!(e, ProbeError::AlreadyMigrated); } diff --git a/contracts/nav-oracle/src/state.rs b/contracts/nav-oracle/src/state.rs index 59af24e2..81786870 100644 --- a/contracts/nav-oracle/src/state.rs +++ b/contracts/nav-oracle/src/state.rs @@ -8,7 +8,7 @@ use soroban_sdk::{contracttype, symbol_short, Env, Symbol}; // Value untrusted; BOTH mint and redeem halt. // - `Paused`: the NAV is paused (issuer freeze). Value ignored entirely, both // directions, until the issuer clears the NAV pause. -pub use bindings::OracleState; +pub use strata_bindings::OracleState; pub const ATTESTER_ROLE: Symbol = symbol_short!("attester"); @@ -49,23 +49,23 @@ pub(crate) enum DataKey { } pub(crate) fn set_config(e: &Env, config: &OracleConfig) { - storage::set_instance(e, &DataKey::Config, config); + strata_storage::set_instance(e, &DataKey::Config, config); } pub(crate) fn get_config(e: &Env) -> OracleConfig { - storage::get_instance(e, &DataKey::Config).unwrap() + strata_storage::get_instance(e, &DataKey::Config).unwrap() } pub(crate) fn set_nav_paused(e: &Env, paused: bool) { - storage::set_instance(e, &DataKey::NavPaused, &paused); + strata_storage::set_instance(e, &DataKey::NavPaused, &paused); } pub(crate) fn nav_paused(e: &Env) -> bool { - storage::get_instance(e, &DataKey::NavPaused).unwrap_or(false) + strata_storage::get_instance(e, &DataKey::NavPaused).unwrap_or(false) } pub(crate) fn set_latest(e: &Env, report: &NavReport) { - storage::set_persistent(e, &DataKey::Latest, report); + strata_storage::set_persistent(e, &DataKey::Latest, report); } pub(crate) fn clear_latest(e: &Env) { @@ -73,5 +73,5 @@ pub(crate) fn clear_latest(e: &Env) { } pub(crate) fn get_latest(e: &Env) -> Option { - storage::get_persistent(e, &DataKey::Latest) + strata_storage::get_persistent(e, &DataKey::Latest) } diff --git a/contracts/nav-oracle/src/test.rs b/contracts/nav-oracle/src/test.rs index 3e9c0b99..bd435db8 100644 --- a/contracts/nav-oracle/src/test.rs +++ b/contracts/nav-oracle/src/test.rs @@ -181,14 +181,14 @@ fn the_latest_valuation_keeps_a_full_ttl_while_it_is_read() { let decayed = f.e.as_contract(&f.oracle.address, || { f.e.storage().persistent().get_ttl(&DataKey::Latest) }); - assert!(decayed < storage::PERSISTENT_EXTEND_AMOUNT); + assert!(decayed < strata_storage::PERSISTENT_EXTEND_AMOUNT); f.oracle.latest(); let restored = f.e.as_contract(&f.oracle.address, || { f.e.storage().persistent().get_ttl(&DataKey::Latest) }); - assert_eq!(restored, storage::PERSISTENT_EXTEND_AMOUNT); + assert_eq!(restored, strata_storage::PERSISTENT_EXTEND_AMOUNT); } #[test] @@ -651,7 +651,7 @@ fn the_bounds_themselves_are_accepted() { }); } -fn implements_the_feed() {} +fn implements_the_feed() {} #[test] fn the_oracle_answers_the_feed_interface_the_vault_reads() { @@ -660,7 +660,7 @@ fn the_oracle_answers_the_feed_interface_the_vault_reads() { f.oracle .attest(&report(&f.e, SCALE, 0, 1_000_000), &f.attester); - let feed = bindings::OracleFeedClient::new(&f.e, &f.oracle.address); + let feed = strata_bindings::OracleFeedClient::new(&f.e, &f.oracle.address); assert_eq!(feed.state(), OracleState::Valid); assert_eq!(feed.nav_per_share(), SCALE); diff --git a/contracts/nav-oracle/src/test_governance.rs b/contracts/nav-oracle/src/test_governance.rs index dd265fcd..842fa6ee 100644 --- a/contracts/nav-oracle/src/test_governance.rs +++ b/contracts/nav-oracle/src/test_governance.rs @@ -6,8 +6,8 @@ use soroban_sdk::{ 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 strata_storage::INSTANCE_EXTEND_AMOUNT; +use strata_timelock::{Clock, DAY_DURATION, MIN_DELAY}; use crate::state::{ATTESTER_ROLE, GUARDIAN_ROLE}; use crate::{ @@ -38,7 +38,7 @@ impl ClockStub { } #[contractimpl(contracttrait)] -impl timelock::ClockSource for ClockStub { +impl strata_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); @@ -60,7 +60,7 @@ struct Fixture<'a> { impl Fixture<'_> { fn clock(&self, f: impl FnOnce(&mut Clock)) { let mut clock = - timelock::ClockSourceClient::new(&self.e, &self.vault.address).timelock_clock(); + strata_timelock::ClockSourceClient::new(&self.e, &self.vault.address).timelock_clock(); f(&mut clock); self.vault.set(&clock); } @@ -416,8 +416,11 @@ fn a_config_outside_the_bounds_is_refused_when_it_applies() { // 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() + strata_timelock::queue::propose::( + &f.e, + &OracleChange::Config(invalid), + ) + .unwrap() }); f.advance(MIN_DELAY); @@ -502,13 +505,13 @@ fn only_the_admin_binds_and_finishes() { assert!(f.oracle.setup_open()); } -fn implements_the_vault_view() {} +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); + let view = strata_bindings::VaultBindingClient::new(&f.e, &f.oracle.address); assert_eq!(view.vault(), None); assert!(view.setup_open()); diff --git a/contracts/share-token/Cargo.toml b/contracts/share-token/Cargo.toml index f26c96d0..d8588513 100644 --- a/contracts/share-token/Cargo.toml +++ b/contracts/share-token/Cargo.toml @@ -15,9 +15,9 @@ stellar-access = { workspace = true } stellar-macros = { workspace = true } stellar-tokens = { workspace = true } stellar-contract-utils = { workspace = true } -storage = { path = "../../crates/storage" } -timelock = { path = "../../crates/timelock" } -bindings = { path = "../../crates/bindings" } +strata-storage = { path = "../../crates/strata-storage" } +strata-timelock = { path = "../../crates/strata-timelock" } +strata-bindings = { path = "../../crates/strata-bindings" } [dev-dependencies] soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/share-token/src/contract.rs b/contracts/share-token/src/contract.rs index acfbe495..fbf51437 100644 --- a/contracts/share-token/src/contract.rs +++ b/contracts/share-token/src/contract.rs @@ -3,7 +3,6 @@ use crate::event::LegalTransfer; use crate::governance::{self, TokenChange, TokenPendingChange}; use crate::roles::COMPLIANCE_ROLE; -use bindings::VaultBinding; use soroban_sdk::{ contract, contractimpl, panic_with_error, Address, Env, MuxedAddress, String, Symbol, Vec, }; @@ -15,6 +14,7 @@ use stellar_tokens::{ fungible::{Base, ContractOverrides, FungibleToken}, rwa::{identity_verification::IdentityVerifierClient, RWAToken, RWA}, }; +use strata_bindings::VaultBinding; /// Longest order reference a legal transfer accepts, in bytes. pub const MAX_REFERENCE_LEN: u32 = 128; @@ -28,7 +28,7 @@ fn require_admin_co_sign(e: &Env, operator: &Address) { } fn refuse_vault_address(e: &Env, account: &Address) { - if timelock::setup::source(e).as_ref() == Some(account) { + if strata_timelock::setup::source(e).as_ref() == Some(account) { panic_with_error!(e, ShareTokenError::VaultAddress); } } @@ -58,7 +58,7 @@ impl ShareToken { RWA::set_compliance(e, &compliance); RWA::set_identity_verifier(e, &identity_verifier); upgradeable::set_schema_version(e, 1); - storage::bump_instance(e); + strata_storage::bump_instance(e); } pub fn schema_version(e: &Env) -> u32 { @@ -70,7 +70,7 @@ impl ShareToken { #[only_admin] pub fn bind_vault(e: &Env, vault: Address, caller: Address) { caller.require_auth(); - storage::bump_instance(e); + strata_storage::bump_instance(e); governance::bind_vault(e, &vault); } @@ -78,7 +78,7 @@ impl ShareToken { #[only_admin] pub fn finish_setup(e: &Env, caller: Address) { caller.require_auth(); - storage::bump_instance(e); + strata_storage::bump_instance(e); governance::finish_setup(e); } @@ -86,14 +86,14 @@ impl ShareToken { #[only_admin] pub fn propose_change(e: &Env, change: TokenChange, caller: Address) -> u32 { caller.require_auth(); - storage::bump_instance(e); + strata_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); + strata_storage::bump_instance(e); governance::cancel(e, id); } @@ -102,7 +102,7 @@ impl ShareToken { #[only_admin] pub fn apply_change(e: &Env, id: u32, caller: Address) { caller.require_auth(); - storage::bump_instance(e); + strata_storage::bump_instance(e); governance::apply(e, id); } @@ -122,7 +122,7 @@ impl ShareToken { reference: String, operator: Address, ) { - storage::bump_instance(e); + strata_storage::bump_instance(e); require_admin_co_sign(e, &operator); if reference.is_empty() || reference.len() > MAX_REFERENCE_LEN { panic_with_error!(e, ShareTokenError::InvalidReference); @@ -144,11 +144,11 @@ impl ShareToken { #[contractimpl(contracttrait)] impl VaultBinding for ShareToken { fn vault(e: &Env) -> Option
{ - timelock::setup::source(e) + strata_timelock::setup::source(e) } fn setup_open(e: &Env) -> bool { - timelock::setup::is_open(e) + strata_timelock::setup::is_open(e) } } @@ -157,13 +157,13 @@ impl Pausable for ShareToken { #[only_admin] fn pause(e: &Env, caller: Address) { caller.require_auth(); - storage::bump_instance(e); + strata_storage::bump_instance(e); pausable::pause(e); } #[only_admin] fn unpause(e: &Env, caller: Address) { caller.require_auth(); - storage::bump_instance(e); + strata_storage::bump_instance(e); pausable::unpause(e); } } @@ -175,17 +175,17 @@ impl FungibleToken for ShareToken { type ContractType = RWA; fn transfer(e: &Env, from: Address, to: MuxedAddress, amount: i128) { - storage::bump_instance(e); + strata_storage::bump_instance(e); ::transfer(e, &from, &to, amount); } fn transfer_from(e: &Env, spender: Address, from: Address, to: Address, amount: i128) { - storage::bump_instance(e); + strata_storage::bump_instance(e); ::transfer_from(e, &spender, &from, &to, amount); } fn approve(e: &Env, owner: Address, spender: Address, amount: i128, live_until_ledger: u32) { - storage::bump_instance(e); + strata_storage::bump_instance(e); ::approve( e, &owner, @@ -201,7 +201,7 @@ impl AccessControl for ShareToken { /// Instant during setup; after it, propose the grant through the timelock. fn grant_role(e: &Env, account: Address, role: Symbol, caller: Address) { governance::require_setup_open(e); - storage::bump_instance(e); + strata_storage::bump_instance(e); access_control::grant_role(e, &account, &role, &caller); } @@ -209,7 +209,7 @@ impl AccessControl for ShareToken { /// because the vault needs it to pay claims. fn revoke_role(e: &Env, account: Address, role: Symbol, caller: Address) { governance::require_revoke_allowed(e, &role); - storage::bump_instance(e); + strata_storage::bump_instance(e); access_control::revoke_role(e, &account, &role, &caller); } @@ -219,7 +219,7 @@ impl AccessControl for ShareToken { if live_until_ledger != 0 { governance::require_setup_open(e); } - storage::bump_instance(e); + strata_storage::bump_instance(e); access_control::transfer_admin_role(e, &new_admin, live_until_ledger); } @@ -241,19 +241,19 @@ impl AccessControl for ShareToken { impl RWAToken for ShareToken { #[only_role(operator, "manager")] fn mint(e: &Env, to: Address, amount: i128, operator: Address) { - storage::bump_instance(e); + strata_storage::bump_instance(e); RWA::mint(e, &to, amount); } #[only_role(operator, "manager")] fn burn(e: &Env, user_address: Address, amount: i128, operator: Address) { - storage::bump_instance(e); + strata_storage::bump_instance(e); RWA::burn(e, &user_address, amount); } /// The vault escrows redemptions through it. Compliance moves shares with /// `legal_transfer` instead. #[only_role(operator, "manager")] fn forced_transfer(e: &Env, from: Address, to: Address, amount: i128, operator: Address) { - storage::bump_instance(e); + strata_storage::bump_instance(e); RWA::forced_transfer(e, &from, &to, amount); } /// Moves a whole balance to the account the identity verifier names, so @@ -266,7 +266,7 @@ impl RWAToken for ShareToken { new_account: Address, operator: Address, ) -> bool { - storage::bump_instance(e); + strata_storage::bump_instance(e); require_admin_co_sign(e, &operator); refuse_vault_address(e, &old_account); refuse_vault_address(e, &new_account); @@ -274,7 +274,7 @@ impl RWAToken for ShareToken { } #[only_role(operator, "compliance")] fn set_address_frozen(e: &Env, user_address: Address, freeze: bool, operator: Address) { - storage::bump_instance(e); + strata_storage::bump_instance(e); if freeze { refuse_vault_address(e, &user_address); } @@ -282,27 +282,27 @@ impl RWAToken for ShareToken { } #[only_role(operator, "compliance")] fn freeze_partial_tokens(e: &Env, user_address: Address, amount: i128, operator: Address) { - storage::bump_instance(e); + strata_storage::bump_instance(e); refuse_vault_address(e, &user_address); RWA::freeze_partial_tokens(e, &user_address, amount); } #[only_role(operator, "compliance")] fn unfreeze_partial_tokens(e: &Env, user_address: Address, amount: i128, operator: Address) { - storage::bump_instance(e); + strata_storage::bump_instance(e); RWA::unfreeze_partial_tokens(e, &user_address, amount); } #[only_admin] fn set_compliance(e: &Env, compliance: Address, operator: Address) { operator.require_auth(); governance::require_setup_open(e); - storage::bump_instance(e); + strata_storage::bump_instance(e); RWA::set_compliance(e, &compliance); } #[only_admin] fn set_identity_verifier(e: &Env, identity_verifier: Address, operator: Address) { operator.require_auth(); governance::require_setup_open(e); - storage::bump_instance(e); + strata_storage::bump_instance(e); RWA::set_identity_verifier(e, &identity_verifier); } } @@ -322,7 +322,7 @@ impl ShareToken { #[only_admin] pub fn migrate(e: &Env, caller: Address) { caller.require_auth(); - storage::bump_instance(e); + strata_storage::bump_instance(e); if upgradeable::get_schema_version(e) >= 2 { panic_with_error!(e, ProbeError::AlreadyMigrated); } diff --git a/contracts/share-token/src/governance.rs b/contracts/share-token/src/governance.rs index b22f960b..0407a211 100644 --- a/contracts/share-token/src/governance.rs +++ b/contracts/share-token/src/governance.rs @@ -5,7 +5,7 @@ use stellar_access::{ }; use stellar_contract_utils::upgradeable; use stellar_tokens::rwa::RWA; -use timelock::{queue, setup, Clock, Refusal, TimelockHost}; +use strata_timelock::{queue, setup, Clock, Refusal, TimelockHost}; use crate::error::ShareTokenError; use crate::event::{ChangeApplied, ChangeCancelled, ChangeProposed, SetupFinished, VaultBound}; @@ -39,7 +39,7 @@ pub struct TokenPendingChange { /// The token keeps no clock of its own: it reads the vault's. impl TimelockHost for ShareToken { fn clock(e: &Env) -> Result { - timelock::clock::from_source(e) + strata_timelock::clock::from_source(e) } } diff --git a/contracts/share-token/src/test.rs b/contracts/share-token/src/test.rs index 7d7b7c63..7a4a67b0 100644 --- a/contracts/share-token/src/test.rs +++ b/contracts/share-token/src/test.rs @@ -5,7 +5,7 @@ use soroban_sdk::{ }; use stellar_access::access_control::{self as access_control, AccessControlError}; use stellar_tokens::rwa::RWAError; -use storage::INSTANCE_EXTEND_AMOUNT; +use strata_storage::INSTANCE_EXTEND_AMOUNT; use crate::{ShareToken, ShareTokenClient}; use compliance::{Compliance, ComplianceClient}; @@ -541,7 +541,7 @@ mod legal_transfer { Event as _, }; use stellar_tokens::rwa::identity_verification; - use timelock::{Clock, MIN_DELAY}; + use strata_timelock::{Clock, MIN_DELAY}; use super::*; use crate::{LegalTransfer, ShareTokenError, MAX_REFERENCE_LEN}; @@ -552,7 +552,7 @@ mod legal_transfer { struct VaultStub; #[contractimpl(contracttrait)] - impl timelock::ClockSource for VaultStub { + impl strata_timelock::ClockSource for VaultStub { fn timelock_clock(_e: &Env) -> Clock { Clock { delay: MIN_DELAY, diff --git a/contracts/share-token/src/test_governance.rs b/contracts/share-token/src/test_governance.rs index f394bba3..b0510654 100644 --- a/contracts/share-token/src/test_governance.rs +++ b/contracts/share-token/src/test_governance.rs @@ -6,8 +6,8 @@ use soroban_sdk::{ Address, BytesN, Env, Event as _, IntoVal, String, Symbol, }; use stellar_access::role_transfer::RoleTransferError; -use storage::INSTANCE_EXTEND_AMOUNT; -use timelock::{Clock, DAY_DURATION, MIN_DELAY}; +use strata_storage::INSTANCE_EXTEND_AMOUNT; +use strata_timelock::{Clock, DAY_DURATION, MIN_DELAY}; use crate::{ ChangeApplied, ChangeProposed, ShareToken, ShareTokenClient, ShareTokenError, TokenChange, @@ -35,7 +35,7 @@ impl ClockStub { } #[contractimpl(contracttrait)] -impl timelock::ClockSource for ClockStub { +impl strata_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); @@ -53,7 +53,7 @@ struct Fixture<'a> { impl Fixture<'_> { fn clock(&self, f: impl FnOnce(&mut Clock)) { let mut clock = - timelock::ClockSourceClient::new(&self.e, &self.vault.address).timelock_clock(); + strata_timelock::ClockSourceClient::new(&self.e, &self.vault.address).timelock_clock(); f(&mut clock); self.vault.set(&clock); } @@ -231,13 +231,13 @@ fn only_the_admin_binds_and_finishes() { assert!(f.token.setup_open()); } -fn implements_the_vault_view() {} +fn implements_the_vault_view() {} #[test] fn the_token_answers_the_view_the_vault_reads() { implements_the_vault_view::(); let f = setup(); - let view = bindings::VaultBindingClient::new(&f.e, &f.token.address); + let view = strata_bindings::VaultBindingClient::new(&f.e, &f.token.address); assert_eq!(view.vault(), None); assert!(view.setup_open()); diff --git a/crates/bindings/Cargo.toml b/crates/strata-bindings/Cargo.toml similarity index 83% rename from crates/bindings/Cargo.toml rename to crates/strata-bindings/Cargo.toml index a7fb4a54..119a584c 100644 --- a/crates/bindings/Cargo.toml +++ b/crates/strata-bindings/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "bindings" +name = "strata-bindings" version = "0.1.0" edition.workspace = true license.workspace = true diff --git a/crates/bindings/src/lib.rs b/crates/strata-bindings/src/lib.rs similarity index 100% rename from crates/bindings/src/lib.rs rename to crates/strata-bindings/src/lib.rs diff --git a/crates/storage/Cargo.toml b/crates/strata-storage/Cargo.toml similarity index 89% rename from crates/storage/Cargo.toml rename to crates/strata-storage/Cargo.toml index 853bfd7e..3e577a0b 100644 --- a/crates/storage/Cargo.toml +++ b/crates/strata-storage/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "storage" +name = "strata-storage" version = "0.1.0" edition.workspace = true license.workspace = true diff --git a/crates/storage/src/lib.rs b/crates/strata-storage/src/lib.rs similarity index 100% rename from crates/storage/src/lib.rs rename to crates/strata-storage/src/lib.rs diff --git a/crates/storage/src/test.rs b/crates/strata-storage/src/test.rs similarity index 100% rename from crates/storage/src/test.rs rename to crates/strata-storage/src/test.rs diff --git a/crates/timelock/Cargo.toml b/crates/strata-timelock/Cargo.toml similarity index 79% rename from crates/timelock/Cargo.toml rename to crates/strata-timelock/Cargo.toml index ada90bb8..16ec159f 100644 --- a/crates/timelock/Cargo.toml +++ b/crates/strata-timelock/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "timelock" +name = "strata-timelock" version = "0.1.0" edition = { workspace = true } license = { workspace = true } @@ -10,7 +10,7 @@ doctest = false [dependencies] soroban-sdk = { workspace = true } -storage = { path = "../storage" } +strata-storage = { path = "../strata-storage" } [dev-dependencies] soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/crates/timelock/src/clock.rs b/crates/strata-timelock/src/clock.rs similarity index 100% rename from crates/timelock/src/clock.rs rename to crates/strata-timelock/src/clock.rs diff --git a/crates/timelock/src/delay.rs b/crates/strata-timelock/src/delay.rs similarity index 100% rename from crates/timelock/src/delay.rs rename to crates/strata-timelock/src/delay.rs diff --git a/crates/timelock/src/keys.rs b/crates/strata-timelock/src/keys.rs similarity index 100% rename from crates/timelock/src/keys.rs rename to crates/strata-timelock/src/keys.rs diff --git a/crates/timelock/src/lib.rs b/crates/strata-timelock/src/lib.rs similarity index 100% rename from crates/timelock/src/lib.rs rename to crates/strata-timelock/src/lib.rs diff --git a/crates/timelock/src/pause.rs b/crates/strata-timelock/src/pause.rs similarity index 62% rename from crates/timelock/src/pause.rs rename to crates/strata-timelock/src/pause.rs index 51f1b363..d9cdc856 100644 --- a/crates/timelock/src/pause.rs +++ b/crates/strata-timelock/src/pause.rs @@ -7,29 +7,29 @@ use crate::keys::Key; /// A second start while paused changes nothing. pub fn start(e: &Env) { - let since: Option = storage::get_instance(e, &Key::TimelockPausedSince); + let since: Option = strata_storage::get_instance(e, &Key::TimelockPausedSince); if since.is_none() { - storage::set_instance(e, &Key::TimelockPausedSince, &e.ledger().timestamp()); + strata_storage::set_instance(e, &Key::TimelockPausedSince, &e.ledger().timestamp()); } } /// Banks the time since `start`. An end with no pause running changes nothing. pub fn end(e: &Env) { - let since: Option = storage::get_instance(e, &Key::TimelockPausedSince); + let since: Option = strata_storage::get_instance(e, &Key::TimelockPausedSince); if let Some(since) = since { let total = closed_secs(e).saturating_add(e.ledger().timestamp().saturating_sub(since)); - storage::set_instance(e, &Key::TimelockPausedSecs, &total); + strata_storage::set_instance(e, &Key::TimelockPausedSecs, &total); e.storage().instance().remove(&Key::TimelockPausedSince); } } /// Seconds spent paused so far, a running pause included. pub fn paused_secs(e: &Env) -> u64 { - let since: Option = storage::get_instance(e, &Key::TimelockPausedSince); + let since: Option = strata_storage::get_instance(e, &Key::TimelockPausedSince); let running = since.map_or(0, |s| e.ledger().timestamp().saturating_sub(s)); closed_secs(e).saturating_add(running) } fn closed_secs(e: &Env) -> u64 { - storage::get_instance(e, &Key::TimelockPausedSecs).unwrap_or(0) + strata_storage::get_instance(e, &Key::TimelockPausedSecs).unwrap_or(0) } diff --git a/crates/timelock/src/queue.rs b/crates/strata-timelock/src/queue.rs similarity index 78% rename from crates/timelock/src/queue.rs rename to crates/strata-timelock/src/queue.rs index d1adc354..31f39d84 100644 --- a/crates/timelock/src/queue.rs +++ b/crates/strata-timelock/src/queue.rs @@ -13,7 +13,7 @@ pub const MAX_PENDING: u32 = 16; /// The ids of the changes waiting, oldest first. pub fn open_ids(e: &Env) -> Vec { - storage::get_instance(e, &Key::TimelockOpen).unwrap_or_else(|| Vec::new(e)) + strata_storage::get_instance(e, &Key::TimelockOpen).unwrap_or_else(|| Vec::new(e)) } pub fn pending(e: &Env, id: u32) -> Option<(C, Schedule)> @@ -21,7 +21,7 @@ where C: TryFromVal, >::Error: Debug, { - storage::get_persistent(e, &Key::TimelockPending(id)) + strata_storage::get_persistent(e, &Key::TimelockPending(id)) } /// Queues a change behind the host's current delay and returns its id. @@ -41,16 +41,16 @@ where if open.len() >= MAX_PENDING { return Err(Refusal::TooMany); } - let id: u32 = storage::get_instance(e, &Key::TimelockNextId).unwrap_or(1); + let id: u32 = strata_storage::get_instance(e, &Key::TimelockNextId).unwrap_or(1); let schedule = Schedule { proposed_at: e.ledger().timestamp(), delay: clock.delay, paused_secs: clock.paused_secs, }; - storage::set_persistent(e, &Key::TimelockPending(id), &(change.clone(), schedule)); + strata_storage::set_persistent(e, &Key::TimelockPending(id), &(change.clone(), schedule)); open.push_back(id); - storage::set_instance(e, &Key::TimelockOpen, &open); - storage::set_instance(e, &Key::TimelockNextId, &(id + 1)); + strata_storage::set_instance(e, &Key::TimelockOpen, &open); + strata_storage::set_instance(e, &Key::TimelockNextId, &(id + 1)); Ok(id) } @@ -74,7 +74,7 @@ fn remove(e: &Env, id: u32) -> Result<(), Refusal> { let mut open = open_ids(e); let index = open.first_index_of(id).ok_or(Refusal::NotFound)?; open.remove(index); - storage::set_instance(e, &Key::TimelockOpen, &open); + strata_storage::set_instance(e, &Key::TimelockOpen, &open); e.storage().persistent().remove(&Key::TimelockPending(id)); Ok(()) } diff --git a/crates/timelock/src/refusal.rs b/crates/strata-timelock/src/refusal.rs similarity index 100% rename from crates/timelock/src/refusal.rs rename to crates/strata-timelock/src/refusal.rs diff --git a/crates/timelock/src/schedule.rs b/crates/strata-timelock/src/schedule.rs similarity index 100% rename from crates/timelock/src/schedule.rs rename to crates/strata-timelock/src/schedule.rs diff --git a/crates/timelock/src/setup.rs b/crates/strata-timelock/src/setup.rs similarity index 78% rename from crates/timelock/src/setup.rs rename to crates/strata-timelock/src/setup.rs index 195ad642..6d165eb2 100644 --- a/crates/timelock/src/setup.rs +++ b/crates/strata-timelock/src/setup.rs @@ -8,12 +8,12 @@ use crate::keys::Key; use crate::refusal::Refusal; pub fn is_open(e: &Env) -> bool { - !storage::get_instance::<_, bool>(e, &Key::TimelockSetupDone).unwrap_or(false) + !strata_storage::get_instance::<_, bool>(e, &Key::TimelockSetupDone).unwrap_or(false) } /// The clock source this contract is bound to. pub fn source(e: &Env) -> Option
{ - storage::get_instance(e, &Key::TimelockSource) + strata_storage::get_instance(e, &Key::TimelockSource) } /// Binds this contract to a clock source, once and only during setup. @@ -26,7 +26,7 @@ pub fn bind_source(e: &Env, source: &Address) -> Result<(), Refusal> { } // An address that does not answer as a clock source fails the call here. ClockSourceClient::new(e, source).timelock_clock(); - storage::set_instance(e, &Key::TimelockSource, source); + strata_storage::set_instance(e, &Key::TimelockSource, source); Ok(()) } @@ -36,6 +36,6 @@ pub fn finish(e: &Env) -> Result<(), Refusal> { return Err(Refusal::SetupClosed); } H::clock(e)?; - storage::set_instance(e, &Key::TimelockSetupDone, &true); + strata_storage::set_instance(e, &Key::TimelockSetupDone, &true); Ok(()) } diff --git a/crates/timelock/src/test/clock.rs b/crates/strata-timelock/src/test/clock.rs similarity index 100% rename from crates/timelock/src/test/clock.rs rename to crates/strata-timelock/src/test/clock.rs diff --git a/crates/timelock/src/test/delay.rs b/crates/strata-timelock/src/test/delay.rs similarity index 100% rename from crates/timelock/src/test/delay.rs rename to crates/strata-timelock/src/test/delay.rs diff --git a/crates/timelock/src/test/mod.rs b/crates/strata-timelock/src/test/mod.rs similarity index 100% rename from crates/timelock/src/test/mod.rs rename to crates/strata-timelock/src/test/mod.rs diff --git a/crates/timelock/src/test/pause.rs b/crates/strata-timelock/src/test/pause.rs similarity index 100% rename from crates/timelock/src/test/pause.rs rename to crates/strata-timelock/src/test/pause.rs diff --git a/crates/timelock/src/test/queue.rs b/crates/strata-timelock/src/test/queue.rs similarity index 100% rename from crates/timelock/src/test/queue.rs rename to crates/strata-timelock/src/test/queue.rs diff --git a/crates/timelock/src/test/schedule.rs b/crates/strata-timelock/src/test/schedule.rs similarity index 100% rename from crates/timelock/src/test/schedule.rs rename to crates/strata-timelock/src/test/schedule.rs diff --git a/crates/timelock/src/test/setup.rs b/crates/strata-timelock/src/test/setup.rs similarity index 100% rename from crates/timelock/src/test/setup.rs rename to crates/strata-timelock/src/test/setup.rs diff --git a/docs/TESTING.md b/docs/TESTING.md index d1a67816..dd800139 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -36,7 +36,7 @@ suite under it: tests that subtract from the minimum assume seven days. ## Storage and TTL The contracts read and write through the shared helpers in -[`crates/storage`](../crates/storage/src/lib.rs). A write extends the entry to +[`crates/strata-storage`](../crates/strata-storage/src/lib.rs). A write extends the entry to 30 days; a read that finds the entry extends it again once it has fallen more than a day short. The instance follows the same rule. The [TTL policy](./kit-spec.md#5-storage-lifetime-ttl-policy) says what each @@ -44,9 +44,9 @@ contract extends, who pays, and how to restore an archived one. | Behaviour | Tests | Key test names | | --- | --- | --- | -| Persistent entries round-trip and get the full TTL on write | [`crates/storage/src/test.rs`](../crates/storage/src/test.rs) | `persistent_round_trips`, `writing_sets_the_full_ttl` | -| A read restores a decayed entry; a missing entry is not created | [`crates/storage/src/test.rs`](../crates/storage/src/test.rs) | `reading_a_decayed_entry_restores_the_full_ttl`, `reading_a_missing_entry_creates_nothing` | -| Instance writes and reads extend the contract instance | [`crates/storage/src/test.rs`](../crates/storage/src/test.rs) | `instance_writes_bump_the_instance`, `reading_the_instance_restores_its_ttl` | +| Persistent entries round-trip and get the full TTL on write | [`crates/strata-storage/src/test.rs`](../crates/strata-storage/src/test.rs) | `persistent_round_trips`, `writing_sets_the_full_ttl` | +| A read restores a decayed entry; a missing entry is not created | [`crates/strata-storage/src/test.rs`](../crates/strata-storage/src/test.rs) | `reading_a_decayed_entry_restores_the_full_ttl`, `reading_a_missing_entry_creates_nothing` | +| Instance writes and reads extend the contract instance | [`crates/strata-storage/src/test.rs`](../crates/strata-storage/src/test.rs) | `instance_writes_bump_the_instance`, `reading_the_instance_restores_its_ttl` | | The share token, identity verifier and compliance contract extend their own instance on every call that does work | [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.rs), [`contracts/share-token/src/test_governance.rs`](../contracts/share-token/src/test_governance.rs), [`contracts/identity-verifier/src/test.rs`](../contracts/identity-verifier/src/test.rs), [`contracts/compliance/src/test.rs`](../contracts/compliance/src/test.rs), [`contracts/compliance/src/test/changes.rs`](../contracts/compliance/src/test/changes.rs) | `a_transfer_restores_the_instance_ttl`, `the_vaults_mint_burn_and_escrow_restore_the_instance_ttl`, `every_admin_call_restores_the_instance_ttl`, `allowing_and_checking_restore_the_instance_ttl`, `the_hooks_a_token_calls_after_moving_shares_restore_the_instance_ttl` | | Vault requests and epochs are written with the full TTL | [`contracts/async-vault/src/test/ttl.rs`](../contracts/async-vault/src/test/ttl.rs) | `a_request_and_its_epoch_are_written_with_the_full_ttl`, `a_redemption_request_is_written_with_the_full_ttl` | | 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` | diff --git a/docs/kit-spec.md b/docs/kit-spec.md index b97e12a8..7d5368aa 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -316,7 +316,7 @@ instance is archived refuses every call. | `identity_verifier` | Its instance and each allowlist entry | `allow`, `is_allowed`, `verify_identity` | Same | | `compliance` | Its instance, and each pending governance change it reads or writes | Every hook the token calls (`can_create`, `can_transfer`, `created`, `transferred`, `destroyed`) and its admin calls | Same | -The lengths are the constants in `crates/storage` +The lengths are the constants in `crates/strata-storage` (`PERSISTENT_EXTEND_AMOUNT`, `INSTANCE_EXTEND_AMOUNT`, and thresholds one day shorter). They are fixed in code: a governance setting would add an admin power for little gain. @@ -324,7 +324,7 @@ for little gain. **Pending governance changes.** A change waiting in the timelock of `async_vault`, `share_token`, `nav_oracle` or `compliance` 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 +(`strata_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. `npm run extend` also keeps it alive: it reads each contract's open ids from @@ -375,7 +375,7 @@ implementation against what the vault calls. | Interface | Defined in | Implemented here by | What the vault calls | |---|---|---|---| -| `OracleFeed` | `crates/bindings` | `nav-oracle` | `state`, `nav_per_share`, `attested_at`, `freshness_duration` | +| `OracleFeed` | `crates/strata-bindings` | `nav-oracle` | `state`, `nav_per_share`, `attested_at`, `freshness_duration` | | `FungibleToken` | OpenZeppelin `stellar-tokens` 0.7.2 | `share-token` | `transfer`, `balance`, `total_supply` | | `RWAToken` | OpenZeppelin `stellar-tokens` 0.7.2 | `share-token` | `mint`, `burn`, `forced_transfer` | | `IdentityVerifier` | OpenZeppelin `stellar-tokens` 0.7.2 | `identity-verifier` (testnet stub) | `verify_identity`, called by the share token | @@ -386,8 +386,8 @@ be the ledger time the report was accepted, not a time the reporter supplies. Two more traits connect the share token, the oracle and the kit's compliance contract to the vault's timelock. The vault implements `ClockSource` -(`crates/timelock`), and the three read its `timelock_clock` to time their own -changes. Each implements `VaultBinding` (`crates/bindings`): `vault` names the +(`crates/strata-timelock`), and the three read its `timelock_clock` to time their own +changes. Each implements `VaultBinding` (`crates/strata-bindings`): `vault` names the vault it is bound to, and `setup_open` says whether its admin changes are still instant. diff --git a/scripts/harness/extend.ts b/scripts/harness/extend.ts index 97b6fd85..eb14cb42 100644 --- a/scripts/harness/extend.ts +++ b/scripts/harness/extend.ts @@ -41,7 +41,7 @@ const CONTRACTS_WITH_QUEUES = new Set([ /** * The timelock's own storage key for a change still waiting: * `Key::TimelockPending(id)`, a contracttype variant whose ScVal is a vec of - * its symbol and the id (`crates/timelock/src/keys.rs`, `queue.rs`). + * its symbol and the id (`crates/strata-timelock/src/keys.rs`, `queue.rs`). */ function pendingChangeKey(id: number): string { return xdr.ScVal.scvVec([ From 8aafbfafc1b97f8deb545da5962581bed5cded31 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:11:27 -0300 Subject: [PATCH 067/147] build: pin proptest exactly Both manifests that use proptest now pin the version Cargo.lock already holds, like every other dependency. --- contracts/async-vault/Cargo.toml | 2 +- crates/strata-timelock/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/async-vault/Cargo.toml b/contracts/async-vault/Cargo.toml index 11e869e8..64160de6 100644 --- a/contracts/async-vault/Cargo.toml +++ b/contracts/async-vault/Cargo.toml @@ -26,7 +26,7 @@ nav-oracle = { path = "../nav-oracle" } share-token = { path = "../share-token" } compliance = { path = "../compliance" } identity-verifier = { path = "../identity-verifier" } -proptest = "1" +proptest = "=1.11.0" [features] upgrade_wasm = [] diff --git a/crates/strata-timelock/Cargo.toml b/crates/strata-timelock/Cargo.toml index 16ec159f..68733c88 100644 --- a/crates/strata-timelock/Cargo.toml +++ b/crates/strata-timelock/Cargo.toml @@ -14,4 +14,4 @@ strata-storage = { path = "../strata-storage" } [dev-dependencies] soroban-sdk = { workspace = true, features = ["testutils"] } -proptest = "1" +proptest = "=1.11.0" From 9805b514535f43f9a7f8481bfeada92be35ae3e3 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:28:15 -0300 Subject: [PATCH 068/147] refactor: name acting addresses caller and holder In the kit's own entrypoints, caller is the account that signs and holder is the account whose request or position the call is about. When one account is both, the entrypoint takes holder and that account signs. The vault's deposit, redemption and request-view entrypoints take holder instead of from, caller or controller, and fund takes caller. legal_transfer and the compliance contract's own admin calls take caller instead of operator. OpenZeppelin trait methods keep their names. Argument order and types do not change. The harness and the docs follow the new names. --- contracts/async-vault/src/lib.rs | 52 +++++++--------- contracts/compliance/src/lib.rs | 10 ++-- contracts/share-token/src/contract.rs | 6 +- docs/AUTHORIZATION_MATRIX.md | 22 +++---- docs/COMPLIANCE_MATRIX.md | 10 ++-- docs/kit-spec.md | 20 +++---- scripts/harness/deploy.ts | 4 +- scripts/harness/guards.test.ts | 85 ++++++++++++++------------- scripts/harness/happy-path.test.ts | 26 ++++---- scripts/harness/seed.ts | 20 +++---- scripts/harness/wind-down.test.ts | 18 +++--- 11 files changed, 134 insertions(+), 139 deletions(-) diff --git a/contracts/async-vault/src/lib.rs b/contracts/async-vault/src/lib.rs index fa2ca093..895e84c0 100644 --- a/contracts/async-vault/src/lib.rs +++ b/contracts/async-vault/src/lib.rs @@ -345,8 +345,8 @@ impl AsyncVault { treasury::deploy(e, assets) } - pub fn fund(e: &Env, from: Address, assets: i128) -> i128 { - treasury::fund(e, &from, assets) + pub fn fund(e: &Env, caller: Address, assets: i128) -> i128 { + treasury::fund(e, &caller, assets) } pub fn notice(e: &Env) -> u64 { @@ -366,53 +366,45 @@ impl AsyncVault { settlement::get(e, epoch_id) } - pub fn get_deposit_request( - e: &Env, - epoch_id: u64, - controller: Address, - ) -> Option { - state::get_deposit_request(e, epoch_id, &controller) + pub fn get_deposit_request(e: &Env, epoch_id: u64, holder: Address) -> Option { + state::get_deposit_request(e, epoch_id, &holder) } #[when_not_paused] - pub fn request_deposit(e: &Env, from: Address, amount: i128) -> u64 { - deposit::request(e, &from, amount) + pub fn request_deposit(e: &Env, holder: Address, amount: i128) -> u64 { + deposit::request(e, &holder, amount) } - /// Returns the shares minted to the caller. Zero means none were: a deposit - /// too small for one share was refunded, or the caller is off the allowlist + /// Returns the shares minted to the holder. Zero means none were: a deposit + /// too small for one share was refunded, or the holder is off the allowlist /// and the shares joined an exit instead (`DepositConvertedToExit`). - pub fn claim_deposit(e: &Env, caller: Address, epoch_id: u64) -> i128 { - deposit::claim(e, &caller, epoch_id) + pub fn claim_deposit(e: &Env, holder: Address, epoch_id: u64) -> i128 { + deposit::claim(e, &holder, epoch_id) } /// Cancels an unpriced deposit. Never blocked by the pause. - pub fn cancel_deposit(e: &Env, from: Address, epoch_id: u64) -> i128 { - deposit::cancel(e, &from, epoch_id) + pub fn cancel_deposit(e: &Env, holder: Address, epoch_id: u64) -> i128 { + deposit::cancel(e, &holder, epoch_id) } /// Cancels an unpriced redemption. Outside a wind-down the shares come - /// back, even while the token is paused, unless the controller is delisted, + /// back, even while the token is paused, unless the holder is delisted, /// frozen or refused by the compliance rules; in one the escrow joins the - /// controller's entitlement, and a frozen controller is held. - pub fn cancel_redeem(e: &Env, from: Address, epoch_id: u64) -> i128 { - redeem::cancel(e, &from, epoch_id) + /// holder's entitlement, and a frozen holder is held. + pub fn cancel_redeem(e: &Env, holder: Address, epoch_id: u64) -> i128 { + redeem::cancel(e, &holder, epoch_id) } - pub fn request_redeem(e: &Env, from: Address, shares: i128) -> u64 { - redeem::request(e, &from, shares) + pub fn request_redeem(e: &Env, holder: Address, shares: i128) -> u64 { + redeem::request(e, &holder, shares) } - pub fn get_redeem_request( - e: &Env, - epoch_id: u64, - controller: Address, - ) -> Option { - state::get_redeem_request(e, epoch_id, &controller) + pub fn get_redeem_request(e: &Env, epoch_id: u64, holder: Address) -> Option { + state::get_redeem_request(e, epoch_id, &holder) } - pub fn claim_redeem(e: &Env, caller: Address, epoch_id: u64) -> i128 { - redeem::claim(e, &caller, epoch_id) + pub fn claim_redeem(e: &Env, holder: Address, epoch_id: u64) -> i128 { + redeem::claim(e, &holder, epoch_id) } #[only_role(caller, "manager")] diff --git a/contracts/compliance/src/lib.rs b/contracts/compliance/src/lib.rs index 3dd695db..8340e0ee 100644 --- a/contracts/compliance/src/lib.rs +++ b/contracts/compliance/src/lib.rs @@ -96,7 +96,7 @@ impl Compliance { /// Names the vault whose clock this contract's timelock reads. Once, and /// only during setup. #[only_admin] - pub fn bind_vault(e: &Env, vault: Address, _operator: Address) { + pub fn bind_vault(e: &Env, vault: Address, _caller: Address) { strata_storage::bump_instance(e); governance::bind_vault(e, &vault); } @@ -104,20 +104,20 @@ impl Compliance { /// Ends the window in which module and binding changes are instant. It /// never reopens. #[only_admin] - pub fn finish_setup(e: &Env, _operator: Address) { + pub fn finish_setup(e: &Env, _caller: Address) { strata_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: ComplianceChange, _operator: Address) -> u32 { + pub fn propose_change(e: &Env, change: ComplianceChange, _caller: Address) -> u32 { strata_storage::bump_instance(e); governance::propose(e, change) } #[only_admin] - pub fn cancel_change(e: &Env, id: u32, _operator: Address) { + pub fn cancel_change(e: &Env, id: u32, _caller: Address) { strata_storage::bump_instance(e); governance::cancel(e, id); } @@ -125,7 +125,7 @@ impl Compliance { /// 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, _operator: Address) { + pub fn apply_change(e: &Env, id: u32, _caller: Address) { strata_storage::bump_instance(e); governance::apply(e, id); } diff --git a/contracts/share-token/src/contract.rs b/contracts/share-token/src/contract.rs index fbf51437..17309333 100644 --- a/contracts/share-token/src/contract.rs +++ b/contracts/share-token/src/contract.rs @@ -113,17 +113,17 @@ impl ShareToken { /// Moves shares under a legal order, at once. The compliance authority /// asks and the admin co-signs the same call. Never out of or into the /// vault's address, and only to an account the identity verifier accepts. - #[only_role(operator, "compliance")] + #[only_role(caller, "compliance")] pub fn legal_transfer( e: &Env, from: Address, to: Address, amount: i128, reference: String, - operator: Address, + caller: Address, ) { strata_storage::bump_instance(e); - require_admin_co_sign(e, &operator); + require_admin_co_sign(e, &caller); if reference.is_empty() || reference.len() > MAX_REFERENCE_LEN { panic_with_error!(e, ShareTokenError::InvalidReference); } diff --git a/docs/AUTHORIZATION_MATRIX.md b/docs/AUTHORIZATION_MATRIX.md index e1de3f84..8b97446f 100644 --- a/docs/AUTHORIZATION_MATRIX.md +++ b/docs/AUTHORIZATION_MATRIX.md @@ -7,7 +7,7 @@ This document provides the complete authorization and role model specification f ## 1. Architectural Principles 1. **Direct Role Membership Checks**: Role membership is checked directly via `has_role(e, &caller, &role)` or `stellar_macros::only_role` / `only_admin`, never by enumerating members. -2. **Strict Caller Authentication**: No entrypoint takes a caller argument that it does not authenticate. Every function accepting a `caller: Address` executes `caller.require_auth()`. The testnet compliance stub is the exception: its admin calls check the stored admin's signature and ignore their `operator` argument (§5.5). +2. **Strict Caller Authentication**: No entrypoint takes a caller argument that it does not authenticate. Every function accepting a `caller: Address` executes `caller.require_auth()`. The testnet compliance stub is the exception: its admin calls check the stored admin's signature and ignore their `caller` or `operator` argument (§5.5). 3. **Exit guarantee, with one hold**: A covered redemption claim pays out cash regardless of pause, staleness or allowlist status. A delisted investor exits through the cash path and cannot re-enter share circulation. A frozen investor is held: the vault refuses their requests, cancellations and claims, and pays them only after the freeze lifts. 4. **Five Separate Authorities**: - **Governance (Admin)**: Root authority; upgrades, timelock parameters, custodian settings, unpause, oracle parameters, emergency NAV pause reset. As the share token's admin, it co-signs compliance's `legal_transfer` and `recover_balance`. @@ -37,7 +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. That no other account holds it is checked by the vault's `finish_setup` below. - 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). -- It calls `compliance.bind_token({ token, operator: governance })`, then `compliance.bind_vault({ vault, operator: governance })` and `compliance.finish_setup({ operator: governance })`, and stops if `compliance.setup_open()` still reads true. From then on the compliance contract's module, token-binding and admin changes wait for the vault's timelock (§5.5). +- It calls `compliance.bind_token({ token, operator: governance })`, then `compliance.bind_vault({ vault, caller: governance })` and `compliance.finish_setup({ caller: governance })`, and stops if `compliance.setup_open()` still reads true. From then on the compliance contract's module, token-binding and admin changes wait for the vault's timelock (§5.5). - 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). - Last, it calls `vault.set_custodian({ custodian, caller: governance })` and `vault.finish_setup({ caller: governance })`, and stops if `vault.setup_open()` still reads true. `finish_setup` refuses with `SetupIncomplete` unless the token and the oracle report this vault with their setup closed, the vault is the token's only `manager`, the token has no shares outstanding, the vault passes the identity check, its address is not frozen on the token and the token is bound to its compliance contract. A compliance contract that answers `vault()` and `setup_open()` must report this vault with its setup closed; one that does not answer them is an external module and is accepted. From then on the vault takes deposits and its admin changes wait for its timelock (§5.1). @@ -59,17 +59,17 @@ The following entrypoints are open to any caller on purpose: | Contract | Entrypoint | Authorization | Rationale | |---|---|---|---| -| **`AsyncVault`** | `fund` | `from.require_auth()` | Anyone (treasury, LP, custodian, donor) may fund the vault with settlement assets. `from.require_auth()` ensures that the sender explicitly approves transferring their own assets into the vault. Receiving capital is non-privileged and always safe. | +| **`AsyncVault`** | `fund` | `caller.require_auth()` | Anyone (treasury, LP, custodian, donor) may fund the vault with settlement assets. `caller.require_auth()` ensures that the sender explicitly approves transferring their own assets into the vault. Receiving capital is non-privileged and always safe. | | **`AsyncVault`** | `fulfill_epoch` | Open (Any caller) | Pricing is deterministic once an epoch is closed and the oracle publishes a valid attestation. Permissionless fulfillment prevents a malicious operator from stalling pricing or censoring settlements. Guarded by `#[when_not_paused]` and oracle validity. | | **`AsyncVault`** | `activate_wind_down` | Open (Any caller) | Once governance announces wind-down and the timelock expires, anyone (including investors) can trigger activation. This ensures an operator cannot propose wind-down to block deposits and then stall activation indefinitely. | | **`AsyncVault`** | `finalize_wind_down_round` | Open (Any caller) | Distributing returned capital pro-rata across the supply snapshot is deterministic. Making finalisation open ensures investors or keepers can trigger payout rounds immediately when funds arrive. | | **`AsyncVault`** | `claim_wind_down` | `holder.require_auth()` | The holder signs their own claim; a keeper cannot claim for them. The payout goes to `holder`. | -| **`AsyncVault`** | `request_deposit` | `from.require_auth()` | Investor entrypoint. Deposits escrowed cash; guarded by `when_not_paused`, not winding down, and refused with `SetupOpen` until the vault's setup finishes, so every deposit is covered by the timelock. | -| **`AsyncVault`** | `claim_deposit` | `caller.require_auth()` | Investor entrypoint. Mints the shares once the epoch is priced; the token checks the receiver on `mint`. A delisted holder's shares become an exit instead (`DepositConvertedToExit`): they join the holder's redemption in the open epoch, or the holder's wind-down entitlement during a wind-down. A frozen holder's claim is held (`Frozen`). | -| **`AsyncVault`** | `cancel_deposit` | `from.require_auth()` | Investor exit while the epoch cannot be priced. Refunds the whole escrowed deposit in one step, delisted or not. A frozen holder is held (`Frozen`). | -| **`AsyncVault`** | `request_redeem` | `from.require_auth()` | Investor exit. Always open, even during pause. | -| **`AsyncVault`** | `claim_redeem` | `caller.require_auth()` | Investor exit. Pays settlement asset once covered. | -| **`AsyncVault`** | `cancel_redeem` | `from.require_auth()` | Investor exit while the epoch cannot be priced. Outside a wind-down the vault checks the controller itself and returns the escrowed shares with the manager `forced_transfer`; it refuses a delisted controller (`NotAllowlisted`) and a move the compliance rules refuse (`HolderNotEligible`). In an active wind-down the escrow is surrendered into the controller's wind-down entitlement instead (`RedemptionSurrendered`). A frozen controller is held (`Frozen`) either way. | +| **`AsyncVault`** | `request_deposit` | `holder.require_auth()` | Investor entrypoint. Deposits escrowed cash; guarded by `when_not_paused`, not winding down, and refused with `SetupOpen` until the vault's setup finishes, so every deposit is covered by the timelock. | +| **`AsyncVault`** | `claim_deposit` | `holder.require_auth()` | Investor entrypoint. Mints the shares once the epoch is priced; the token checks the receiver on `mint`. A delisted holder's shares become an exit instead (`DepositConvertedToExit`): they join the holder's redemption in the open epoch, or the holder's wind-down entitlement during a wind-down. A frozen holder's claim is held (`Frozen`). | +| **`AsyncVault`** | `cancel_deposit` | `holder.require_auth()` | Investor exit while the epoch cannot be priced. Refunds the whole escrowed deposit in one step, delisted or not. A frozen holder is held (`Frozen`). | +| **`AsyncVault`** | `request_redeem` | `holder.require_auth()` | Investor exit. Always open, even during pause. | +| **`AsyncVault`** | `claim_redeem` | `holder.require_auth()` | Investor exit. Pays settlement asset once covered. | +| **`AsyncVault`** | `cancel_redeem` | `holder.require_auth()` | Investor exit while the epoch cannot be priced. Outside a wind-down the vault checks the holder itself and returns the escrowed shares with the manager `forced_transfer`; it refuses a delisted holder (`NotAllowlisted`) and a move the compliance rules refuse (`HolderNotEligible`). In an active wind-down the escrow is surrendered into the holder's wind-down entitlement instead (`RedemptionSurrendered`). A frozen holder is held (`Frozen`) either way. | --- @@ -119,7 +119,7 @@ Tests: [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.r | `mint` | `#[only_role(operator, "manager")]` | Receiver allowlisted | `test::unauthorized_caller_cannot_mint`, `test::the_compliance_authority_cannot_mint_burn_or_swap_the_rules` | | `burn` | `#[only_role(operator, "manager")]` | Balance sufficient | `test::unauthorized_caller_cannot_burn` | | `forced_transfer` | `#[only_role(operator, "manager")]` | Balance sufficient | `test::unauthorized_caller_cannot_forced_transfer`, `test::legal_transfer::compliance_no_longer_forces_transfers` | -| `legal_transfer` | `#[only_role(operator, "compliance")]` and the admin's `require_auth()` in the same call, from two different accounts (`CoSignRequired`) | Reference of 1–128 bytes (`InvalidReference`); never from or to the vault's address (`VaultAddress`); destination passes `verify_identity`; balance sufficient | `test::legal_transfer::*` | +| `legal_transfer` | `#[only_role(caller, "compliance")]` and the admin's `require_auth()` in the same call, from two different accounts (`CoSignRequired`) | Reference of 1–128 bytes (`InvalidReference`); never from or to the vault's address (`VaultAddress`); destination passes `verify_identity`; balance sufficient | `test::legal_transfer::*` | | `set_address_frozen` | `#[only_role(operator, "compliance")]` | Never freezes the bound vault's address (`VaultAddress`); unfreezing it stays possible | `test::unauthorized_caller_cannot_freeze_or_recover`, `test::the_manager_escrows_but_does_not_intervene`, `test::legal_transfer::the_vault_address_cannot_be_frozen` | | `freeze_partial_tokens` | `#[only_role(operator, "compliance")]` | Balance sufficient; never the bound vault's address (`VaultAddress`) | `test::unauthorized_caller_cannot_freeze_or_recover`, `test::the_manager_escrows_but_does_not_intervene`, `test::legal_transfer::the_vault_address_cannot_be_frozen` | | `unfreeze_partial_tokens`| `#[only_role(operator, "compliance")]` | Frozen amount sufficient | `test::unauthorized_caller_cannot_freeze_or_recover`, `test::the_manager_escrows_but_does_not_intervene` | @@ -180,7 +180,7 @@ Tests: [`contracts/identity-verifier/src/test.rs`](../contracts/identity-verifie ### 5.5 Compliance (`contracts/compliance`) -A testnet-only stub. Its admin is governance. Module changes, token bindings and admin hand-overs are instant during setup and wait for the vault's timelock after `finish_setup`. Each admin call checks the stored admin's signature; its `operator` argument is not checked. +A testnet-only stub. Its admin is governance. Module changes, token bindings and admin hand-overs are instant during setup and wait for the vault's timelock after `finish_setup`. Each admin call checks the stored admin's signature; its `caller` or `operator` argument is not checked. Tests: [`contracts/compliance/src/test/changes.rs`](../contracts/compliance/src/test/changes.rs). diff --git a/docs/COMPLIANCE_MATRIX.md b/docs/COMPLIANCE_MATRIX.md index 3038a909..9e01af2b 100644 --- a/docs/COMPLIANCE_MATRIX.md +++ b/docs/COMPLIANCE_MATRIX.md @@ -32,10 +32,10 @@ Every investor account in the vault ecosystem exists in one of three primary com | Operation | Allowlisted | De-listed (Non-Frozen) | Frozen (Address or Partial) | Contract Mechanics & Regulatory Rationale | |---|---|---|---|---| | **`request_deposit`** | ✅ Allowed | ❌ **Refused** | ❌ **Refused** | Before any cash moves, the vault asks the share token whether the address is frozen and then asks the token's identity verifier. It refuses a frozen address (`Frozen`) and an address the token would not let hold shares (`NotAllowlisted`). A partial freeze is not checked. | -| **`cancel_deposit`** | ✅ Allowed | ✅ **Allowed** | ❌ **Refused** (`Frozen`); the escrow stays | **Escape Hatch**: Calls `refund_deposit`. Escrowed settlement asset (USDC) is refunded directly to `controller`. The vault checks `is_frozen` on the share token before it refunds anything; once past that, cash refunds bypass share token compliance checks. A partial freeze is not checked. | +| **`cancel_deposit`** | ✅ Allowed | ✅ **Allowed** | ❌ **Refused** (`Frozen`); the escrow stays | **Escape Hatch**: Calls `refund_deposit`. Escrowed settlement asset (USDC) is refunded directly to `holder`. The vault checks `is_frozen` on the share token before it refunds anything; once past that, cash refunds bypass share token compliance checks. A partial freeze is not checked. | | **`claim_deposit`** | ✅ Allowed | ↪ **Moved to the exit queue** | ⛔ **Held** | A receiver off the allowlist gets no shares: the vault mints them into its own escrow and adds them to the holder's redemption in the open epoch (`DepositConvertedToExit`), paid in cash at that epoch's price. In a wind-down they join the holder's wind-down entitlement. A compliance module that refuses the mint (`can_create`) still blocks the claim. A receiver whose address is frozen, listed or not, is refused with `Frozen` (6071) and keeps the priced deposit until the freeze is lifted. A partial freeze is not checked. | | **`request_redeem`** | ✅ Allowed | ✅ **Allowed** | ❌ **Refused** (`Frozen`); a partial freeze caps the request to the free balance | **Exit Right**: the vault checks `is_frozen` and `get_frozen_tokens` on the share token before it escrows anything with `RWATokenClient::forced_transfer`. A de-listed, non-frozen holder can still start an exit; a frozen one cannot, and a partial freeze caps the request to the shares `get_frozen_tokens` leaves free. | -| **`cancel_redeem`** | ✅ Allowed | ❌ **Refused**; ↪ **surrendered** in a wind-down | ⛔ **Held**; a partial freeze does not stop it: the shares return and stay partly frozen | Outside a wind-down the vault refuses a de-listed controller (`NotAllowlisted`) and holds one whose address is frozen (`Frozen`). For anyone else it checks the receiver itself (passes the identity check, not frozen, accepted by the compliance rules' `can_transfer`) and returns the shares with the manager `forced_transfer`; a move the rules refuse gets `HolderNotEligible`. Neither the vault's own allowlist place nor the share-token pause stops a return. A refused controller stays in the queue and exits in cash once the epoch is priced. In an active wind-down no shares come back: the escrow is burned into the controller's wind-down entitlement (`RedemptionSurrendered`), allowlisted or not. A frozen controller's escrow stays until the freeze is lifted. | +| **`cancel_redeem`** | ✅ Allowed | ❌ **Refused**; ↪ **surrendered** in a wind-down | ⛔ **Held**; a partial freeze does not stop it: the shares return and stay partly frozen | Outside a wind-down the vault refuses a de-listed holder (`NotAllowlisted`) and holds one whose address is frozen (`Frozen`). For anyone else it checks the receiver itself (passes the identity check, not frozen, accepted by the compliance rules' `can_transfer`) and returns the shares with the manager `forced_transfer`; a move the rules refuse gets `HolderNotEligible`. Neither the vault's own allowlist place nor the share-token pause stops a return. A refused holder stays in the queue and exits in cash once the epoch is priced. In an active wind-down no shares come back: the escrow is burned into the holder's wind-down entitlement (`RedemptionSurrendered`), allowlisted or not. A frozen holder's escrow stays until the freeze is lifted. | | **`claim_redeem`** | ✅ Allowed | ✅ **Allowed** | ❌ **Refused** (`Frozen`); the cash stays in `committed` | **Exit-Only Cash Path**: the settlement asset pays out from the liquid reserve and the escrowed shares are burned from the vault. The cash claim checks `is_frozen` on the share token first; it does not check the allowlist. A partial freeze is not checked. Outside a wind-down, a claim that pays nothing returns the escrowed shares instead, under the same checks as `cancel_redeem`: a de-listed holder's is refused with `HolderNotEligible` and the request stays. In a wind-down such a claim burns the escrowed shares. | | **`claim_wind_down`** | ✅ Allowed | ✅ **Allowed** | ❌ **Refused** (`Frozen`); the slice stays in `wind_down_owed` | **Wind-Down Payout**: pays the holder's pro-rata entitlement and burns the free shares it surrenders. The vault checks `is_frozen` and `get_frozen_tokens` on the share token first; a partial freeze surrenders only the free shares — the rest keeps earning every round and is paid once unfrozen. | | **`transfer`** (Secondary) | ✅ Allowed | ❌ **Refused** | ❌ **Refused** | Standard peer-to-peer share transfer via SEP-57. Requires both sender and receiver to be allowlisted and neither to be frozen. | @@ -57,7 +57,7 @@ A core guarantee of the Strata Vault Kit is that **a de-listed, non-frozen inves ### 3.2 Deposit Cancellation for De-Listed Investors If an investor deposits into an open epoch but is de-listed (or KYC expires) before the epoch is priced: -- The investor is permitted to call `cancel_deposit(investor, epoch_id)`. +- The investor is permitted to call `cancel_deposit(holder, epoch_id)`. - `refund_deposit` transfers the escrowed USDC back to the investor. - Because no shares were minted, returning cash restores the investor's funds without violating share token compliance rules. @@ -77,9 +77,9 @@ An address that was never allowlisted cannot reach this state: `request_deposit` ### 3.4 Redemption Cancellation Refusal When an investor requests redemption, their shares are transferred to the vault. If the investor becomes de-listed or frozen before pricing: -- The vault checks the controller itself before it returns escrowed shares. A de-listed controller is refused (`NotAllowlisted`), and so is a move the compliance rules refuse (`HolderNotEligible`); either way the investor stays in the queue and exits in cash once the epoch is priced. A frozen controller is held (`Frozen`) until the freeze is lifted. +- The vault checks the holder itself before it returns escrowed shares. A de-listed holder is refused (`NotAllowlisted`), and so is a move the compliance rules refuse (`HolderNotEligible`); either way the investor stays in the queue and exits in cash once the epoch is priced. A frozen holder is held (`Frozen`) until the freeze is lifted. - The vault moves the shares as the token's `manager` (`forced_transfer`), so removing the vault's own address from the allowlist does not stop returns. A share-token pause does not stop them either: it stops trading between holders, not the vault's escrow. A partial freeze does not stop them: the shares return and stay partly frozen. -- During a wind-down, every cancel burns the escrowed shares into the controller's wind-down entitlement (`RedemptionSurrendered`), listed or not, and they collect pro rata with `claim_wind_down`. A frozen controller's escrow stays until the freeze is lifted. +- During a wind-down, every cancel burns the escrowed shares into the holder's wind-down entitlement (`RedemptionSurrendered`), listed or not, and they collect pro rata with `claim_wind_down`. A frozen holder's escrow stays until the freeze is lifted. --- diff --git a/docs/kit-spec.md b/docs/kit-spec.md index 7d5368aa..c0b7b30c 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -50,7 +50,7 @@ The contracts enforce a set of mathematical and state-machine invariants across - `request_deposit` is refused by `#[when_not_paused]` (`PausableError::EnforcedPause`). - `request_redeem` succeeds and locks shares into escrow. - `claim_redeem` and `claim_deposit` remain open. - - `cancel_redeem` remains open. Outside a wind-down, a delisted controller, or one the compliance rules refuse, exits through the cash claim instead; in a wind-down the escrow is surrendered. A frozen controller is held (Invariant 6). + - `cancel_redeem` remains open. Outside a wind-down, a delisted holder, or one the compliance rules refuse, exits through the cash claim instead; in a wind-down the escrow is surrendered. A frozen holder is held (Invariant 6). - `cancel_deposit` and `cancel_redeem` stay open for a sealed epoch while the vault is paused, because a paused vault cannot price it (Invariant 6). ### Invariant 6: Single-Step Atomic Cancellation @@ -62,7 +62,7 @@ The contracts enforce a set of mathematical and state-machine invariants across - While an epoch is `Open`, or `Pending` with no readable price or with the vault paused, `cancel_deposit` and `cancel_redeem` return 100% of the escrow. - A readable price refuses cancellation with `PriceAvailable`, even before `priceable_at`; once that report expires, cancellation works again (`cancellation_reopens_when_the_price_that_closed_it_expires`). - Pausing the vault reopens cancellation; unpausing closes it again while the price is still readable (`a_paused_vault_reopens_cancellation_of_an_epoch_it_could_price`). - - Outside a wind-down, `cancel_redeem` refuses a delisted controller (`NotAllowlisted`) and a move the compliance rules refuse (`HolderNotEligible`); the request stays pending and the cash claim remains open. It holds a frozen controller (`Frozen`): the request stays pending, and its cash claim is held too until the freeze is lifted. Neither the share token's transfer pause nor the vault's own allowlist place stops a cancellation (`a_token_pause_stops_trading_but_not_a_share_return`, `a_delisted_vault_still_returns_escrowed_shares`). + - Outside a wind-down, `cancel_redeem` refuses a delisted holder (`NotAllowlisted`) and a move the compliance rules refuse (`HolderNotEligible`); the request stays pending and the cash claim remains open. It holds a frozen holder (`Frozen`): the request stays pending, and its cash claim is held too until the freeze is lifted. Neither the share token's transfer pause nor the vault's own allowlist place stops a cancellation (`a_token_pause_stops_trading_but_not_a_share_return`, `a_delisted_vault_still_returns_escrowed_shares`). - In an active wind-down, `cancel_redeem` never returns shares: the escrow joins the holder's wind-down entitlement (`RedemptionSurrendered`), whether the holder is allowlisted or not. A frozen holder's escrow stays until the freeze is lifted. - Once fulfilled, cancellation is refused with `AlreadyPriced`. - **Operating note**: keep `freshness_duration` longer than the notice, so a normal epoch's price does not lapse and reopen the window on its own. @@ -206,12 +206,12 @@ Strata distributes control across five native Stellar multisig authorities and p | Contract | Entrypoint | Calling Authority | Access Control Guard | Pausable? | |---|---|---|---|---| -| **`AsyncVault`** | `request_deposit` | Investor (Caller) | `from.require_auth()`; refused until setup finishes (`SetupOpen`) | Yes (halted by pause) | -| **`AsyncVault`** | `cancel_deposit` | Investor (Caller) | `from.require_auth()` | No (open during pause) | -| **`AsyncVault`** | `claim_deposit` | Investor (Caller) | `caller.require_auth()` | No (open during pause) | -| **`AsyncVault`** | `request_redeem` | Investor (Caller) | `from.require_auth()` | No (always open) | -| **`AsyncVault`** | `cancel_redeem` | Investor (Caller) | `from.require_auth()` | No (open during pause) | -| **`AsyncVault`** | `claim_redeem` | Investor (Caller) | `caller.require_auth()` | No (always open) | +| **`AsyncVault`** | `request_deposit` | Investor (Caller) | `holder.require_auth()`; refused until setup finishes (`SetupOpen`) | Yes (halted by pause) | +| **`AsyncVault`** | `cancel_deposit` | Investor (Caller) | `holder.require_auth()` | No (open during pause) | +| **`AsyncVault`** | `claim_deposit` | Investor (Caller) | `holder.require_auth()` | No (open during pause) | +| **`AsyncVault`** | `request_redeem` | Investor (Caller) | `holder.require_auth()` | No (always open) | +| **`AsyncVault`** | `cancel_redeem` | Investor (Caller) | `holder.require_auth()` | No (open during pause) | +| **`AsyncVault`** | `claim_redeem` | Investor (Caller) | `holder.require_auth()` | No (always open) | | **`AsyncVault`** | `close_epoch` | Manager | `#[only_role(caller, "manager")]` | No | | **`AsyncVault`** | `fulfill_epoch` | Permissionless | None | Yes (halted by pause) | | **`AsyncVault`** | `pause` | Guardian | `#[only_role(caller, "guardian")]` | N/A | @@ -226,7 +226,7 @@ Strata distributes control across five native Stellar multisig authorities and p | **`AsyncVault`** | `cancel_wind_down_proposal` | Governance | `#[only_admin]` | No | | **`AsyncVault`** | `activate_wind_down` | Permissionless | None (requires timelock elapsed) | No | | **`AsyncVault`** | `finalize_wind_down_round` | Permissionless | None | No | -| **`AsyncVault`** | `claim_wind_down` | Shareholder (Caller) | `caller.require_auth()` | No | +| **`AsyncVault`** | `claim_wind_down` | Shareholder (Caller) | `holder.require_auth()` | No | | **`AsyncVault`** | `deploy_to_custodian` | Treasury | `#[only_role(caller, "treasury")]` | No (open during pause) | | **`AsyncVault`** | `fund` | Treasury | None (caller transfers funds) | No | | **`AsyncVault`** | `propose_change` | Governance | `#[only_admin]`; after setup | No | @@ -256,7 +256,7 @@ Strata distributes control across five native Stellar multisig authorities and p | **`ShareToken`** | `mint` | Manager (Vault) | `#[only_role(operator, "manager")]` | Bypasses token pause | | **`ShareToken`** | `burn` | Manager (Vault) | `#[only_role(operator, "manager")]` | Bypasses token pause | | **`ShareToken`** | `forced_transfer` | Manager (Vault) | `#[only_role(operator, "manager")]` | Bypasses token pause | -| **`ShareToken`** | `legal_transfer` | Compliance, co-signed by Governance (token admin) | `#[only_role(operator, "compliance")]` and the admin's `require_auth()` in the same call, from two different accounts; never from or to the vault's address; the destination passes `verify_identity`; a reference of 1–128 bytes | Bypasses token pause | +| **`ShareToken`** | `legal_transfer` | Compliance, co-signed by Governance (token admin) | `#[only_role(caller, "compliance")]` and the admin's `require_auth()` in the same call, from two different accounts; never from or to the vault's address; the destination passes `verify_identity`; a reference of 1–128 bytes | Bypasses token pause | | **`ShareToken`** | `set_address_frozen`, `freeze_partial_tokens`, `unfreeze_partial_tokens` | Compliance | `#[only_role(operator, "compliance")]`; never freezes the vault's address once the token knows its vault | No | | **`ShareToken`** | `recover_balance` | Compliance, co-signed by Governance (token admin) | `#[only_role(operator, "compliance")]` and the admin's `require_auth()` in the same call, from two different accounts; never from or to the vault's address | No | | **`ShareToken`** | `set_compliance`, `set_identity_verifier` | Governance | `#[only_admin]`; instant during setup, then `propose_change` | No | diff --git a/scripts/harness/deploy.ts b/scripts/harness/deploy.ts index 9f161ea9..a0f68701 100644 --- a/scripts/harness/deploy.ts +++ b/scripts/harness/deploy.ts @@ -204,8 +204,8 @@ async function grantAndVerify( // Binding the token is instant only during setup, so the compliance // contract's setup closes after it. - await (await compliance.bind_vault({ vault, operator: caller })).signAndSend() - await (await compliance.finish_setup({ operator: caller })).signAndSend() + await (await compliance.bind_vault({ vault, caller })).signAndSend() + await (await compliance.finish_setup({ caller })).signAndSend() if ((await compliance.setup_open()).result) throw new Error("the compliance contract's setup did not finish") console.log("compliance bound to the vault, setup finished") diff --git a/scripts/harness/guards.test.ts b/scripts/harness/guards.test.ts index a4c4ffa4..5cdc3ab9 100644 --- a/scripts/harness/guards.test.ts +++ b/scripts/harness/guards.test.ts @@ -303,7 +303,7 @@ describe("the oracle after setup", () => { }) describe("the compliance contract after setup", () => { - const operator = () => governance.publicKey() + const caller = () => governance.publicKey() const canCreate = { tag: "CanCreate", values: undefined } as const it("governance cannot add a module at once", async () => { @@ -312,7 +312,7 @@ describe("the compliance contract after setup", () => { await rules.add_module_to({ hook: canCreate, module: outsider.publicKey(), - operator: operator(), + operator: caller(), }), rules, "UseTimelock", @@ -324,15 +324,15 @@ describe("the compliance contract after setup", () => { const id = await send( rules.propose_change({ change: { tag: "AddModule", values: [canCreate, outsider.publicKey()] }, - operator: operator(), + caller: caller(), }), ) expectRefused( - await rules.apply_change({ id, operator: operator() }), + await rules.apply_change({ id, caller: caller() }), rules, "ChangeNotReady", ) - await send(rules.cancel_change({ id, operator: operator() })) + await send(rules.cancel_change({ id, caller: caller() })) expect((await rules.pending_changes()).result).toEqual([]) }) }) @@ -375,7 +375,7 @@ describe("invalid amounts and duplicate requests in an epoch", () => { it("rejects zero deposit amount", async () => { const v = vault(c, first) expectRefused( - await v.request_deposit({ from: first.publicKey(), amount: 0n }), + await v.request_deposit({ holder: first.publicKey(), amount: 0n }), v, "InvalidAmount", ) @@ -384,7 +384,7 @@ describe("invalid amounts and duplicate requests in an epoch", () => { it("rejects zero redeem shares", async () => { const v = vault(c, first) expectRefused( - await v.request_redeem({ from: first.publicKey(), shares: 0n }), + await v.request_redeem({ holder: first.publicKey(), shares: 0n }), v, "InvalidAmount", ) @@ -393,14 +393,14 @@ describe("invalid amounts and duplicate requests in an epoch", () => { it("rejects a second request from the same account in one epoch", async () => { const v = vault(c, first) const epoch = await send( - v.request_deposit({ from: first.publicKey(), amount: usdc(10) }), + v.request_deposit({ holder: first.publicKey(), amount: usdc(10) }), ) expectRefused( - await v.request_deposit({ from: first.publicKey(), amount: usdc(10) }), + await v.request_deposit({ holder: first.publicKey(), amount: usdc(10) }), v, "RequestOutstanding", ) - await send(v.cancel_deposit({ from: first.publicKey(), epoch_id: epoch })) + await send(v.cancel_deposit({ holder: first.publicKey(), epoch_id: epoch })) }) }) @@ -409,7 +409,7 @@ describe("who may subscribe", () => { const v = vault(c, outsider) expectRefused( await v.request_deposit({ - from: outsider.publicKey(), + holder: outsider.publicKey(), amount: usdc(10), }), v, @@ -427,14 +427,14 @@ describe("two epochs pending at once", () => { it("both read Pending", async () => { epochA = await send( vault(c, first).request_deposit({ - from: first.publicKey(), + holder: first.publicKey(), amount: usdc(100), }), ) expect(await closeEpoch()).toBe(epochA) epochB = await send( vault(c, second).request_deposit({ - from: second.publicKey(), + holder: second.publicKey(), amount: usdc(100), }), ) @@ -443,7 +443,7 @@ describe("two epochs pending at once", () => { expect((await epochOf(epochB))?.status.tag).toBe("Pending") expectRefused( await vault(c, first).claim_deposit({ - caller: first.publicKey(), + holder: first.publicKey(), epoch_id: epochA, }), vault(c, first), @@ -464,7 +464,7 @@ describe("two epochs pending at once", () => { expect( await send( vault(c, first).claim_deposit({ - caller: first.publicKey(), + holder: first.publicKey(), epoch_id: epochA, }), ), @@ -472,14 +472,14 @@ describe("two epochs pending at once", () => { expect( await send( vault(c, second).claim_deposit({ - caller: second.publicKey(), + holder: second.publicKey(), epoch_id: epochB, }), ), ).toBe(sharesFor(usdc(100), secondPrice)) expectRefused( await vault(c, first).claim_deposit({ - caller: first.publicKey(), + holder: first.publicKey(), epoch_id: epochA, }), vault(c, first), @@ -487,7 +487,7 @@ describe("two epochs pending at once", () => { ) expectRefused( await vault(c, outsider).claim_deposit({ - caller: outsider.publicKey(), + holder: outsider.publicKey(), epoch_id: epochA, }), vault(c, outsider), @@ -506,13 +506,13 @@ describe("a small covered claim pays while a larger one waits", () => { large = await shares(second.publicKey()) await send( vault(c, first).request_redeem({ - from: first.publicKey(), + holder: first.publicKey(), shares: small, }), ) epoch = await send( vault(c, second).request_redeem({ - from: second.publicKey(), + holder: second.publicKey(), shares: large, }), ) @@ -534,7 +534,7 @@ describe("a small covered claim pays while a larger one waits", () => { const before = await usdcBalance(c, first.publicKey()) const paid = await send( vault(c, first).claim_redeem({ - caller: first.publicKey(), + holder: first.publicKey(), epoch_id: epoch, }), ) @@ -545,7 +545,7 @@ describe("a small covered claim pays while a larger one waits", () => { it("the large claim waits", async () => { const v = vault(c, second) expectRefused( - await v.claim_redeem({ caller: second.publicKey(), epoch_id: epoch }), + await v.claim_redeem({ holder: second.publicKey(), epoch_id: epoch }), v, "ClaimNotCovered", ) @@ -555,13 +555,13 @@ describe("a small covered claim pays while a larger one waits", () => { it("pays once the capital returns", async () => { await send( vault(c, custodian).fund({ - from: custodian.publicKey(), + caller: custodian.publicKey(), assets: usdc(150), }), ) const paid = await send( vault(c, second).claim_redeem({ - caller: second.publicKey(), + holder: second.publicKey(), epoch_id: epoch, }), ) @@ -569,7 +569,7 @@ describe("a small covered claim pays while a larger one waits", () => { expect((await reader.uncovered()).result).toBe(0n) expectRefused( await vault(c, second).claim_redeem({ - caller: second.publicKey(), + holder: second.publicKey(), epoch_id: epoch, }), vault(c, second), @@ -584,7 +584,10 @@ describe("a deposit too small to mint a share", () => { it("is refunded at claim", async () => { const before = await usdcBalance(c, third.publicKey()) epoch = await send( - vault(c, third).request_deposit({ from: third.publicKey(), amount: 1n }), + vault(c, third).request_deposit({ + holder: third.publicKey(), + amount: 1n, + }), ) expect(await closeEpoch()).toBe(epoch) await attest(c, secondPrice) @@ -592,7 +595,7 @@ describe("a deposit too small to mint a share", () => { expect( await send( vault(c, third).claim_deposit({ - caller: third.publicKey(), + holder: third.publicKey(), epoch_id: epoch, }), ), @@ -603,14 +606,14 @@ describe("a deposit too small to mint a share", () => { }) describe("a de-listed holder", () => { - const from = first.publicKey() + const holder = first.publicKey() let epoch = 0n let redeeming = 0n it("cannot cancel a redemption back to shares", async () => { redeeming = sharesFor(usdc(10), secondPrice) epoch = await send( - vault(c, first).request_redeem({ from, shares: redeeming }), + vault(c, first).request_redeem({ holder, shares: redeeming }), ) await send( identityVerifier(c, compliance).allow({ @@ -620,7 +623,7 @@ describe("a de-listed holder", () => { }), ) expectRefused( - await vault(c, first).cancel_redeem({ from, epoch_id: epoch }), + await vault(c, first).cancel_redeem({ holder, epoch_id: epoch }), vault(c, first), "NotAllowlisted", ) @@ -633,7 +636,7 @@ describe("a de-listed holder", () => { const before = await usdcBalance(c, first.publicKey()) const paid = await send( vault(c, first).claim_redeem({ - caller: first.publicKey(), + holder: first.publicKey(), epoch_id: epoch, }), ) @@ -667,13 +670,13 @@ describe("a sealed epoch while the feed is down", () => { redeeming = await shares(first.publicKey()) await send( vault(c, first).request_deposit({ - from: first.publicKey(), + holder: first.publicKey(), amount: usdc(100), }), ) epoch = await send( vault(c, first).request_redeem({ - from: first.publicKey(), + holder: first.publicKey(), shares: redeeming, }), ) @@ -681,12 +684,12 @@ describe("a sealed epoch while the feed is down", () => { await attest(c, secondPrice) const v = vault(c, first) expectRefused( - await v.cancel_deposit({ from: first.publicKey(), epoch_id: epoch }), + await v.cancel_deposit({ holder: first.publicKey(), epoch_id: epoch }), v, "PriceAvailable", ) expectRefused( - await v.cancel_redeem({ from: first.publicKey(), epoch_id: epoch }), + await v.cancel_redeem({ holder: first.publicKey(), epoch_id: epoch }), v, "PriceAvailable", ) @@ -707,7 +710,7 @@ describe("a sealed epoch while the feed is down", () => { expect( await send( vault(c, first).cancel_deposit({ - from: first.publicKey(), + holder: first.publicKey(), epoch_id: epoch, }), ), @@ -719,7 +722,7 @@ describe("a sealed epoch while the feed is down", () => { expect( await send( vault(c, first).cancel_redeem({ - from: first.publicKey(), + holder: first.publicKey(), epoch_id: epoch, }), ), @@ -730,7 +733,7 @@ describe("a sealed epoch while the feed is down", () => { it("a second cancel finds no request", async () => { expectRefused( await vault(c, first).cancel_redeem({ - from: first.publicKey(), + holder: first.publicKey(), epoch_id: epoch, }), reader, @@ -762,7 +765,7 @@ describe("the notice period and valuation age rules", () => { it("records priceable_at at close and holds it through the notice", async () => { await send( vault(c, first).request_deposit({ - from: first.publicKey(), + holder: first.publicKey(), amount: usdc(100), }), ) @@ -801,7 +804,7 @@ describe("the notice period and valuation age rules", () => { it("refuses cancellation while the notice is running once price is readable", async () => { await send( vault(c, first).request_deposit({ - from: first.publicKey(), + holder: first.publicKey(), amount: usdc(100), }), ) @@ -809,7 +812,7 @@ describe("the notice period and valuation age rules", () => { await attest(c, secondPrice) const v = vault(c, first) expectRefused( - await v.cancel_deposit({ from: first.publicKey(), epoch_id: epochB }), + await v.cancel_deposit({ holder: first.publicKey(), epoch_id: epochB }), v, "PriceAvailable", ) diff --git a/scripts/harness/happy-path.test.ts b/scripts/harness/happy-path.test.ts index 415ae3d2..164cffc7 100644 --- a/scripts/harness/happy-path.test.ts +++ b/scripts/harness/happy-path.test.ts @@ -94,7 +94,7 @@ describe("subscription", () => { for (const holder of [first, second]) { const epoch = await send( vault(c, holder).request_deposit({ - from: holder.publicKey(), + holder: holder.publicKey(), amount: each(), }), ) @@ -102,7 +102,7 @@ describe("subscription", () => { const request = ( await reader.get_deposit_request({ epoch_id: subscriptionEpoch, - controller: holder.publicKey(), + holder: holder.publicKey(), }) ).result expect(request?.amount).toBe(each()) @@ -120,7 +120,7 @@ describe("subscription", () => { const v = vault(c, first) expectRefused( await v.cancel_deposit({ - from: first.publicKey(), + holder: first.publicKey(), epoch_id: subscriptionEpoch + 1000n, }), v, @@ -132,12 +132,12 @@ describe("subscription", () => { const before = await usdcBalance(c, canceller.publicKey()) const v = vault(c, canceller) await send( - v.request_deposit({ from: canceller.publicKey(), amount: each() }), + v.request_deposit({ holder: canceller.publicKey(), amount: each() }), ) expect(await usdcBalance(c, canceller.publicKey())).toBe(before - each()) const returned = await send( v.cancel_deposit({ - from: canceller.publicKey(), + holder: canceller.publicKey(), epoch_id: subscriptionEpoch, }), ) @@ -201,7 +201,7 @@ describe("pricing", () => { const before = await shares(holder.publicKey()) const got = await send( vault(c, holder).claim_deposit({ - caller: holder.publicKey(), + holder: holder.publicKey(), epoch_id: subscriptionEpoch, }), ) @@ -216,7 +216,7 @@ describe("pricing", () => { const v = vault(c, first) expectRefused( await v.claim_deposit({ - caller: first.publicKey(), + holder: first.publicKey(), epoch_id: subscriptionEpoch, }), v, @@ -267,7 +267,7 @@ describe("redemption", () => { const held = await shares(holder.publicKey()) const epoch = await send( vault(c, holder).request_redeem({ - from: holder.publicKey(), + holder: holder.publicKey(), shares: held, }), ) @@ -282,7 +282,7 @@ describe("redemption", () => { it("refuses a second exit request in one epoch", async () => { const v = vault(c, first) expectRefused( - await v.request_redeem({ from: first.publicKey(), shares: 1n }), + await v.request_redeem({ holder: first.publicKey(), shares: 1n }), v, "RequestOutstanding", ) @@ -292,7 +292,7 @@ describe("redemption", () => { const v = vault(c, first) expectRefused( await v.claim_redeem({ - caller: first.publicKey(), + holder: first.publicKey(), epoch_id: redemptionEpoch, }), v, @@ -339,7 +339,7 @@ describe("treasury", () => { const v = vault(c, first) expectRefused( await v.claim_redeem({ - caller: first.publicKey(), + holder: first.publicKey(), epoch_id: redemptionEpoch, }), v, @@ -362,7 +362,7 @@ describe("treasury", () => { it("fund returns the capital and the refused claims then pay", async () => { await send( vault(c, custodian).fund({ - from: custodian.publicKey(), + caller: custodian.publicKey(), assets: deployed(), }), ) @@ -371,7 +371,7 @@ describe("treasury", () => { const before = await usdcBalance(c, holder.publicKey()) const paid = await send( vault(c, holder).claim_redeem({ - caller: holder.publicKey(), + holder: holder.publicKey(), epoch_id: redemptionEpoch, }), ) diff --git a/scripts/harness/seed.ts b/scripts/harness/seed.ts index 4bb16227..c8285936 100644 --- a/scripts/harness/seed.ts +++ b/scripts/harness/seed.ts @@ -185,13 +185,13 @@ async function main(): Promise { ) await send( vault(c, holder1).request_deposit({ - from: holder1.publicKey(), + holder: holder1.publicKey(), amount: usdc(5_000), }), ) await send( vault(c, holder2).request_deposit({ - from: holder2.publicKey(), + holder: holder2.publicKey(), amount: usdc(3_000), }), ) @@ -213,13 +213,13 @@ async function main(): Promise { console.log(" + claiming shares for holder-1 and holder-2...") await send( vault(c, holder1).claim_deposit({ - caller: holder1.publicKey(), + holder: holder1.publicKey(), epoch_id: 1n, }), ) await send( vault(c, holder2).claim_deposit({ - caller: holder2.publicKey(), + holder: holder2.publicKey(), epoch_id: 1n, }), ) @@ -266,14 +266,14 @@ async function main(): Promise { const h1RedeemReq = ( await reader.get_redeem_request({ epoch_id: currentEpoch, - controller: holder1.publicKey(), + holder: holder1.publicKey(), }) ).result if (!h1RedeemReq && h1Shares >= usdc(1_000)) { console.log(" + holder-1 requesting redemption of 1,000 shares...") await send( vault(c, holder1).request_redeem({ - from: holder1.publicKey(), + holder: holder1.publicKey(), shares: usdc(1_000), }), ) @@ -287,14 +287,14 @@ async function main(): Promise { const h3DepositReq = ( await reader.get_deposit_request({ epoch_id: currentEpoch, - controller: holder3.publicKey(), + holder: holder3.publicKey(), }) ).result if (!h3DepositReq) { console.log(" + holder-3 requesting deposit of 2,500 USDC...") await send( vault(c, holder3).request_deposit({ - from: holder3.publicKey(), + holder: holder3.publicKey(), amount: usdc(2_500), }), ) @@ -371,13 +371,13 @@ async function main(): Promise { const depReq = ( await reader.get_deposit_request({ epoch_id: finalEpoch, - controller: key.publicKey(), + holder: key.publicKey(), }) ).result const redReq = ( await reader.get_redeem_request({ epoch_id: finalEpoch, - controller: key.publicKey(), + holder: key.publicKey(), }) ).result if (depReq) { diff --git a/scripts/harness/wind-down.test.ts b/scripts/harness/wind-down.test.ts index 8bb27fea..7dd0eccb 100644 --- a/scripts/harness/wind-down.test.ts +++ b/scripts/harness/wind-down.test.ts @@ -84,7 +84,7 @@ describe("a wind-down on a throwaway vault", () => { await call( "request_deposit", "holder-1", - { from: holder(), amount: usdc(200) }, + { holder: holder(), amount: usdc(200) }, true, ), ).toBe("accepted") @@ -104,7 +104,7 @@ describe("a wind-down on a throwaway vault", () => { await call( "claim_deposit", "holder-1", - { caller: holder(), epoch_id: 1n }, + { holder: holder(), epoch_id: 1n }, true, ), ).toBe("accepted") @@ -115,7 +115,7 @@ describe("a wind-down on a throwaway vault", () => { await call( "request_redeem", "holder-1", - { from: holder(), shares: usdc(10) }, + { holder: holder(), shares: usdc(10) }, true, ), ).toBe("accepted") @@ -204,8 +204,8 @@ describe("a wind-down on a throwaway vault", () => { ["wind_down_claimable", { holder: holder() }], ["get_epoch", { epoch_id: 1n }], ["get_epoch_settlement", { epoch_id: 1n }], - ["get_deposit_request", { epoch_id: 1n, controller: holder() }], - ["get_redeem_request", { epoch_id: 2n, controller: holder() }], + ["get_deposit_request", { epoch_id: 1n, holder: holder() }], + ["get_redeem_request", { epoch_id: 2n, holder: holder() }], ["has_role", { account: gov, role: "manager" }], ["get_role_member_count", { role: "manager" }], ["get_role_member", { role: "manager", index: 0 }], @@ -264,23 +264,23 @@ describe("a wind-down on a throwaway vault", () => { { caller: c.keys.treasury.publicKey(), assets: 1n }, "refused: WindDownActive", ], - ["fund", "holder-1", { from: holder(), assets: 1n }, "accepted"], + ["fund", "holder-1", { caller: holder(), assets: 1n }, "accepted"], [ "cancel_deposit", "holder-1", - { from: holder(), epoch_id: 1n }, + { holder: holder(), epoch_id: 1n }, "refused: AlreadyPriced", ], [ "cancel_redeem", "holder-1", - { from: holder(), epoch_id: 2n }, + { holder: holder(), epoch_id: 2n }, "accepted", ], [ "claim_redeem", "holder-1", - { caller: holder(), epoch_id: 2n }, + { holder: holder(), epoch_id: 2n }, "refused: EpochNotFulfilled", ], [ From 988fd66a817713004f3ac7827963475d471d7040 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:31:05 -0300 Subject: [PATCH 069/147] docs: write the code conventions in AGENTS.md A short section states how the kit's code is named and written: the strata- crate prefix, caller and holder in entrypoints, named errors, separate functions instead of boolean switches, guards, doc comments, design notes and exact pins. --- AGENTS.md | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a7528957..8f2e454a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -110,6 +110,39 @@ Not recorded here on purpose. The reference base kept them in this file and they drifted: its `AGENTS.md` and its generated client pointed at two different vault contracts. The addresses emitted by the deploy script are authoritative. +## Code conventions + +- **Crate names.** Shared crates in `crates/` start with `strata-` + (`strata-timelock`, `strata-storage`, `strata-bindings`), so a path such as + `strata_timelock::DAY_DURATION` reads as the kit's own code. +- **Addresses in entrypoints.** In an entrypoint the kit defines, `caller` is + the account that must sign the call, and `holder` is the account whose + position, request or balance the call is about. When one account is both, the + entrypoint takes `holder` and that account signs. The two ends of a share + transfer are `from` and `to`, and other addresses are named for what they + are (`custodian`, `vault`, the allowlisted `account`). OpenZeppelin trait + methods keep their own names (`operator`, `user_address`). +- **Named errors.** Contract code fails with a named error variant, never with + `unwrap`, `expect` or a bare `panic!`. Tests may unwrap. +- **No boolean switches.** A new entrypoint does one thing. Two behaviours get + two functions, like `freeze_partial_tokens` and `unfreeze_partial_tokens`, + not one function that takes a `bool`. +- **Guards.** Each precondition is a `pub(crate) fn` in the contract's + `guards.rs`. Its name is the condition it checks (`positive_amount`, + `not_winding_down`), and it panics with a named error when the condition + fails. An entrypoint calls its guards first and then does its work, so its + preconditions are the first lines a reader sees. +- **Doc comments.** Every public entrypoint has one, in OpenZeppelin's style: a + one-line summary, then `# Arguments` with units (asset base units, share base + units, WAD prices, seconds or ledgers), `# Errors`, `# Events` and + `# Authorization`. Every error variant's doc says when it happens. Keep them + short: they are stored in the Wasm as part of the contract spec. +- **Design notes.** Each contract's `lib.rs` opens with a `//! # Design` + section: five to ten bullets, each a choice and its trade-off, in plain words + and with no issue numbers or decision ids. +- **Exact pins.** Every crates.io dependency, dev-dependencies included, is + pinned with `=` to the version in `Cargo.lock`. + ## Conventions - Conventional Commits, imperative, subject ≤ 50 chars. **No AI attribution.** @@ -117,8 +150,8 @@ contracts. The addresses emitted by the deploy script are authoritative. `feat/-`, `fix/...`, `chore/...`. - Issue bodies follow the templates in `.github/ISSUE_TEMPLATE/`, 200 words of prose hard cap. Titles are natural language with no prefix. -- Exact dependency pins. Dependencies are never upgraded mid-milestone; each - migration is its own issue in the following one. +- Dependencies are never upgraded mid-milestone; each migration is its own + issue in the following one. - Estimates live in the `Estimate` field of the [project board](https://github.com/orgs/BootNodeDev/projects/29), not in issue bodies. From 5e97dc1e7e4ba42bab5e3b5ce7ea818295174280 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:06:06 -0300 Subject: [PATCH 070/147] refactor: name event fields and helpers the same way The vault's request events name the account holder instead of controller, and Funded names the payer caller instead of from. These are topic fields, so the emitted topics and data do not change; only their names in the contract spec do. The vault's internal helpers, the token's co-sign check, the vault's error docs and the design doc use the same names. --- contracts/async-vault/src/deposit.rs | 56 ++++++------- contracts/async-vault/src/error.rs | 10 +-- contracts/async-vault/src/event.rs | 20 ++--- contracts/async-vault/src/redeem.rs | 79 +++++++++---------- contracts/async-vault/src/state.rs | 42 +++------- contracts/async-vault/src/test/delisted.rs | 6 +- contracts/async-vault/src/test/events.rs | 4 +- contracts/async-vault/src/test/multi_epoch.rs | 2 +- contracts/async-vault/src/test/settlement.rs | 2 +- contracts/async-vault/src/treasury.rs | 8 +- contracts/share-token/src/contract.rs | 6 +- docs/strata-product-and-architecture.md | 4 +- 12 files changed, 110 insertions(+), 129 deletions(-) diff --git a/contracts/async-vault/src/deposit.rs b/contracts/async-vault/src/deposit.rs index bc9da0a5..a2d6d685 100644 --- a/contracts/async-vault/src/deposit.rs +++ b/contracts/async-vault/src/deposit.rs @@ -14,8 +14,8 @@ use crate::state::{self, DepositRequest, EpochStatus}; use crate::treasury; use crate::wind_down; -fn refuse_unless_allowed_in(e: &Env, from: &Address) { - let standing = lock_policy::standing(e, from); +fn refuse_unless_allowed_in(e: &Env, holder: &Address) { + let standing = lock_policy::standing(e, holder); if lock_policy::decide(Exit::RequestDeposit, standing, wind_down::is_active(e)) != Decision::Proceed { @@ -36,20 +36,20 @@ fn refuse_if_exceeds_cap(e: &Env, amount: i128) { } } -pub(crate) fn request(e: &Env, from: &Address, amount: i128) -> u64 { +pub(crate) fn request(e: &Env, holder: &Address, amount: i128) -> u64 { wind_down::refuse_if_active(e); governance::refuse_if_setup_open(e); - from.require_auth(); + holder.require_auth(); if amount <= 0 { panic_with_error!(e, VaultError::InvalidAmount); } - refuse_unless_allowed_in(e, from); + refuse_unless_allowed_in(e, holder); refuse_if_exceeds_cap(e, amount); let epoch_id = state::current_epoch(e); - if state::get_deposit_request(e, epoch_id, from).is_some() { + if state::get_deposit_request(e, epoch_id, holder).is_some() { panic_with_error!(e, VaultError::RequestOutstanding); } @@ -64,7 +64,7 @@ pub(crate) fn request(e: &Env, from: &Address, amount: i128) -> u64 { state::set_deposit_request( e, epoch_id, - from, + holder, &DepositRequest { amount, claimed: false, @@ -75,10 +75,10 @@ pub(crate) fn request(e: &Env, from: &Address, amount: i128) -> u64 { settlement::on_deposit_requested(e, epoch_id); let asset = state::get_addr(e, &DataKey::Asset); - TokenClient::new(e, &asset).transfer(from, e.current_contract_address(), &amount); + TokenClient::new(e, &asset).transfer(holder, e.current_contract_address(), &amount); DepositRequested { - controller: from.clone(), + holder: holder.clone(), epoch: epoch_id, amount, } @@ -87,8 +87,8 @@ pub(crate) fn request(e: &Env, from: &Address, amount: i128) -> u64 { epoch_id } -pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 { - caller.require_auth(); +pub(crate) fn claim(e: &Env, holder: &Address, epoch_id: u64) -> i128 { + holder.require_auth(); let epoch = state::get_epoch(e, epoch_id) .unwrap_or_else(|| panic_with_error!(e, VaultError::EpochNotFound)); @@ -97,14 +97,14 @@ pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 { panic_with_error!(e, VaultError::EpochNotFulfilled); } - let mut request = state::get_deposit_request(e, epoch_id, caller) + let mut request = state::get_deposit_request(e, epoch_id, holder) .unwrap_or_else(|| panic_with_error!(e, VaultError::RequestNotFound)); if request.claimed { panic_with_error!(e, VaultError::AlreadyClaimed); } - let standing = lock_policy::standing(e, caller); + let standing = lock_policy::standing(e, holder); let decision = lock_policy::decide(Exit::ClaimDeposit, standing, wind_down::is_active(e)); match decision { Decision::Proceed | Decision::ConvertToExit | Decision::Surrender => {} @@ -123,25 +123,25 @@ pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 { if treasury::free_reserve(e) < request.amount { panic_with_error!(e, VaultError::ClaimNotCovered); } - refund_deposit(e, caller, epoch_id, request.amount); + refund_deposit(e, holder, epoch_id, request.amount); settlement::on_deposit_claimed(e, epoch_id, 0); return 0; } request.claimed = true; - state::set_deposit_request(e, epoch_id, caller, &request); + state::set_deposit_request(e, epoch_id, holder, &request); state::set_pending_mint_shares(e, state::reduce(e, state::pending_mint_shares(e), shares)); if decision != Decision::Proceed { - convert_to_exit(e, caller, epoch_id, shares, standing, decision); + convert_to_exit(e, holder, epoch_id, shares, standing, decision); return 0; } let share_token = state::get_addr(e, &DataKey::ShareToken); - RWATokenClient::new(e, &share_token).mint(caller, &shares, &e.current_contract_address()); + RWATokenClient::new(e, &share_token).mint(holder, &shares, &e.current_contract_address()); DepositClaimed { - controller: caller.clone(), + holder: holder.clone(), epoch: epoch_id, amount: request.amount, shares, @@ -178,7 +178,7 @@ fn convert_to_exit( }; DepositConvertedToExit { - controller: holder.clone(), + holder: holder.clone(), epoch: epoch_id, into_epoch, shares, @@ -189,26 +189,26 @@ fn convert_to_exit( /// Returns the settlement asset and clears the request. Used by cancellation and /// by a claim whose conversion floors to zero. -fn refund_deposit(e: &Env, controller: &Address, epoch_id: u64, amount: i128) { +fn refund_deposit(e: &Env, holder: &Address, epoch_id: u64, amount: i128) { let mut epoch = state::get_epoch(e, epoch_id) .unwrap_or_else(|| panic_with_error!(e, VaultError::EpochNotFound)); epoch.total_deposited = state::reduce(e, epoch.total_deposited, amount); state::set_epoch(e, epoch_id, &epoch); - state::remove_deposit_request(e, epoch_id, controller); + state::remove_deposit_request(e, epoch_id, holder); let asset = state::get_addr(e, &DataKey::Asset); - TokenClient::new(e, &asset).transfer(&e.current_contract_address(), controller, &amount); + TokenClient::new(e, &asset).transfer(&e.current_contract_address(), holder, &amount); DepositCancelled { - controller: controller.clone(), + holder: holder.clone(), epoch: epoch_id, amount, } .publish(e); } -pub(crate) fn cancel(e: &Env, from: &Address, epoch_id: u64) -> i128 { - from.require_auth(); +pub(crate) fn cancel(e: &Env, holder: &Address, epoch_id: u64) -> i128 { + holder.require_auth(); let epoch = state::get_epoch(e, epoch_id) .unwrap_or_else(|| panic_with_error!(e, VaultError::EpochNotFound)); @@ -225,10 +225,10 @@ pub(crate) fn cancel(e: &Env, from: &Address, epoch_id: u64) -> i128 { panic_with_error!(e, VaultError::PriceAvailable); } - let request = state::get_deposit_request(e, epoch_id, from) + let request = state::get_deposit_request(e, epoch_id, holder) .unwrap_or_else(|| panic_with_error!(e, VaultError::RequestNotFound)); - let standing = lock_policy::standing(e, from); + let standing = lock_policy::standing(e, holder); match lock_policy::decide(Exit::CancelDeposit, standing, wind_down::is_active(e)) { Decision::Proceed => {} Decision::Refuse | Decision::Hold | Decision::ConvertToExit | Decision::Surrender => { @@ -241,6 +241,6 @@ pub(crate) fn cancel(e: &Env, from: &Address, epoch_id: u64) -> i128 { state::reduce(e, state::cancellable_escrow(e), request.amount), ); settlement::on_deposit_cancelled(e, epoch_id); - refund_deposit(e, from, epoch_id, request.amount); + refund_deposit(e, holder, epoch_id, request.amount); request.amount } diff --git a/contracts/async-vault/src/error.rs b/contracts/async-vault/src/error.rs index 36faee2d..5c92a6d4 100644 --- a/contracts/async-vault/src/error.rs +++ b/contracts/async-vault/src/error.rs @@ -9,13 +9,13 @@ pub enum VaultError { /// The constructor was given the same address for two authorities that /// must be held separately. RolesNotDistinct = 6000, - /// The controller has no request in the given epoch. + /// The holder has no request in the given epoch. RequestNotFound = 6001, /// An amount or share quantity of zero or less was specified. InvalidAmount = 6007, - /// The controller already holds an unclaimed request in this epoch. A + /// The holder already has an unclaimed request in this epoch. A /// second request is rejected rather than added to the first, so that at - /// most one request per controller per epoch holds by construction. + /// most one request per holder per epoch holds by construction. RequestOutstanding = 6009, /// The epoch total would exceed `i128::MAX`. AmountTooLarge = 6014, @@ -50,7 +50,7 @@ pub enum VaultError { /// No custodian has been set, so capital has nowhere to go. CustodianNotSet = 6012, /// The epoch is priced, so the request can no longer be cancelled. The - /// controller claims instead. + /// holder claims instead. AlreadyPriced = 6039, /// The vault must always have an admin. Governance hands over in two steps /// rather than stepping down into nobody. @@ -142,7 +142,7 @@ pub enum VaultError { /// The proposed feed does not name this vault as its own, or its setup is /// still open. Its admin could then change its limits with no delay. FeedNotBound = 6072, - /// The controller may not hold shares (delisted, frozen or refused by the + /// The holder may not receive shares (delisted, frozen or refused by the /// compliance rules), so the escrowed shares stay with the vault and the /// request stays open. HolderNotEligible = 6073, diff --git a/contracts/async-vault/src/event.rs b/contracts/async-vault/src/event.rs index d825af74..b2ba60ac 100644 --- a/contracts/async-vault/src/event.rs +++ b/contracts/async-vault/src/event.rs @@ -5,7 +5,7 @@ use crate::governance::VaultChange; #[contractevent] pub struct DepositRequested { #[topic] - pub controller: Address, + pub holder: Address, pub epoch: u64, pub amount: i128, } @@ -21,7 +21,7 @@ pub struct EpochFulfilled { #[contractevent] pub struct DepositClaimed { #[topic] - pub controller: Address, + pub holder: Address, pub epoch: u64, pub amount: i128, pub shares: i128, @@ -30,7 +30,7 @@ pub struct DepositClaimed { #[contractevent] pub struct RedeemRequested { #[topic] - pub controller: Address, + pub holder: Address, pub epoch: u64, pub shares: i128, } @@ -38,7 +38,7 @@ pub struct RedeemRequested { #[contractevent] pub struct RedeemClaimed { #[topic] - pub controller: Address, + pub holder: Address, pub epoch: u64, pub shares: i128, pub assets: i128, @@ -74,7 +74,7 @@ pub struct Deployed { #[contractevent] pub struct Funded { #[topic] - pub from: Address, + pub caller: Address, pub assets: i128, pub net_deployed: i128, } @@ -82,7 +82,7 @@ pub struct Funded { #[contractevent] pub struct DepositCancelled { #[topic] - pub controller: Address, + pub holder: Address, pub epoch: u64, pub amount: i128, } @@ -90,7 +90,7 @@ pub struct DepositCancelled { #[contractevent] pub struct RedeemCancelled { #[topic] - pub controller: Address, + pub holder: Address, pub epoch: u64, pub shares: i128, } @@ -152,11 +152,11 @@ pub struct WindDownClaimed { pub assets: i128, } -/// An escrowed redemption burned into the controller's wind-down entitlement. +/// An escrowed redemption burned into the holder's wind-down entitlement. #[contractevent] pub struct RedemptionSurrendered { #[topic] - pub controller: Address, + pub holder: Address, pub epoch: u64, pub shares: i128, } @@ -165,7 +165,7 @@ pub struct RedemptionSurrendered { #[contractevent] pub struct DepositConvertedToExit { #[topic] - pub controller: Address, + pub holder: Address, pub epoch: u64, pub into_epoch: u64, pub shares: i128, diff --git a/contracts/async-vault/src/redeem.rs b/contracts/async-vault/src/redeem.rs index bf4ec6fb..005638cc 100644 --- a/contracts/async-vault/src/redeem.rs +++ b/contracts/async-vault/src/redeem.rs @@ -15,15 +15,15 @@ use crate::state::{self, EpochStatus, RedeemRequest}; use crate::treasury; use crate::wind_down; -pub(crate) fn request(e: &Env, from: &Address, shares: i128) -> u64 { +pub(crate) fn request(e: &Env, holder: &Address, shares: i128) -> u64 { wind_down::refuse_if_active(e); - from.require_auth(); + holder.require_auth(); if shares <= 0 { panic_with_error!(e, VaultError::InvalidAmount); } - let standing = lock_policy::standing(e, from); + let standing = lock_policy::standing(e, holder); match lock_policy::decide(Exit::RequestRedeem, standing, wind_down::is_active(e)) { Decision::Proceed => {} Decision::Refuse | Decision::Hold | Decision::ConvertToExit | Decision::Surrender => { @@ -32,9 +32,9 @@ pub(crate) fn request(e: &Env, from: &Address, shares: i128) -> u64 { } // A request within the balance but short of the free shares needs frozen // ones; beyond the balance entirely, the token's own check applies instead. - if shares > lock_policy::free_shares(e, from) { + if shares > lock_policy::free_shares(e, holder) { let held = - FungibleTokenClient::new(e, &state::get_addr(e, &DataKey::ShareToken)).balance(from); + FungibleTokenClient::new(e, &state::get_addr(e, &DataKey::ShareToken)).balance(holder); if shares <= held { panic_with_error!(e, VaultError::Frozen); } @@ -42,7 +42,7 @@ pub(crate) fn request(e: &Env, from: &Address, shares: i128) -> u64 { let epoch_id = state::current_epoch(e); - if state::get_redeem_request(e, epoch_id, from).is_some() { + if state::get_redeem_request(e, epoch_id, holder).is_some() { panic_with_error!(e, VaultError::RequestOutstanding); } @@ -57,7 +57,7 @@ pub(crate) fn request(e: &Env, from: &Address, shares: i128) -> u64 { state::set_redeem_request( e, epoch_id, - from, + holder, &RedeemRequest { shares, claimed: false, @@ -68,10 +68,10 @@ pub(crate) fn request(e: &Env, from: &Address, shares: i128) -> u64 { let vault = e.current_contract_address(); let share_token = state::get_addr(e, &DataKey::ShareToken); - RWATokenClient::new(e, &share_token).forced_transfer(from, &vault, &shares, &vault); + RWATokenClient::new(e, &share_token).forced_transfer(holder, &vault, &shares, &vault); RedeemRequested { - controller: from.clone(), + holder: holder.clone(), epoch: epoch_id, shares, } @@ -80,8 +80,8 @@ pub(crate) fn request(e: &Env, from: &Address, shares: i128) -> u64 { epoch_id } -pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 { - caller.require_auth(); +pub(crate) fn claim(e: &Env, holder: &Address, epoch_id: u64) -> i128 { + holder.require_auth(); let epoch = state::get_epoch(e, epoch_id) .unwrap_or_else(|| panic_with_error!(e, VaultError::EpochNotFound)); @@ -90,14 +90,14 @@ pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 { panic_with_error!(e, VaultError::EpochNotFulfilled); } - let mut request = state::get_redeem_request(e, epoch_id, caller) + let mut request = state::get_redeem_request(e, epoch_id, holder) .unwrap_or_else(|| panic_with_error!(e, VaultError::RequestNotFound)); if request.claimed { panic_with_error!(e, VaultError::AlreadyClaimed); } - let standing = lock_policy::standing(e, caller); + let standing = lock_policy::standing(e, holder); match lock_policy::decide(Exit::ClaimRedeem, standing, wind_down::is_active(e)) { Decision::Proceed => {} Decision::Refuse | Decision::Hold | Decision::ConvertToExit | Decision::Surrender => { @@ -115,7 +115,7 @@ pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 { // In a wind-down they are burned below instead: its snapshot left them out. if assets == 0 && !wind_down::is_active(e) { - return_shares(e, caller, epoch_id, request.shares); + return_shares(e, holder, epoch_id, request.shares); settlement::on_redeem_claimed(e, epoch_id, 0); return 0; } @@ -130,11 +130,11 @@ pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 { let asset = state::get_addr(e, &DataKey::Asset); request.claimed = true; - state::set_redeem_request(e, epoch_id, caller, &request); + state::set_redeem_request(e, epoch_id, holder, &request); state::set_committed(e, state::reduce(e, state::committed(e), assets)); if assets > 0 { - TokenClient::new(e, &asset).transfer(&vault, caller, &assets); + TokenClient::new(e, &asset).transfer(&vault, holder, &assets); } RWATokenClient::new(e, &state::get_addr(e, &DataKey::ShareToken)).burn( &vault, @@ -143,7 +143,7 @@ pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 { ); RedeemClaimed { - controller: caller.clone(), + holder: holder.clone(), epoch: epoch_id, shares: request.shares, assets, @@ -155,7 +155,7 @@ pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 { } /// Queues shares the vault already holds, adding to any redemption open in the epoch. -pub(crate) fn queue_escrowed(e: &Env, controller: &Address, shares: i128) -> u64 { +pub(crate) fn queue_escrowed(e: &Env, holder: &Address, shares: i128) -> u64 { let epoch_id = state::current_epoch(e); let mut epoch = state::get_epoch(e, epoch_id) .unwrap_or_else(|| panic_with_error!(e, VaultError::EpochNotFound)); @@ -164,7 +164,7 @@ pub(crate) fn queue_escrowed(e: &Env, controller: &Address, shares: i128) -> u64 .checked_add(shares) .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)); - let request = match state::get_redeem_request(e, epoch_id, controller) { + let request = match state::get_redeem_request(e, epoch_id, holder) { Some(mut open) => { open.shares = open .shares @@ -180,23 +180,23 @@ pub(crate) fn queue_escrowed(e: &Env, controller: &Address, shares: i128) -> u64 } } }; - state::set_redeem_request(e, epoch_id, controller, &request); + state::set_redeem_request(e, epoch_id, holder, &request); state::set_epoch(e, epoch_id, &epoch); epoch_id } -/// Moves escrowed shares back to the controller and clears the request. -fn return_shares(e: &Env, controller: &Address, epoch_id: u64, shares: i128) { +/// Moves escrowed shares back to the holder and clears the request. +fn return_shares(e: &Env, holder: &Address, epoch_id: u64, shares: i128) { let mut epoch = state::get_epoch(e, epoch_id) .unwrap_or_else(|| panic_with_error!(e, VaultError::EpochNotFound)); epoch.total_shares_redeeming = state::reduce(e, epoch.total_shares_redeeming, shares); state::set_epoch(e, epoch_id, &epoch); - state::remove_redeem_request(e, epoch_id, controller); + state::remove_redeem_request(e, epoch_id, holder); - deliver(e, controller, shares); + deliver(e, holder, shares); RedeemCancelled { - controller: controller.clone(), + holder: holder.clone(), epoch: epoch_id, shares, } @@ -204,21 +204,20 @@ fn return_shares(e: &Env, controller: &Address, epoch_id: u64, shares: i128) { } /// Checks the receiver, then moves as manager, so the vault's listing and token pause never apply. -fn deliver(e: &Env, controller: &Address, shares: i128) { +fn deliver(e: &Env, holder: &Address, shares: i128) { let vault = e.current_contract_address(); let token = state::get_addr(e, &DataKey::ShareToken); let share = RWATokenClient::new(e, &token); - if lock_policy::standing(e, controller) != Standing::Listed - || !RulesClient::new(e, &share.compliance()) - .can_transfer(&vault, controller, &shares, &token) + if lock_policy::standing(e, holder) != Standing::Listed + || !RulesClient::new(e, &share.compliance()).can_transfer(&vault, holder, &shares, &token) { panic_with_error!(e, VaultError::HolderNotEligible); } - share.forced_transfer(&vault, controller, &shares, &vault); + share.forced_transfer(&vault, holder, &shares, &vault); } -pub(crate) fn cancel(e: &Env, from: &Address, epoch_id: u64) -> i128 { - from.require_auth(); +pub(crate) fn cancel(e: &Env, holder: &Address, epoch_id: u64) -> i128 { + holder.require_auth(); let epoch = state::get_epoch(e, epoch_id) .unwrap_or_else(|| panic_with_error!(e, VaultError::EpochNotFound)); @@ -237,13 +236,13 @@ pub(crate) fn cancel(e: &Env, from: &Address, epoch_id: u64) -> i128 { panic_with_error!(e, VaultError::PriceAvailable); } - let request = state::get_redeem_request(e, epoch_id, from) + let request = state::get_redeem_request(e, epoch_id, holder) .unwrap_or_else(|| panic_with_error!(e, VaultError::RequestNotFound)); - let standing = lock_policy::standing(e, from); + let standing = lock_policy::standing(e, holder); match lock_policy::decide(Exit::CancelRedeem, standing, winding_down) { - Decision::Proceed => return_shares(e, from, epoch_id, request.shares), - Decision::Surrender => surrender_escrow(e, from, epoch_id, request.shares), + Decision::Proceed => return_shares(e, holder, epoch_id, request.shares), + Decision::Surrender => surrender_escrow(e, holder, epoch_id, request.shares), Decision::Refuse | Decision::Hold | Decision::ConvertToExit => { panic_with_error!(e, lock_policy::refused_with(standing)) } @@ -253,19 +252,19 @@ pub(crate) fn cancel(e: &Env, from: &Address, epoch_id: u64) -> i128 { } /// Burns the escrow into the entitlement, so no token pause or allowlist place can hold it. -fn surrender_escrow(e: &Env, controller: &Address, epoch_id: u64, shares: i128) { +fn surrender_escrow(e: &Env, holder: &Address, epoch_id: u64, shares: i128) { let mut epoch = state::get_epoch(e, epoch_id) .unwrap_or_else(|| panic_with_error!(e, VaultError::EpochNotFound)); epoch.total_shares_redeeming = state::reduce(e, epoch.total_shares_redeeming, shares); state::set_epoch(e, epoch_id, &epoch); - state::remove_redeem_request(e, epoch_id, controller); + state::remove_redeem_request(e, epoch_id, holder); let vault = e.current_contract_address(); RWATokenClient::new(e, &state::get_addr(e, &DataKey::ShareToken)).burn(&vault, &shares, &vault); - wind_down::credit(e, controller, shares); + wind_down::credit(e, holder, shares); RedemptionSurrendered { - controller: controller.clone(), + holder: holder.clone(), epoch: epoch_id, shares, } diff --git a/contracts/async-vault/src/state.rs b/contracts/async-vault/src/state.rs index 71195975..f10fd294 100644 --- a/contracts/async-vault/src/state.rs +++ b/contracts/async-vault/src/state.rs @@ -99,38 +99,20 @@ pub(crate) fn get_epoch(e: &Env, id: u64) -> Option { strata_storage::get_persistent(e, &DataKey::Epoch(id)) } -pub(crate) fn set_deposit_request( - e: &Env, - epoch: u64, - controller: &Address, - request: &DepositRequest, -) { - strata_storage::set_persistent(e, &DataKey::UserDeposit(epoch, controller.clone()), request); +pub(crate) fn set_deposit_request(e: &Env, epoch: u64, holder: &Address, request: &DepositRequest) { + strata_storage::set_persistent(e, &DataKey::UserDeposit(epoch, holder.clone()), request); } -pub(crate) fn get_deposit_request( - e: &Env, - epoch: u64, - controller: &Address, -) -> Option { - strata_storage::get_persistent(e, &DataKey::UserDeposit(epoch, controller.clone())) +pub(crate) fn get_deposit_request(e: &Env, epoch: u64, holder: &Address) -> Option { + strata_storage::get_persistent(e, &DataKey::UserDeposit(epoch, holder.clone())) } -pub(crate) fn set_redeem_request( - e: &Env, - epoch: u64, - controller: &Address, - request: &RedeemRequest, -) { - strata_storage::set_persistent(e, &DataKey::UserRedeem(epoch, controller.clone()), request); +pub(crate) fn set_redeem_request(e: &Env, epoch: u64, holder: &Address, request: &RedeemRequest) { + strata_storage::set_persistent(e, &DataKey::UserRedeem(epoch, holder.clone()), request); } -pub(crate) fn get_redeem_request( - e: &Env, - epoch: u64, - controller: &Address, -) -> Option { - strata_storage::get_persistent(e, &DataKey::UserRedeem(epoch, controller.clone())) +pub(crate) fn get_redeem_request(e: &Env, epoch: u64, holder: &Address) -> Option { + strata_storage::get_persistent(e, &DataKey::UserRedeem(epoch, holder.clone())) } /// Cash owed to holders whose exit is priced but not yet claimed. @@ -152,16 +134,16 @@ pub(crate) fn cancellable_escrow(e: &Env) -> i128 { strata_storage::get_instance(e, &DataKey::CancellableEscrow).unwrap_or(0) } -pub(crate) fn remove_deposit_request(e: &Env, epoch: u64, controller: &Address) { +pub(crate) fn remove_deposit_request(e: &Env, epoch: u64, holder: &Address) { e.storage() .persistent() - .remove(&DataKey::UserDeposit(epoch, controller.clone())); + .remove(&DataKey::UserDeposit(epoch, holder.clone())); } -pub(crate) fn remove_redeem_request(e: &Env, epoch: u64, controller: &Address) { +pub(crate) fn remove_redeem_request(e: &Env, epoch: u64, holder: &Address) { e.storage() .persistent() - .remove(&DataKey::UserRedeem(epoch, controller.clone())); + .remove(&DataKey::UserRedeem(epoch, holder.clone())); } pub(crate) fn set_pending_mint_shares(e: &Env, shares: i128) { diff --git a/contracts/async-vault/src/test/delisted.rs b/contracts/async-vault/src/test/delisted.rs index bb7430a2..cead24d1 100644 --- a/contracts/async-vault/src/test/delisted.rs +++ b/contracts/async-vault/src/test/delisted.rs @@ -25,7 +25,7 @@ fn a_delisted_holders_priced_deposit_joins_their_redemption_in_the_open_epoch() f.e.events().all().filter_by_contract(&f.vault.address), [ DepositConvertedToExit { - controller: investor.clone(), + holder: investor.clone(), epoch, into_epoch: open, shares: 200, @@ -116,7 +116,7 @@ fn in_a_wind_down_the_shares_are_credited_before_the_first_round() { f.e.events().all().filter_by_contract(&f.vault.address), [ DepositConvertedToExit { - controller: investor.clone(), + holder: investor.clone(), epoch, into_epoch: 0, shares: 200, @@ -204,7 +204,7 @@ fn a_conversion_caught_by_a_wind_down_is_paid_from_the_rounds() { assert_eq!( f.e.events().all().filter_by_contract(&f.vault.address), [RedemptionSurrendered { - controller: investor.clone(), + holder: investor.clone(), epoch: open, shares: 200, } diff --git a/contracts/async-vault/src/test/events.rs b/contracts/async-vault/src/test/events.rs index 39fdb963..8d2988a7 100644 --- a/contracts/async-vault/src/test/events.rs +++ b/contracts/async-vault/src/test/events.rs @@ -13,7 +13,7 @@ fn requesting_a_deposit_publishes_the_request() { assert_eq!( f.e.events().all().filter_by_contract(&f.vault.address), [DepositRequested { - controller: user, + holder: user, epoch: 1, amount: 400, } @@ -74,7 +74,7 @@ fn claiming_the_last_deposit_publishes_the_shares_paid_and_the_settlement() { f.e.events().all().filter_by_contract(&f.vault.address), [ DepositClaimed { - controller: user, + holder: user, epoch: 1, amount: 400, shares: 200, diff --git a/contracts/async-vault/src/test/multi_epoch.rs b/contracts/async-vault/src/test/multi_epoch.rs index aab0a04e..0e19b678 100644 --- a/contracts/async-vault/src/test/multi_epoch.rs +++ b/contracts/async-vault/src/test/multi_epoch.rs @@ -78,7 +78,7 @@ fn the_rounding_residue_stays_in_the_reserve_once_the_epoch_settles() { assert_eq!(f.vault.free_reserve(), held - 462); } -/// One controller may hold a live redemption in two epochs at once, because the +/// One holder may hold a live redemption in two epochs at once, because the /// outstanding-request check is keyed per epoch. Claims are taken newest first /// here, which today is allowed. #[test] diff --git a/contracts/async-vault/src/test/settlement.rs b/contracts/async-vault/src/test/settlement.rs index aded2f31..9976020f 100644 --- a/contracts/async-vault/src/test/settlement.rs +++ b/contracts/async-vault/src/test/settlement.rs @@ -49,7 +49,7 @@ fn the_last_redemption_claim_clears_the_cash_remainder_and_says_so_once() { f.e.events().all().filter_by_contract(&f.vault.address), [ RedeemClaimed { - controller: b, + holder: b, epoch, shares: 103, assets: 154, diff --git a/contracts/async-vault/src/treasury.rs b/contracts/async-vault/src/treasury.rs index 4958a1fe..e8d0c36c 100644 --- a/contracts/async-vault/src/treasury.rs +++ b/contracts/async-vault/src/treasury.rs @@ -73,8 +73,8 @@ pub(crate) fn deploy(e: &Env, assets: i128) -> i128 { net_deployed } -pub(crate) fn fund(e: &Env, from: &Address, assets: i128) -> i128 { - from.require_auth(); +pub(crate) fn fund(e: &Env, caller: &Address, assets: i128) -> i128 { + caller.require_auth(); if assets <= 0 { panic_with_error!(e, VaultError::InvalidAmount); @@ -86,10 +86,10 @@ pub(crate) fn fund(e: &Env, from: &Address, assets: i128) -> i128 { state::set_net_deployed(e, net_deployed); let asset = state::get_addr(e, &DataKey::Asset); - TokenClient::new(e, &asset).transfer(from, e.current_contract_address(), &assets); + TokenClient::new(e, &asset).transfer(caller, e.current_contract_address(), &assets); Funded { - from: from.clone(), + caller: caller.clone(), assets, net_deployed, } diff --git a/contracts/share-token/src/contract.rs b/contracts/share-token/src/contract.rs index 17309333..bbff2999 100644 --- a/contracts/share-token/src/contract.rs +++ b/contracts/share-token/src/contract.rs @@ -19,10 +19,10 @@ use strata_bindings::VaultBinding; /// Longest order reference a legal transfer accepts, in bytes. pub const MAX_REFERENCE_LEN: u32 = 128; -/// The co-signing admin must be a different account from the operator. -fn require_admin_co_sign(e: &Env, operator: &Address) { +/// The co-signing admin must be a different account from the caller. +fn require_admin_co_sign(e: &Env, caller: &Address) { let admin = access_control::enforce_admin_auth(e); - if admin == *operator { + if admin == *caller { panic_with_error!(e, ShareTokenError::CoSignRequired); } } diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index f1b7f4e4..abcbc3cb 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -313,14 +313,14 @@ batch boundary, though not the price it receives. #### Subscription -- Request: moves the deposit asset into escrow; at most one active request per controller. The vault first asks the share token whether the address is frozen and asks the token's identity verifier, and refuses a frozen address or one that could not receive the shares, before any cash moves; the token checks again when it mints at claim time. +- Request: moves the deposit asset into escrow; at most one active request per holder. The vault first asks the share token whether the address is frozen and asks the token's identity verifier, and refuses a frozen address or one that could not receive the shares, before any cash moves; the token checks again when it mints at claim time. - Pricing: the escrow leaves the cancellable bucket, the share quantity is set at the epoch's price, and the economic supply liability is recorded. To avoid unbounded loops that exceed Soroban transaction resource limits, physical share minting occurs lazily when each investor executes `claim_deposit`. - Cancellation: atomic, and returns the escrowed asset in full. Open while the epoch is open. Once sealed, a readable price closes it, even while the notice still runs: an investor who can see the price cannot decline it. If the vault can no longer price the epoch — the report expires, the NAV is paused, or the vault is paused — it opens again, so escrow is never stuck behind a stalled feed. The epoch is then priced at the latest valid report at fulfil time. Cancellation also remains open during an active wind-down, allowing pending depositors to recover their escrowed assets when pricing is closed. A frozen depositor's cancellation is held until the freeze is lifted, in a wind-down too. - Share claim: verifies the receiver and delivers the shares via `mint`. A receiver off the allowlist gets no shares: they join the holder's redemption in the open epoch and are paid in cash at that epoch's price; in a wind-down they join the holder's wind-down entitlement. A frozen receiver is held until the freeze is lifted. No nominal refund exists after pricing. #### Redemption -- Request: moves shares into escrow; at most one active request per controller per epoch. A frozen holder's request is held until the freeze is lifted; a partial freeze caps it to the free shares. +- Request: moves shares into escrow; at most one active request per holder per epoch. A frozen holder's request is held until the freeze is lifted; a partial freeze caps it to the free shares. - Cancellation: atomic, and returns the escrowed shares under the same rules as subscription cancellation; stays open during an active wind-down, where the escrow joins the holder's wind-down entitlement instead of coming back as shares. A frozen holder's cancellation is held until the freeze is lifted. - Pricing: the fixed cash liability enters committed at the epoch's price. Escrowed shares remain in the vault contract and are burned lazily as each claimant executes `claim_redeem`, preserving constant-bounded resource execution per transaction. Priced claims are never re-priced. - Coverage: a priced claim is payable when the liquid reserve covers that claim's own amount, in any order. An earlier unpaid claim never blocks a later one that is already covered. The gap between committed and liquid reserve is the uncovered amount treasury must top up. From c5f4448c9259471cf525d98531a288409027757d Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:03:17 -0300 Subject: [PATCH 071/147] refactor: check the vault's remaining totals --- contracts/async-vault/src/deposit.rs | 5 ++- contracts/async-vault/src/lib.rs | 2 +- contracts/async-vault/src/settlement.rs | 19 ++++++++--- contracts/async-vault/src/test/deposit.rs | 14 ++++++++ contracts/async-vault/src/test/settlement.rs | 29 ++++++++++++++++ contracts/async-vault/src/test/supply.rs | 35 ++++++++++++++++++++ contracts/async-vault/src/test/treasury.rs | 24 ++++++++++++++ contracts/async-vault/src/test/wind_down.rs | 14 ++++++++ contracts/async-vault/src/treasury.rs | 6 +++- contracts/async-vault/src/wind_down.rs | 5 ++- 10 files changed, 143 insertions(+), 10 deletions(-) diff --git a/contracts/async-vault/src/deposit.rs b/contracts/async-vault/src/deposit.rs index a2d6d685..4f01f95b 100644 --- a/contracts/async-vault/src/deposit.rs +++ b/contracts/async-vault/src/deposit.rs @@ -71,7 +71,10 @@ pub(crate) fn request(e: &Env, holder: &Address, amount: i128) -> u64 { }, ); state::set_epoch(e, epoch_id, &epoch); - state::set_cancellable_escrow(e, state::cancellable_escrow(e) + amount); + let escrow = state::cancellable_escrow(e) + .checked_add(amount) + .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)); + state::set_cancellable_escrow(e, escrow); settlement::on_deposit_requested(e, epoch_id); let asset = state::get_addr(e, &DataKey::Asset); diff --git a/contracts/async-vault/src/lib.rs b/contracts/async-vault/src/lib.rs index 895e84c0..f334244a 100644 --- a/contracts/async-vault/src/lib.rs +++ b/contracts/async-vault/src/lib.rs @@ -172,7 +172,7 @@ impl AsyncVault { let client = FungibleTokenClient::new(e, &share_token); let total_supply = client.total_supply(); let vault_escrow = client.balance(&e.current_contract_address()); - let circulating = total_supply.saturating_sub(vault_escrow); + let circulating = state::reduce(e, total_supply, vault_escrow); circulating .checked_add(state::pending_mint_shares(e)) .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)) diff --git a/contracts/async-vault/src/settlement.rs b/contracts/async-vault/src/settlement.rs index 24ab22cb..9db3d948 100644 --- a/contracts/async-vault/src/settlement.rs +++ b/contracts/async-vault/src/settlement.rs @@ -33,14 +33,25 @@ fn save(e: &Env, epoch: u64, s: &EpochSettlement) { strata_storage::set_persistent(e, &DataKey::Settlement(epoch), s); } +fn open_one(e: &Env, open: u32) -> u32 { + open.checked_add(1) + .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)) +} + fn close_one(e: &Env, open: u32) -> u32 { open.checked_sub(1) .unwrap_or_else(|| panic_with_error!(e, VaultError::AccountingMismatch)) } +fn add(e: &Env, total: i128, amount: i128) -> i128 { + total + .checked_add(amount) + .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)) +} + pub(crate) fn on_deposit_requested(e: &Env, epoch: u64) { let mut s = load(e, epoch); - s.deposits_open += 1; + s.deposits_open = open_one(e, s.deposits_open); save(e, epoch, &s); } @@ -52,7 +63,7 @@ pub(crate) fn on_deposit_cancelled(e: &Env, epoch: u64) { pub(crate) fn on_redeem_requested(e: &Env, epoch: u64) { let mut s = load(e, epoch); - s.redeems_open += 1; + s.redeems_open = open_one(e, s.redeems_open); save(e, epoch, &s); } @@ -75,7 +86,7 @@ pub(crate) fn on_priced(e: &Env, epoch: u64, shares_promised: i128, assets_promi pub(crate) fn on_deposit_claimed(e: &Env, epoch: u64, shares: i128) { let mut s = load(e, epoch); s.deposits_open = close_one(e, s.deposits_open); - s.shares_settled += shares; + s.shares_settled = add(e, s.shares_settled, shares); reconcile_if_settled(e, epoch, &mut s); save(e, epoch, &s); } @@ -84,7 +95,7 @@ pub(crate) fn on_deposit_claimed(e: &Env, epoch: u64, shares: i128) { pub(crate) fn on_redeem_claimed(e: &Env, epoch: u64, assets: i128) { let mut s = load(e, epoch); s.redeems_open = close_one(e, s.redeems_open); - s.assets_settled += assets; + s.assets_settled = add(e, s.assets_settled, assets); reconcile_if_settled(e, epoch, &mut s); save(e, epoch, &s); } diff --git a/contracts/async-vault/src/test/deposit.rs b/contracts/async-vault/src/test/deposit.rs index af1351cf..e9123955 100644 --- a/contracts/async-vault/src/test/deposit.rs +++ b/contracts/async-vault/src/test/deposit.rs @@ -230,3 +230,17 @@ fn deposits_after_fulfilling_land_in_the_new_epoch() { ); assert_eq!(f.balance(&f.vault.address), 700); } + +#[test] +fn escrow_that_would_overflow_is_refused_by_name() { + let f = setup(); + let investor = f.investor(1); + f.e.as_contract(&f.vault.address, || { + crate::state::set_cancellable_escrow(&f.e, i128::MAX) + }); + + refused( + f.vault.try_request_deposit(&investor, &1), + VaultError::AmountTooLarge, + ); +} diff --git a/contracts/async-vault/src/test/settlement.rs b/contracts/async-vault/src/test/settlement.rs index 9976020f..9dfd4288 100644 --- a/contracts/async-vault/src/test/settlement.rs +++ b/contracts/async-vault/src/test/settlement.rs @@ -153,6 +153,35 @@ fn a_total_that_would_go_below_zero_fails_loudly() { f.e.as_contract(&f.vault.address, || crate::state::reduce(&f.e, 1, 2)); } +#[test] +fn a_settlement_total_that_would_overflow_fails_by_name() { + let f = setup(); + let epoch = f.vault.current_epoch(); + let full = crate::EpochSettlement { + deposits_open: u32::MAX, + redeems_open: u32::MAX, + shares_settled: i128::MAX, + assets_settled: i128::MAX, + ..Default::default() + }; + f.e.as_contract(&f.vault.address, || { + strata_storage::set_persistent(&f.e, &crate::keys::DataKey::Settlement(epoch), &full) + }); + + let steps: [&dyn Fn(); 4] = [ + &|| crate::settlement::on_deposit_requested(&f.e, epoch), + &|| crate::settlement::on_redeem_requested(&f.e, epoch), + &|| crate::settlement::on_deposit_claimed(&f.e, epoch, 1), + &|| crate::settlement::on_redeem_claimed(&f.e, epoch, 1), + ]; + for step in steps { + assert_eq!( + f.e.try_as_contract::<(), VaultError>(&f.vault.address, step), + Err(Ok(VaultError::AmountTooLarge)) + ); + } +} + /// A request that can never settle holds its epoch open, so the remainder /// stays in the totals. Here the redemption pays nothing and the holder, since /// delisted, cannot take the shares back. diff --git a/contracts/async-vault/src/test/supply.rs b/contracts/async-vault/src/test/supply.rs index 01b2a1be..dbe46bbc 100644 --- a/contracts/async-vault/src/test/supply.rs +++ b/contracts/async-vault/src/test/supply.rs @@ -1,3 +1,5 @@ +use soroban_sdk::{contract, contractimpl}; + use super::*; #[test] @@ -94,3 +96,36 @@ fn economic_supply_combines_circulating_and_pending_mint_correctly() { assert_eq!(f.vault.pending_mint_shares(), 0); assert_eq!(f.vault.total_economic_supply(), 360); } + +/// A share token whose books disagree: the vault holds more than exists. +#[contract] +struct SkewedShares; + +#[contractimpl] +impl SkewedShares { + pub fn total_supply(_e: Env) -> i128 { + 1 + } + + pub fn balance(_e: Env, _id: Address) -> i128 { + 2 + } +} + +#[test] +fn a_vault_holding_more_shares_than_exist_fails_loudly() { + let e = Env::default(); + let asset = e + .register_stellar_asset_contract_v2(Address::generate(&e)) + .address(); + let shares = e.register(SkewedShares, ()); + let vault = AsyncVaultClient::new( + &e, + &e.register(AsyncVault, (&asset, &shares, &asset, distinct_roles(&e))), + ); + + refused( + vault.try_total_economic_supply(), + VaultError::AccountingMismatch, + ); +} diff --git a/contracts/async-vault/src/test/treasury.rs b/contracts/async-vault/src/test/treasury.rs index 4fd928b8..a3823b2d 100644 --- a/contracts/async-vault/src/test/treasury.rs +++ b/contracts/async-vault/src/test/treasury.rs @@ -132,3 +132,27 @@ fn deploying_a_non_positive_amount_is_rejected() { assert!(f.vault.try_deploy_to_custodian(&f.treasury, &0).is_err()); assert!(f.vault.try_deploy_to_custodian(&f.treasury, &-1).is_err()); } + +/// Exits priced after a gain can owe more than the capital the vault counts, +/// so the result may be negative; only leaving the type's range is refused. +#[test] +fn deposited_capital_fails_by_name_instead_of_overflowing() { + let f = setup(); + let investor = f.investor(1); + f.vault.request_deposit(&investor, &1); + let capital = |net_deployed: i128, committed: i128| { + f.e.try_as_contract::(&f.vault.address, || { + crate::state::set_net_deployed(&f.e, net_deployed); + crate::state::set_committed(&f.e, committed); + crate::treasury::deposited_capital(&f.e) + }) + }; + + assert_eq!(capital(10, 3), Ok(8)); + assert_eq!(capital(0, 5), Ok(-4)); + assert_eq!(capital(i128::MAX, 0), Err(Ok(VaultError::AmountTooLarge))); + assert_eq!( + capital(i128::MIN, 2), + Err(Ok(VaultError::AccountingMismatch)) + ); +} diff --git a/contracts/async-vault/src/test/wind_down.rs b/contracts/async-vault/src/test/wind_down.rs index 811f8bb8..dda3f1ab 100644 --- a/contracts/async-vault/src/test/wind_down.rs +++ b/contracts/async-vault/src/test/wind_down.rs @@ -853,3 +853,17 @@ fn a_partial_freeze_keeps_frozen_shares_out_of_a_wind_down_claim() { let rest = f.vault.claim_wind_down(&holder); assert_eq!(rest, 4 * first); } + +#[test] +fn a_snapshot_with_more_shares_to_burn_than_exist_fails_loudly() { + let f = setup(); + wind_down_now(&f); + f.e.as_contract(&f.vault.address, || { + crate::state::set_pending_burn_shares(&f.e, 1) + }); + + refused( + f.vault.try_finalize_wind_down_round(), + VaultError::AccountingMismatch, + ); +} diff --git a/contracts/async-vault/src/treasury.rs b/contracts/async-vault/src/treasury.rs index e8d0c36c..e1846150 100644 --- a/contracts/async-vault/src/treasury.rs +++ b/contracts/async-vault/src/treasury.rs @@ -39,7 +39,11 @@ pub(crate) fn uncovered(e: &Env) -> i128 { /// Capital in the vault and deployed to the custodian, less liabilities already /// committed to priced exits. Used to evaluate headroom against the deposit cap. pub(crate) fn deposited_capital(e: &Env) -> i128 { - (held(e) + state::net_deployed(e)).saturating_sub(state::committed(e)) + held(e) + .checked_add(state::net_deployed(e)) + .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)) + .checked_sub(state::committed(e)) + .unwrap_or_else(|| panic_with_error!(e, VaultError::AccountingMismatch)) } pub(crate) fn deploy(e: &Env, assets: i128) -> i128 { diff --git a/contracts/async-vault/src/wind_down.rs b/contracts/async-vault/src/wind_down.rs index 6667abe9..f7642ad9 100644 --- a/contracts/async-vault/src/wind_down.rs +++ b/contracts/async-vault/src/wind_down.rs @@ -175,9 +175,8 @@ fn credited_before_snapshot(e: &Env) -> i128 { /// credited straight to a holder's entitlement. fn snapshot_supply(e: &Env) -> i128 { let share_token = state::get_addr(e, &DataKey::ShareToken); - FungibleTokenClient::new(e, &share_token) - .total_supply() - .saturating_sub(state::pending_burn_shares(e)) + let total_supply = FungibleTokenClient::new(e, &share_token).total_supply(); + state::reduce(e, total_supply, state::pending_burn_shares(e)) .checked_add(state::pending_mint_shares(e)) .and_then(|supply| supply.checked_add(credited_before_snapshot(e))) .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)) From 76b221f7c74a8c9fe2f6f16b2f052611c35cd60f Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:05:51 -0300 Subject: [PATCH 072/147] refactor: name the vault's preconditions as guards --- contracts/async-vault/src/deposit.rs | 99 +-- contracts/async-vault/src/epoch.rs | 16 +- contracts/async-vault/src/governance.rs | 99 +-- contracts/async-vault/src/guards.rs | 362 +++++++++ contracts/async-vault/src/lib.rs | 36 +- contracts/async-vault/src/redeem.rs | 104 +-- contracts/async-vault/src/test/guards.rs | 711 ++++++++++++++++++ contracts/async-vault/src/test/mod.rs | 1 + .../async-vault/src/test/share_returns.rs | 2 +- contracts/async-vault/src/treasury.rs | 18 +- contracts/async-vault/src/upgrade.rs | 17 +- contracts/async-vault/src/wind_down.rs | 89 +-- 12 files changed, 1158 insertions(+), 396 deletions(-) create mode 100644 contracts/async-vault/src/guards.rs create mode 100644 contracts/async-vault/src/test/guards.rs diff --git a/contracts/async-vault/src/deposit.rs b/contracts/async-vault/src/deposit.rs index 4f01f95b..15cc8643 100644 --- a/contracts/async-vault/src/deposit.rs +++ b/contracts/async-vault/src/deposit.rs @@ -1,57 +1,26 @@ use soroban_sdk::{panic_with_error, token::TokenClient, Address, Env}; use stellar_tokens::rwa::RWATokenClient; -use crate::epoch; use crate::error::VaultError; use crate::event::{DepositCancelled, DepositClaimed, DepositConvertedToExit, DepositRequested}; -use crate::governance; +use crate::guards; use crate::keys::DataKey; use crate::lock_policy::{self, Decision, Exit, Standing}; use crate::pricing::{Pricing, PricingScheme}; use crate::redeem; use crate::settlement; -use crate::state::{self, DepositRequest, EpochStatus}; -use crate::treasury; +use crate::state::{self, DepositRequest}; use crate::wind_down; -fn refuse_unless_allowed_in(e: &Env, holder: &Address) { - let standing = lock_policy::standing(e, holder); - if lock_policy::decide(Exit::RequestDeposit, standing, wind_down::is_active(e)) - != Decision::Proceed - { - panic_with_error!(e, lock_policy::refused_with(standing)); - } -} - -fn refuse_if_exceeds_cap(e: &Env, amount: i128) { - let Some(cap) = state::deposit_cap(e) else { - return; - }; - let current = treasury::deposited_capital(e); - let new_total = current - .checked_add(amount) - .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)); - if new_total > cap { - panic_with_error!(e, VaultError::DepositCapExceeded); - } -} - pub(crate) fn request(e: &Env, holder: &Address, amount: i128) -> u64 { - wind_down::refuse_if_active(e); - governance::refuse_if_setup_open(e); + guards::not_winding_down(e); + guards::setup_finished(e); holder.require_auth(); - - if amount <= 0 { - panic_with_error!(e, VaultError::InvalidAmount); - } - refuse_unless_allowed_in(e, holder); - refuse_if_exceeds_cap(e, amount); - + guards::positive_amount(e, amount); + guards::allowed_to(e, Exit::RequestDeposit, holder); + guards::within_cap(e, amount); let epoch_id = state::current_epoch(e); - - if state::get_deposit_request(e, epoch_id, holder).is_some() { - panic_with_error!(e, VaultError::RequestOutstanding); - } + guards::no_open_deposit(e, epoch_id, holder); let mut epoch = state::get_epoch(e, epoch_id) .unwrap_or_else(|| panic_with_error!(e, VaultError::EpochNotFound)); @@ -92,40 +61,21 @@ pub(crate) fn request(e: &Env, holder: &Address, amount: i128) -> u64 { pub(crate) fn claim(e: &Env, holder: &Address, epoch_id: u64) -> i128 { holder.require_auth(); - let epoch = state::get_epoch(e, epoch_id) .unwrap_or_else(|| panic_with_error!(e, VaultError::EpochNotFound)); - - if epoch.status != EpochStatus::Fulfilled { - panic_with_error!(e, VaultError::EpochNotFulfilled); - } - + guards::epoch_fulfilled(e, &epoch); let mut request = state::get_deposit_request(e, epoch_id, holder) .unwrap_or_else(|| panic_with_error!(e, VaultError::RequestNotFound)); - - if request.claimed { - panic_with_error!(e, VaultError::AlreadyClaimed); - } - + guards::not_claimed(e, request.claimed); let standing = lock_policy::standing(e, holder); let decision = lock_policy::decide(Exit::ClaimDeposit, standing, wind_down::is_active(e)); - match decision { - Decision::Proceed | Decision::ConvertToExit | Decision::Surrender => {} - Decision::Refuse | Decision::Hold => { - panic_with_error!(e, lock_policy::refused_with(standing)) - } - } + guards::not_refused(e, decision, standing); let shares = Pricing::deposit_shares(e, request.amount, epoch.share_price) .unwrap_or_else(|err| panic_with_error!(e, err)); if shares == 0 { - // Pricing already released this epoch's escrow into the reserve, so a - // refund now is an extra payout rather than part of committed. It comes - // out of free reserve and must not reach money owed to a priced exit. - if treasury::free_reserve(e) < request.amount { - panic_with_error!(e, VaultError::ClaimNotCovered); - } + guards::refund_covered(e, request.amount); refund_deposit(e, holder, epoch_id, request.amount); settlement::on_deposit_claimed(e, epoch_id, 0); return 0; @@ -212,32 +162,13 @@ fn refund_deposit(e: &Env, holder: &Address, epoch_id: u64, amount: i128) { pub(crate) fn cancel(e: &Env, holder: &Address, epoch_id: u64) -> i128 { holder.require_auth(); - let epoch = state::get_epoch(e, epoch_id) .unwrap_or_else(|| panic_with_error!(e, VaultError::EpochNotFound)); - - if epoch.status == EpochStatus::Fulfilled { - panic_with_error!(e, VaultError::AlreadyPriced); - } - - // Cancellation is an escape hatch, not a choice. Once the feed can price a - // sealed epoch, the price is knowable and the only way out is to take it. - // Fulfilment is closed during a wind-down, so this epoch will never take a - // price. There is nothing to decline. - if !wind_down::is_active(e) && epoch::is_priceable(e, &epoch) { - panic_with_error!(e, VaultError::PriceAvailable); - } - + guards::not_priced(e, &epoch); + guards::no_price_available(e, &epoch); let request = state::get_deposit_request(e, epoch_id, holder) .unwrap_or_else(|| panic_with_error!(e, VaultError::RequestNotFound)); - - let standing = lock_policy::standing(e, holder); - match lock_policy::decide(Exit::CancelDeposit, standing, wind_down::is_active(e)) { - Decision::Proceed => {} - Decision::Refuse | Decision::Hold | Decision::ConvertToExit | Decision::Surrender => { - panic_with_error!(e, lock_policy::refused_with(standing)) - } - } + guards::allowed_to(e, Exit::CancelDeposit, holder); state::set_cancellable_escrow( e, diff --git a/contracts/async-vault/src/epoch.rs b/contracts/async-vault/src/epoch.rs index 96ad4b03..2ed633ab 100644 --- a/contracts/async-vault/src/epoch.rs +++ b/contracts/async-vault/src/epoch.rs @@ -4,12 +4,12 @@ use strata_bindings::{OracleFeedClient, OracleState}; use crate::error::VaultError; use crate::event::{EpochClosed, EpochFulfilled}; +use crate::guards; use crate::keys::DataKey; use crate::pricing::{Pricing, PricingScheme}; use crate::settlement; use crate::state::{self, EpochInfo, EpochStatus}; use crate::timing::{FulfilmentTiming, StandardTiming}; -use crate::wind_down; /// The schedule this vault fulfils on. type Timing = StandardTiming; @@ -59,14 +59,11 @@ pub(crate) fn is_priceable(e: &Env, epoch: &EpochInfo) -> bool { } pub(crate) fn close(e: &Env) -> u64 { - wind_down::refuse_if_active(e); + guards::not_winding_down(e); let current = state::current_epoch(e); let mut epoch = state::get_epoch(e, current) .unwrap_or_else(|| panic_with_error!(e, VaultError::EpochNotFound)); - - if epoch.status != EpochStatus::Open { - panic_with_error!(e, VaultError::EpochNotOpen); - } + guards::epoch_open(e, &epoch); let next = current .checked_add(1) @@ -93,13 +90,10 @@ pub(crate) fn close(e: &Env) -> u64 { } pub(crate) fn fulfill(e: &Env, epoch_id: u64) -> i128 { - wind_down::refuse_if_active(e); + guards::not_winding_down(e); let mut epoch = state::get_epoch(e, epoch_id) .unwrap_or_else(|| panic_with_error!(e, VaultError::EpochNotFound)); - - if let Err(refusal) = fulfilable(e, &epoch) { - panic_with_error!(e, refusal); - } + guards::fulfilable(e, &epoch); let share_price = Pricing::resolve_share_price(e).unwrap_or_else(|err| panic_with_error!(e, err)); diff --git a/contracts/async-vault/src/governance.rs b/contracts/async-vault/src/governance.rs index 78f3c649..38bf99dc 100644 --- a/contracts/async-vault/src/governance.rs +++ b/contracts/async-vault/src/governance.rs @@ -21,13 +21,13 @@ use strata_timelock::{queue, setup, Clock, Refusal}; use crate::error::VaultError; use crate::event::{ChangeApplied, ChangeCancelled, ChangeProposed, NoticeSet, SetupFinished}; +use crate::guards; use crate::keys::DataKey; -use crate::roles::MANAGER_ROLE; use crate::state; use crate::treasury; use crate::upgrade; use crate::wind_down; -use crate::{AsyncVault, MAX_NOTICE_SECS}; +use crate::AsyncVault; /// Seven days of five-second ledgers: how long the next admin has to accept a /// hand-over. @@ -74,7 +74,7 @@ pub(crate) struct Wiring { } impl Wiring { - fn complete(&self) -> bool { + pub(crate) fn complete(&self) -> bool { self.token_bound && self.oracle_bound && self.sole_token_manager @@ -113,29 +113,6 @@ fn refuse(e: &Env, refusal: Refusal) -> ! { } } -/// 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, VaultError::UseTimelock); - } -} - -/// Nothing is taken in or queued until every admin change on the kit waits for -/// the timelock. -pub(crate) fn refuse_if_setup_open(e: &Env) { - if setup::is_open(e) { - panic_with_error!(e, VaultError::SetupOpen); - } -} - -/// After setup, revoking `manager` waits like a grant; any other revoke stays -/// instant. -pub(crate) fn require_revoke_allowed(e: &Env, role: &Symbol) { - if *role == MANAGER_ROLE { - require_setup_open(e); - } -} - /// True when `contract` reads this vault's clock and its own setup has /// finished, so its admin changes wait for this timelock. pub(crate) fn bound_here(e: &Env, contract: &Address) -> bool { @@ -193,78 +170,24 @@ pub(crate) fn wiring(e: &Env) -> Wiring { } } -/// 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) { - panic_with_error!(e, VaultError::SetupClosed); - } - if role_transfer::has_active_pending_transfer(e, &AccessControlStorageKey::PendingAdmin) { - panic_with_error!(e, VaultError::AdminTransferPending); - } - if !wiring(e).complete() { - panic_with_error!(e, VaultError::SetupIncomplete); - } + guards::setup_open(e); + guards::no_hand_over_pending(e); + guards::wiring_complete(e); setup::finish::(e).unwrap_or_else(|r| refuse(e, r)); SetupFinished {}.publish(e); } -fn check_notice(e: &Env, secs: u64) { - if secs > MAX_NOTICE_SECS { - panic_with_error!(e, VaultError::NoticeTooLong); - } - if secs > upgrade::delay(e) { - panic_with_error!(e, VaultError::NoticeAboveUpgradeDelay); - } -} - pub(crate) fn set_notice(e: &Env, secs: u64) { - check_notice(e, secs); state::set_notice(e, secs); NoticeSet { secs }.publish(e); } -fn changes_code(change: &VaultChange) -> bool { - matches!( - change, - VaultChange::Upgrade(_) | VaultChange::UpgradeDelay(_) - ) -} - -/// One code change at a time, so a standing eta is never quietly replaced. -fn code_change_pending(e: &Env) -> bool { - queue::open_ids(e).iter().any(|id| { - queue::pending::(e, id).is_some_and(|(change, _)| changes_code(&change)) - }) -} - -/// The bounds a change must meet when it is proposed and again when it -/// applies. -fn check(e: &Env, change: &VaultChange) { - match change { - VaultChange::UpgradeDelay(secs) => upgrade::check_delay(e, *secs), - VaultChange::Notice(secs) => check_notice(e, *secs), - VaultChange::WindDownDelay(secs) => wind_down::check_delay(e, *secs), - VaultChange::Oracle(feed) => { - if !bound_here(e, feed) { - panic_with_error!(e, VaultError::FeedNotBound); - } - } - VaultChange::Upgrade(_) - | VaultChange::Custodian(_) - | VaultChange::GrantRole(..) - | VaultChange::RevokeRole(..) - | VaultChange::Admin(_) => {} - } -} - pub(crate) fn propose(e: &Env, change: VaultChange) -> u32 { - wind_down::refuse_if_active(e); - refuse_if_setup_open(e); - check(e, &change); - if changes_code(&change) && code_change_pending(e) { - panic_with_error!(e, VaultError::UpgradeProposalExists); - } + guards::not_winding_down(e); + guards::setup_finished(e); + guards::change_in_bounds(e, &change); + guards::no_code_change_pending(e, &change); let id = queue::propose::(e, &change).unwrap_or_else(|r| refuse(e, r)); ChangeProposed { id, @@ -284,7 +207,7 @@ pub(crate) fn apply(e: &Env, id: u32) { let change: VaultChange = queue::take_ready::(e, id).unwrap_or_else(|r| refuse(e, r)); // The notice or the delay may have moved since the proposal; this check decides. - check(e, &change); + guards::change_in_bounds(e, &change); let admin = access_control::get_admin(e) .unwrap_or_else(|| panic_with_error!(e, VaultError::AdminRequired)); match &change { diff --git a/contracts/async-vault/src/guards.rs b/contracts/async-vault/src/guards.rs new file mode 100644 index 00000000..dd3cb048 --- /dev/null +++ b/contracts/async-vault/src/guards.rs @@ -0,0 +1,362 @@ +use soroban_sdk::{panic_with_error, Address, Env, Symbol}; +use stellar_access::{access_control::AccessControlStorageKey, role_transfer}; +use stellar_tokens::{ + fungible::FungibleTokenClient, + rwa::{compliance::ComplianceClient as RulesClient, RWATokenClient}, +}; +use strata_timelock::{queue, setup}; + +use crate::epoch; +use crate::error::VaultError; +use crate::governance::{self, VaultChange}; +use crate::keys::DataKey; +use crate::lock_policy::{self, Decision, Exit, Standing}; +use crate::roles::{VaultRoles, MANAGER_ROLE}; +use crate::state::{self, EpochInfo, EpochStatus}; +use crate::treasury; +use crate::upgrade::{self, MAX_UPGRADE_DELAY, MIN_UPGRADE_DELAY}; +use crate::wind_down::{ + self, WindDownInfo, WindDownStatus, MAX_WIND_DOWN_DELAY, MIN_WIND_DOWN_DELAY, +}; +use crate::MAX_NOTICE_SECS; + +pub(crate) fn roles_distinct(e: &Env, roles: &VaultRoles) { + if roles.treasury == roles.guardian + || roles.treasury == roles.governance + || roles.compliance == roles.governance + || roles.compliance == roles.treasury + { + panic_with_error!(e, VaultError::RolesNotDistinct); + } +} + +/// Called at the top of every entrypoint the wind-down closes. +pub(crate) fn not_winding_down(e: &Env) { + if wind_down::is_active(e) { + panic_with_error!(e, VaultError::WindDownActive); + } +} + +pub(crate) fn winding_down(e: &Env) { + if !wind_down::is_active(e) { + panic_with_error!(e, VaultError::WindDownNotActive); + } +} + +/// Nothing is taken in or queued until every admin change on the kit waits for +/// the timelock. +pub(crate) fn setup_finished(e: &Env) { + if setup::is_open(e) { + panic_with_error!(e, VaultError::SetupOpen); + } +} + +pub(crate) fn setup_open(e: &Env) { + if !setup::is_open(e) { + panic_with_error!(e, VaultError::SetupClosed); + } +} + +/// Direct admin calls are instant only before setup finishes. +pub(crate) fn direct_change_allowed(e: &Env) { + if !setup::is_open(e) { + panic_with_error!(e, VaultError::UseTimelock); + } +} + +/// After setup, revoking `manager` waits like a grant; any other revoke stays +/// instant. +pub(crate) fn direct_revoke_allowed(e: &Env, role: &Symbol) { + if *role == MANAGER_ROLE { + direct_change_allowed(e); + } +} + +/// Only an offer needs setup open; cancelling one (`0`) keeps the admin. +pub(crate) fn direct_hand_over_allowed(e: &Env, live_until_ledger: u32) { + if live_until_ledger != 0 { + direct_change_allowed(e); + } +} + +pub(crate) fn not_lowered_after_setup(e: &Env, new: u64, current: u64) { + if new < current { + direct_change_allowed(e); + } +} + +/// No nominee from setup may accept once changes wait, so a hand-over offered +/// during setup holds it open. +pub(crate) fn no_hand_over_pending(e: &Env) { + if role_transfer::has_active_pending_transfer(e, &AccessControlStorageKey::PendingAdmin) { + panic_with_error!(e, VaultError::AdminTransferPending); + } +} + +pub(crate) fn wiring_complete(e: &Env) { + if !governance::wiring(e).complete() { + panic_with_error!(e, VaultError::SetupIncomplete); + } +} + +pub(crate) fn non_negative_cap(e: &Env, cap: Option) { + if cap.is_some_and(|cap| cap < 0) { + panic_with_error!(e, VaultError::InvalidAmount); + } +} + +pub(crate) fn notice_in_bounds(e: &Env, secs: u64) { + if secs > MAX_NOTICE_SECS { + panic_with_error!(e, VaultError::NoticeTooLong); + } + if secs > upgrade::delay(e) { + panic_with_error!(e, VaultError::NoticeAboveUpgradeDelay); + } +} + +#[cfg_attr(feature = "short_wind_down", allow(clippy::absurd_extreme_comparisons))] +pub(crate) fn wind_down_delay_in_bounds(e: &Env, secs: u64) { + if secs < MIN_WIND_DOWN_DELAY { + panic_with_error!(e, VaultError::WindDownDelayTooShort); + } + if secs > MAX_WIND_DOWN_DELAY { + panic_with_error!(e, VaultError::WindDownDelayTooLong); + } +} + +/// An exit takes a full notice, so a delay shorter than one is not a window. +pub(crate) fn upgrade_delay_in_bounds(e: &Env, secs: u64) { + if secs < MIN_UPGRADE_DELAY { + panic_with_error!(e, VaultError::UpgradeDelayTooShort); + } + if secs > MAX_UPGRADE_DELAY { + panic_with_error!(e, VaultError::UpgradeDelayTooLong); + } + if secs < state::notice(e) { + panic_with_error!(e, VaultError::UpgradeDelayBelowNotice); + } +} + +pub(crate) fn feed_bound_here(e: &Env, feed: &Address) { + if !governance::bound_here(e, feed) { + panic_with_error!(e, VaultError::FeedNotBound); + } +} + +/// The bounds a change must meet when it is proposed and again when it +/// applies. +pub(crate) fn change_in_bounds(e: &Env, change: &VaultChange) { + match change { + VaultChange::UpgradeDelay(secs) => upgrade_delay_in_bounds(e, *secs), + VaultChange::Notice(secs) => notice_in_bounds(e, *secs), + VaultChange::WindDownDelay(secs) => wind_down_delay_in_bounds(e, *secs), + VaultChange::Oracle(feed) => feed_bound_here(e, feed), + VaultChange::Upgrade(_) + | VaultChange::Custodian(_) + | VaultChange::GrantRole(..) + | VaultChange::RevokeRole(..) + | VaultChange::Admin(_) => {} + } +} + +/// One code change at a time, so a standing eta is never quietly replaced. +pub(crate) fn no_code_change_pending(e: &Env, change: &VaultChange) { + if changes_code(change) && code_change_pending(e) { + panic_with_error!(e, VaultError::UpgradeProposalExists); + } +} + +fn changes_code(change: &VaultChange) -> bool { + matches!( + change, + VaultChange::Upgrade(_) | VaultChange::UpgradeDelay(_) + ) +} + +fn code_change_pending(e: &Env) -> bool { + queue::open_ids(e).iter().any(|id| { + queue::pending::(e, id).is_some_and(|(change, _)| changes_code(&change)) + }) +} + +pub(crate) fn no_wind_down_proposed(e: &Env) { + if matches!( + wind_down::info(e), + Some(WindDownInfo { + status: WindDownStatus::Proposed, + .. + }) + ) { + panic_with_error!(e, VaultError::WindDownAlreadyProposed); + } +} + +pub(crate) fn wind_down_proposed(e: &Env) { + if !matches!( + wind_down::info(e), + Some(WindDownInfo { + status: WindDownStatus::Proposed, + .. + }) + ) { + panic_with_error!(e, VaultError::WindDownNotProposed); + } +} + +pub(crate) fn wind_down_delay_elapsed(e: &Env, proposal: &WindDownInfo) { + if e.ledger().timestamp() < proposal.active_at { + panic_with_error!(e, VaultError::WindDownDelayNotElapsed); + } +} + +pub(crate) fn distribution_started(e: &Env) { + if wind_down::info(e).map(|wd| wd.round).unwrap_or(0) == 0 { + panic_with_error!(e, VaultError::DistributionNotStarted); + } +} + +/// A round needs a supply to divide by and a pot that credits something. +pub(crate) fn something_to_distribute(e: &Env, value: i128) { + if value <= 0 { + panic_with_error!(e, VaultError::NothingToDistribute); + } +} + +pub(crate) fn has_entitlement(e: &Env, surrendered: i128, assets: i128) { + if surrendered == 0 && assets == 0 { + panic_with_error!(e, VaultError::NoEntitlement); + } +} + +pub(crate) fn positive_amount(e: &Env, amount: i128) { + if amount <= 0 { + panic_with_error!(e, VaultError::InvalidAmount); + } +} + +pub(crate) fn within_cap(e: &Env, amount: i128) { + let Some(cap) = state::deposit_cap(e) else { + return; + }; + let new_total = treasury::deposited_capital(e) + .checked_add(amount) + .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)); + if new_total > cap { + panic_with_error!(e, VaultError::DepositCapExceeded); + } +} + +pub(crate) fn within_free_reserve(e: &Env, assets: i128) { + if assets > treasury::free_reserve(e) { + panic_with_error!(e, VaultError::ReserveCommittedToExits); + } +} + +pub(crate) fn no_open_deposit(e: &Env, epoch_id: u64, holder: &Address) { + if state::get_deposit_request(e, epoch_id, holder).is_some() { + panic_with_error!(e, VaultError::RequestOutstanding); + } +} + +pub(crate) fn no_open_redeem(e: &Env, epoch_id: u64, holder: &Address) { + if state::get_redeem_request(e, epoch_id, holder).is_some() { + panic_with_error!(e, VaultError::RequestOutstanding); + } +} + +pub(crate) fn not_claimed(e: &Env, claimed: bool) { + if claimed { + panic_with_error!(e, VaultError::AlreadyClaimed); + } +} + +/// A request within the balance but short of the free shares needs frozen +/// ones; beyond the balance entirely, the token's own check applies instead. +pub(crate) fn needs_no_frozen_shares(e: &Env, holder: &Address, shares: i128) { + if shares > lock_policy::free_shares(e, holder) { + let held = + FungibleTokenClient::new(e, &state::get_addr(e, &DataKey::ShareToken)).balance(holder); + if shares <= held { + panic_with_error!(e, VaultError::Frozen); + } + } +} + +/// Pricing already released the epoch's escrow into the reserve, so a refund +/// is an extra payout rather than part of committed. It comes out of free +/// reserve and must not reach money owed to a priced exit. +pub(crate) fn refund_covered(e: &Env, amount: i128) { + if treasury::free_reserve(e) < amount { + panic_with_error!(e, VaultError::ClaimNotCovered); + } +} + +/// Against liquid reserve, not the raw balance: escrow a depositor can still +/// cancel is not available to pay an exit. +pub(crate) fn payout_covered(e: &Env, assets: i128) { + if treasury::liquid_reserve(e) < assets { + panic_with_error!(e, VaultError::ClaimNotCovered); + } +} + +/// The holder may hold shares and the compliance rules let the vault send them. +pub(crate) fn eligible_to_receive(e: &Env, holder: &Address, shares: i128) { + let vault = e.current_contract_address(); + let token = state::get_addr(e, &DataKey::ShareToken); + let share = RWATokenClient::new(e, &token); + if lock_policy::standing(e, holder) != Standing::Listed + || !RulesClient::new(e, &share.compliance()).can_transfer(&vault, holder, &shares, &token) + { + panic_with_error!(e, VaultError::HolderNotEligible); + } +} + +pub(crate) fn epoch_open(e: &Env, epoch: &EpochInfo) { + if epoch.status != EpochStatus::Open { + panic_with_error!(e, VaultError::EpochNotOpen); + } +} + +pub(crate) fn epoch_fulfilled(e: &Env, epoch: &EpochInfo) { + if epoch.status != EpochStatus::Fulfilled { + panic_with_error!(e, VaultError::EpochNotFulfilled); + } +} + +pub(crate) fn not_priced(e: &Env, epoch: &EpochInfo) { + if epoch.status == EpochStatus::Fulfilled { + panic_with_error!(e, VaultError::AlreadyPriced); + } +} + +/// Cancellation is an escape hatch, not a choice. Once the feed can price a +/// sealed epoch, the price is knowable and the only way out is to take it. +/// Fulfilment is closed during a wind-down, so this epoch will never take a +/// price. There is nothing to decline. +pub(crate) fn no_price_available(e: &Env, epoch: &EpochInfo) { + if !wind_down::is_active(e) && epoch::is_priceable(e, epoch) { + panic_with_error!(e, VaultError::PriceAvailable); + } +} + +pub(crate) fn fulfilable(e: &Env, epoch: &EpochInfo) { + if let Err(refusal) = epoch::fulfilable(e, epoch) { + panic_with_error!(e, refusal); + } +} + +/// The lock policy lets the holder's `exit` go ahead as asked. +pub(crate) fn allowed_to(e: &Env, exit: Exit, holder: &Address) { + let standing = lock_policy::standing(e, holder); + if lock_policy::decide(exit, standing, wind_down::is_active(e)) != Decision::Proceed { + panic_with_error!(e, lock_policy::refused_with(standing)); + } +} + +/// The lock policy neither refuses nor holds the exit: it goes ahead, or its +/// value moves to an exit. +pub(crate) fn not_refused(e: &Env, decision: Decision, standing: Standing) { + if matches!(decision, Decision::Refuse | Decision::Hold) { + panic_with_error!(e, lock_policy::refused_with(standing)); + } +} diff --git a/contracts/async-vault/src/lib.rs b/contracts/async-vault/src/lib.rs index f334244a..700a809e 100644 --- a/contracts/async-vault/src/lib.rs +++ b/contracts/async-vault/src/lib.rs @@ -5,6 +5,7 @@ mod epoch; mod error; mod event; mod governance; +mod guards; mod health; mod keys; mod lock_policy; @@ -81,13 +82,7 @@ impl AsyncVault { // on the NAV oracle, the vault checks pairwise distinctness at construction // time to prevent role misconfiguration across the kit ecosystem. Neither // compliance nor attester is granted a direct role on the vault contract itself. - if roles.treasury == roles.guardian - || roles.treasury == roles.governance - || roles.compliance == roles.governance - || roles.compliance == roles.treasury - { - panic_with_error!(e, VaultError::RolesNotDistinct); - } + guards::roles_distinct(e, &roles); access_control::set_admin(e, &roles.governance); access_control::grant_role_no_auth(e, &roles.manager, &MANAGER_ROLE, &roles.governance); @@ -185,11 +180,7 @@ impl AsyncVault { #[only_admin] pub fn set_deposit_cap(e: &Env, cap: Option, caller: Address) { caller.require_auth(); - if let Some(c) = cap { - if c < 0 { - panic_with_error!(e, VaultError::InvalidAmount); - } - } + guards::non_negative_cap(e, cap); let old_cap = state::deposit_cap(e); state::set_deposit_cap(e, cap); DepositCapUpdated { @@ -204,7 +195,7 @@ impl AsyncVault { #[only_admin] pub fn set_custodian(e: &Env, custodian: Address, caller: Address) { caller.require_auth(); - governance::require_setup_open(e); + guards::direct_change_allowed(e); treasury::set_custodian(e, &custodian); } @@ -213,10 +204,8 @@ impl AsyncVault { #[only_admin] pub fn set_wind_down_delay(e: &Env, secs: u64, caller: Address) { caller.require_auth(); - wind_down::check_delay(e, secs); - if secs < wind_down::delay(e) { - governance::require_setup_open(e); - } + guards::wind_down_delay_in_bounds(e, secs); + guards::not_lowered_after_setup(e, secs, wind_down::delay(e)); wind_down::set_delay(e, secs); } @@ -280,9 +269,8 @@ impl AsyncVault { #[only_admin] pub fn set_notice(e: &Env, secs: u64, caller: Address) { caller.require_auth(); - if secs < state::notice(e) { - governance::require_setup_open(e); - } + guards::not_lowered_after_setup(e, secs, state::notice(e)); + guards::notice_in_bounds(e, secs); governance::set_notice(e, secs); } @@ -422,7 +410,7 @@ impl AsyncVault { impl AccessControl for AsyncVault { /// Instant during setup; after it, propose the grant through the timelock. fn grant_role(e: &Env, account: Address, role: Symbol, caller: Address) { - governance::require_setup_open(e); + guards::direct_change_allowed(e); access_control::grant_role(e, &account, &role, &caller); } @@ -430,16 +418,14 @@ impl AccessControl for AsyncVault { /// away moves no value. After setup, revoking `manager` waits like a grant, /// because without it no epoch closes and no exit is priced. fn revoke_role(e: &Env, account: Address, role: Symbol, caller: Address) { - governance::require_revoke_allowed(e, &role); + guards::direct_revoke_allowed(e, &role); access_control::revoke_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); - } + guards::direct_hand_over_allowed(e, live_until_ledger); access_control::transfer_admin_role(e, &new_admin, live_until_ledger); } diff --git a/contracts/async-vault/src/redeem.rs b/contracts/async-vault/src/redeem.rs index 005638cc..1ccb3ed5 100644 --- a/contracts/async-vault/src/redeem.rs +++ b/contracts/async-vault/src/redeem.rs @@ -1,50 +1,24 @@ use soroban_sdk::{panic_with_error, token::TokenClient, Address, Env}; -use stellar_tokens::{ - fungible::FungibleTokenClient, - rwa::{compliance::ComplianceClient as RulesClient, RWATokenClient}, -}; +use stellar_tokens::rwa::RWATokenClient; -use crate::epoch; use crate::error::VaultError; use crate::event::{RedeemCancelled, RedeemClaimed, RedeemRequested, RedemptionSurrendered}; +use crate::guards; use crate::keys::DataKey; -use crate::lock_policy::{self, Decision, Exit, Standing}; +use crate::lock_policy::{self, Decision, Exit}; use crate::pricing::{Pricing, PricingScheme}; use crate::settlement; -use crate::state::{self, EpochStatus, RedeemRequest}; -use crate::treasury; +use crate::state::{self, RedeemRequest}; use crate::wind_down; pub(crate) fn request(e: &Env, holder: &Address, shares: i128) -> u64 { - wind_down::refuse_if_active(e); + guards::not_winding_down(e); holder.require_auth(); - - if shares <= 0 { - panic_with_error!(e, VaultError::InvalidAmount); - } - - let standing = lock_policy::standing(e, holder); - match lock_policy::decide(Exit::RequestRedeem, standing, wind_down::is_active(e)) { - Decision::Proceed => {} - Decision::Refuse | Decision::Hold | Decision::ConvertToExit | Decision::Surrender => { - panic_with_error!(e, lock_policy::refused_with(standing)) - } - } - // A request within the balance but short of the free shares needs frozen - // ones; beyond the balance entirely, the token's own check applies instead. - if shares > lock_policy::free_shares(e, holder) { - let held = - FungibleTokenClient::new(e, &state::get_addr(e, &DataKey::ShareToken)).balance(holder); - if shares <= held { - panic_with_error!(e, VaultError::Frozen); - } - } - + guards::positive_amount(e, shares); + guards::allowed_to(e, Exit::RequestRedeem, holder); + guards::needs_no_frozen_shares(e, holder, shares); let epoch_id = state::current_epoch(e); - - if state::get_redeem_request(e, epoch_id, holder).is_some() { - panic_with_error!(e, VaultError::RequestOutstanding); - } + guards::no_open_redeem(e, epoch_id, holder); let mut epoch = state::get_epoch(e, epoch_id) .unwrap_or_else(|| panic_with_error!(e, VaultError::EpochNotFound)); @@ -82,28 +56,13 @@ pub(crate) fn request(e: &Env, holder: &Address, shares: i128) -> u64 { pub(crate) fn claim(e: &Env, holder: &Address, epoch_id: u64) -> i128 { holder.require_auth(); - let epoch = state::get_epoch(e, epoch_id) .unwrap_or_else(|| panic_with_error!(e, VaultError::EpochNotFound)); - - if epoch.status != EpochStatus::Fulfilled { - panic_with_error!(e, VaultError::EpochNotFulfilled); - } - + guards::epoch_fulfilled(e, &epoch); let mut request = state::get_redeem_request(e, epoch_id, holder) .unwrap_or_else(|| panic_with_error!(e, VaultError::RequestNotFound)); - - if request.claimed { - panic_with_error!(e, VaultError::AlreadyClaimed); - } - - let standing = lock_policy::standing(e, holder); - match lock_policy::decide(Exit::ClaimRedeem, standing, wind_down::is_active(e)) { - Decision::Proceed => {} - Decision::Refuse | Decision::Hold | Decision::ConvertToExit | Decision::Surrender => { - panic_with_error!(e, lock_policy::refused_with(standing)) - } - } + guards::not_claimed(e, request.claimed); + guards::allowed_to(e, Exit::ClaimRedeem, holder); let assets = Pricing::redeem_assets(e, request.shares, epoch.share_price) .unwrap_or_else(|err| panic_with_error!(e, err)); @@ -120,11 +79,7 @@ pub(crate) fn claim(e: &Env, holder: &Address, epoch_id: u64) -> i128 { return 0; } - // Against liquid reserve, not the raw balance: escrow a depositor can still - // cancel is not available to pay an exit. - if treasury::liquid_reserve(e) < assets { - panic_with_error!(e, VaultError::ClaimNotCovered); - } + guards::payout_covered(e, assets); let vault = e.current_contract_address(); let asset = state::get_addr(e, &DataKey::Asset); @@ -205,42 +160,25 @@ fn return_shares(e: &Env, holder: &Address, epoch_id: u64, shares: i128) { /// Checks the receiver, then moves as manager, so the vault's listing and token pause never apply. fn deliver(e: &Env, holder: &Address, shares: i128) { + guards::eligible_to_receive(e, holder, shares); let vault = e.current_contract_address(); let token = state::get_addr(e, &DataKey::ShareToken); - let share = RWATokenClient::new(e, &token); - if lock_policy::standing(e, holder) != Standing::Listed - || !RulesClient::new(e, &share.compliance()).can_transfer(&vault, holder, &shares, &token) - { - panic_with_error!(e, VaultError::HolderNotEligible); - } - share.forced_transfer(&vault, holder, &shares, &vault); + RWATokenClient::new(e, &token).forced_transfer(&vault, holder, &shares, &vault); } pub(crate) fn cancel(e: &Env, holder: &Address, epoch_id: u64) -> i128 { holder.require_auth(); - let epoch = state::get_epoch(e, epoch_id) .unwrap_or_else(|| panic_with_error!(e, VaultError::EpochNotFound)); - - if epoch.status == EpochStatus::Fulfilled { - panic_with_error!(e, VaultError::AlreadyPriced); - } - - let winding_down = wind_down::is_active(e); - - // Cancellation is an escape hatch, not a choice. Once the feed can price a - // sealed epoch, the price is knowable and the only way out is to take it. - // Fulfilment is closed during a wind-down, so this epoch will never take a - // price. There is nothing to decline. - if !winding_down && epoch::is_priceable(e, &epoch) { - panic_with_error!(e, VaultError::PriceAvailable); - } - + guards::not_priced(e, &epoch); + guards::no_price_available(e, &epoch); let request = state::get_redeem_request(e, epoch_id, holder) .unwrap_or_else(|| panic_with_error!(e, VaultError::RequestNotFound)); - let standing = lock_policy::standing(e, holder); - match lock_policy::decide(Exit::CancelRedeem, standing, winding_down) { + let decision = lock_policy::decide(Exit::CancelRedeem, standing, wind_down::is_active(e)); + guards::not_refused(e, decision, standing); + + match decision { Decision::Proceed => return_shares(e, holder, epoch_id, request.shares), Decision::Surrender => surrender_escrow(e, holder, epoch_id, request.shares), Decision::Refuse | Decision::Hold | Decision::ConvertToExit => { diff --git a/contracts/async-vault/src/test/guards.rs b/contracts/async-vault/src/test/guards.rs new file mode 100644 index 00000000..dea5eaa8 --- /dev/null +++ b/contracts/async-vault/src/test/guards.rs @@ -0,0 +1,711 @@ +//! Each guard passes while its condition holds and refuses with its error once +//! it does not. + +use soroban_sdk::InvokeError; +use stellar_tokens::rwa::compliance::ComplianceHook; + +use super::share_returns::RefusesEveryTransfer; +use super::*; +use crate::guards; +use crate::lock_policy::{Decision, Exit, Standing}; +use crate::{EpochInfo, WindDownInfo}; + +/// Runs `guard` inside the vault, as an entrypoint would. +fn run(f: &Fixture, guard: impl FnOnce()) -> Result<(), Result> { + f.e.try_as_contract::<(), soroban_sdk::Error>(&f.vault.address, guard) +} + +fn holds(f: &Fixture, guard: impl FnOnce()) { + assert_eq!(run(f, guard), Ok(())); +} + +fn refuses(f: &Fixture, error: VaultError, guard: impl FnOnce()) { + assert_eq!(run(f, guard), Err(Ok(error.into()))); +} + +fn epoch(f: &Fixture, id: u64) -> EpochInfo { + f.vault.get_epoch(&id).unwrap() +} + +fn wind_down_now(f: &Fixture) { + f.vault.propose_wind_down(&f.admin); + f.advance(MIN_WIND_DOWN_DELAY); + f.vault.activate_wind_down(); +} + +#[test] +fn roles_distinct_refuses_one_account_in_two_seats_kept_apart() { + let f = setup(); + let roles = distinct_roles(&f.e); + holds(&f, || guards::roles_distinct(&f.e, &roles)); + + for shared in [ + VaultRoles { + guardian: roles.treasury.clone(), + ..roles.clone() + }, + VaultRoles { + governance: roles.treasury.clone(), + ..roles.clone() + }, + VaultRoles { + governance: roles.compliance.clone(), + ..roles.clone() + }, + VaultRoles { + treasury: roles.compliance.clone(), + ..roles.clone() + }, + ] { + refuses(&f, VaultError::RolesNotDistinct, || { + guards::roles_distinct(&f.e, &shared) + }); + } +} + +#[test] +fn not_winding_down_refuses_once_the_wind_down_is_active() { + let f = setup(); + holds(&f, || guards::not_winding_down(&f.e)); + + f.vault.propose_wind_down(&f.admin); + holds(&f, || guards::not_winding_down(&f.e)); + + f.advance(MIN_WIND_DOWN_DELAY); + f.vault.activate_wind_down(); + refuses(&f, VaultError::WindDownActive, || { + guards::not_winding_down(&f.e) + }); +} + +#[test] +fn winding_down_refuses_until_the_wind_down_is_active() { + let f = setup(); + refuses(&f, VaultError::WindDownNotActive, || { + guards::winding_down(&f.e) + }); + + f.vault.propose_wind_down(&f.admin); + refuses(&f, VaultError::WindDownNotActive, || { + guards::winding_down(&f.e) + }); + + f.advance(MIN_WIND_DOWN_DELAY); + f.vault.activate_wind_down(); + holds(&f, || guards::winding_down(&f.e)); +} + +#[test] +fn setup_finished_refuses_while_setup_is_open() { + let open = setup_unfinished(); + refuses(&open, VaultError::SetupOpen, || { + guards::setup_finished(&open.e) + }); + + let finished = setup(); + holds(&finished, || guards::setup_finished(&finished.e)); +} + +#[test] +fn setup_open_refuses_once_setup_has_finished() { + let open = setup_unfinished(); + holds(&open, || guards::setup_open(&open.e)); + + let finished = setup(); + refuses(&finished, VaultError::SetupClosed, || { + guards::setup_open(&finished.e) + }); +} + +#[test] +fn direct_change_allowed_refuses_after_setup() { + let open = setup_unfinished(); + holds(&open, || guards::direct_change_allowed(&open.e)); + + let finished = setup(); + refuses(&finished, VaultError::UseTimelock, || { + guards::direct_change_allowed(&finished.e) + }); +} + +#[test] +fn direct_revoke_allowed_refuses_only_the_manager_role_after_setup() { + let open = setup_unfinished(); + holds(&open, || { + guards::direct_revoke_allowed(&open.e, &symbol_short!("manager")) + }); + + let f = setup(); + holds(&f, || { + guards::direct_revoke_allowed(&f.e, &symbol_short!("guardian")) + }); + holds(&f, || { + guards::direct_revoke_allowed(&f.e, &symbol_short!("treasury")) + }); + refuses(&f, VaultError::UseTimelock, || { + guards::direct_revoke_allowed(&f.e, &symbol_short!("manager")) + }); +} + +#[test] +fn direct_hand_over_allowed_refuses_only_an_offer_after_setup() { + let open = setup_unfinished(); + holds(&open, || guards::direct_hand_over_allowed(&open.e, 100)); + + let f = setup(); + holds(&f, || guards::direct_hand_over_allowed(&f.e, 0)); + refuses(&f, VaultError::UseTimelock, || { + guards::direct_hand_over_allowed(&f.e, 100) + }); +} + +#[test] +fn not_lowered_after_setup_refuses_only_a_lower_value_after_setup() { + let open = setup_unfinished(); + holds(&open, || guards::not_lowered_after_setup(&open.e, 1, 2)); + + let f = setup(); + holds(&f, || guards::not_lowered_after_setup(&f.e, 2, 2)); + holds(&f, || guards::not_lowered_after_setup(&f.e, 3, 2)); + refuses(&f, VaultError::UseTimelock, || { + guards::not_lowered_after_setup(&f.e, 1, 2) + }); +} + +#[test] +fn no_hand_over_pending_refuses_while_an_offer_is_open() { + let f = setup_unfinished(); + holds(&f, || guards::no_hand_over_pending(&f.e)); + + let next = Address::generate(&f.e); + f.vault + .transfer_admin_role(&next, &(f.e.ledger().sequence() + 100)); + refuses(&f, VaultError::AdminTransferPending, || { + guards::no_hand_over_pending(&f.e) + }); + + f.vault.transfer_admin_role(&next, &0); + holds(&f, || guards::no_hand_over_pending(&f.e)); +} + +#[test] +fn wiring_complete_refuses_a_miswired_vault() { + let wired = setup_unfinished(); + holds(&wired, || guards::wiring_complete(&wired.e)); + + let unbound = miswired(Miswire::TokenUnbound); + refuses(&unbound, VaultError::SetupIncomplete, || { + guards::wiring_complete(&unbound.e) + }); +} + +#[test] +fn non_negative_cap_refuses_a_negative_cap() { + let f = setup(); + holds(&f, || guards::non_negative_cap(&f.e, None)); + holds(&f, || guards::non_negative_cap(&f.e, Some(0))); + refuses(&f, VaultError::InvalidAmount, || { + guards::non_negative_cap(&f.e, Some(-1)) + }); +} + +#[test] +fn notice_in_bounds_refuses_a_notice_too_long_or_above_the_upgrade_delay() { + let f = setup(); + holds(&f, || guards::notice_in_bounds(&f.e, 0)); + holds(&f, || guards::notice_in_bounds(&f.e, MIN_UPGRADE_DELAY)); + refuses(&f, VaultError::NoticeAboveUpgradeDelay, || { + guards::notice_in_bounds(&f.e, MIN_UPGRADE_DELAY + 1) + }); + refuses(&f, VaultError::NoticeTooLong, || { + guards::notice_in_bounds(&f.e, MAX_NOTICE_SECS + 1) + }); +} + +#[test] +fn wind_down_delay_in_bounds_refuses_a_delay_outside_the_bounds() { + let f = setup(); + holds(&f, || { + guards::wind_down_delay_in_bounds(&f.e, MIN_WIND_DOWN_DELAY) + }); + holds(&f, || { + guards::wind_down_delay_in_bounds(&f.e, MAX_WIND_DOWN_DELAY) + }); + refuses(&f, VaultError::WindDownDelayTooShort, || { + guards::wind_down_delay_in_bounds(&f.e, MIN_WIND_DOWN_DELAY - 1) + }); + refuses(&f, VaultError::WindDownDelayTooLong, || { + guards::wind_down_delay_in_bounds(&f.e, MAX_WIND_DOWN_DELAY + 1) + }); +} + +#[test] +fn upgrade_delay_in_bounds_refuses_a_delay_outside_the_bounds_or_below_the_notice() { + let f = setup(); + holds(&f, || { + guards::upgrade_delay_in_bounds(&f.e, MIN_UPGRADE_DELAY) + }); + holds(&f, || { + guards::upgrade_delay_in_bounds(&f.e, MAX_UPGRADE_DELAY) + }); + refuses(&f, VaultError::UpgradeDelayTooShort, || { + guards::upgrade_delay_in_bounds(&f.e, MIN_UPGRADE_DELAY - 1) + }); + refuses(&f, VaultError::UpgradeDelayTooLong, || { + guards::upgrade_delay_in_bounds(&f.e, MAX_UPGRADE_DELAY + 1) + }); + + f.e.as_contract(&f.vault.address, || { + crate::state::set_notice(&f.e, 10 * DAY_DURATION) + }); + refuses(&f, VaultError::UpgradeDelayBelowNotice, || { + guards::upgrade_delay_in_bounds(&f.e, 8 * DAY_DURATION) + }); +} + +#[test] +fn feed_bound_here_refuses_a_feed_not_bound_to_this_vault() { + let f = setup(); + holds(&f, || guards::feed_bound_here(&f.e, &f.oracle.address)); + + let unbound = f.e.register( + NavOracleContract, + ( + f.admin.clone(), + f.attester.clone(), + f.guardian.clone(), + open_feed(), + ), + ); + refuses(&f, VaultError::FeedNotBound, || { + guards::feed_bound_here(&f.e, &unbound) + }); +} + +#[test] +fn change_in_bounds_checks_the_bounds_of_each_change() { + let f = setup(); + let stranger = Address::generate(&f.e); + for change in [ + VaultChange::Upgrade(BytesN::from_array(&f.e, &[1; 32])), + VaultChange::UpgradeDelay(MIN_UPGRADE_DELAY), + VaultChange::Custodian(stranger.clone()), + VaultChange::Notice(0), + VaultChange::WindDownDelay(MIN_WIND_DOWN_DELAY), + VaultChange::GrantRole(symbol_short!("guardian"), stranger.clone()), + VaultChange::RevokeRole(symbol_short!("guardian"), stranger.clone()), + VaultChange::Admin(stranger.clone()), + VaultChange::Oracle(f.oracle.address.clone()), + ] { + holds(&f, || guards::change_in_bounds(&f.e, &change)); + } + + for (change, error) in [ + ( + VaultChange::UpgradeDelay(MIN_UPGRADE_DELAY - 1), + VaultError::UpgradeDelayTooShort, + ), + ( + VaultChange::Notice(MAX_NOTICE_SECS + 1), + VaultError::NoticeTooLong, + ), + ( + VaultChange::WindDownDelay(MAX_WIND_DOWN_DELAY + 1), + VaultError::WindDownDelayTooLong, + ), + ( + VaultChange::Oracle(stranger.clone()), + VaultError::FeedNotBound, + ), + ] { + refuses(&f, error, || guards::change_in_bounds(&f.e, &change)); + } +} + +#[test] +fn no_code_change_pending_refuses_a_second_code_change() { + let f = setup(); + let upgrade = VaultChange::Upgrade(BytesN::from_array(&f.e, &[1; 32])); + holds(&f, || guards::no_code_change_pending(&f.e, &upgrade)); + + f.vault + .propose_change(&VaultChange::UpgradeDelay(MIN_UPGRADE_DELAY), &f.admin); + refuses(&f, VaultError::UpgradeProposalExists, || { + guards::no_code_change_pending(&f.e, &upgrade) + }); + refuses(&f, VaultError::UpgradeProposalExists, || { + guards::no_code_change_pending(&f.e, &VaultChange::UpgradeDelay(MAX_UPGRADE_DELAY)) + }); + holds(&f, || { + guards::no_code_change_pending(&f.e, &VaultChange::Notice(0)) + }); +} + +#[test] +fn no_wind_down_proposed_refuses_a_standing_proposal() { + let f = setup(); + holds(&f, || guards::no_wind_down_proposed(&f.e)); + + f.vault.propose_wind_down(&f.admin); + refuses(&f, VaultError::WindDownAlreadyProposed, || { + guards::no_wind_down_proposed(&f.e) + }); +} + +#[test] +fn wind_down_proposed_refuses_without_a_standing_proposal() { + let f = setup(); + refuses(&f, VaultError::WindDownNotProposed, || { + guards::wind_down_proposed(&f.e) + }); + + f.vault.propose_wind_down(&f.admin); + holds(&f, || guards::wind_down_proposed(&f.e)); + + f.advance(MIN_WIND_DOWN_DELAY); + f.vault.activate_wind_down(); + refuses(&f, VaultError::WindDownNotProposed, || { + guards::wind_down_proposed(&f.e) + }); +} + +#[test] +fn wind_down_delay_elapsed_refuses_before_the_activation_time() { + let f = setup(); + let now = f.e.ledger().timestamp(); + let proposal = |active_at| WindDownInfo { + status: WindDownStatus::Proposed, + active_at, + round: 0, + }; + + holds(&f, || guards::wind_down_delay_elapsed(&f.e, &proposal(now))); + refuses(&f, VaultError::WindDownDelayNotElapsed, || { + guards::wind_down_delay_elapsed(&f.e, &proposal(now + 1)) + }); +} + +#[test] +fn distribution_started_refuses_before_the_first_round() { + let f = setup(); + let _holder = f.holder(500); + refuses(&f, VaultError::DistributionNotStarted, || { + guards::distribution_started(&f.e) + }); + + wind_down_now(&f); + refuses(&f, VaultError::DistributionNotStarted, || { + guards::distribution_started(&f.e) + }); + + f.vault.finalize_wind_down_round(); + holds(&f, || guards::distribution_started(&f.e)); +} + +#[test] +fn something_to_distribute_refuses_zero_and_below() { + let f = setup(); + holds(&f, || guards::something_to_distribute(&f.e, 1)); + refuses(&f, VaultError::NothingToDistribute, || { + guards::something_to_distribute(&f.e, 0) + }); + refuses(&f, VaultError::NothingToDistribute, || { + guards::something_to_distribute(&f.e, -1) + }); +} + +#[test] +fn has_entitlement_refuses_when_nothing_is_surrendered_or_owed() { + let f = setup(); + holds(&f, || guards::has_entitlement(&f.e, 1, 0)); + holds(&f, || guards::has_entitlement(&f.e, 0, 1)); + refuses(&f, VaultError::NoEntitlement, || { + guards::has_entitlement(&f.e, 0, 0) + }); +} + +#[test] +fn positive_amount_refuses_zero_and_below() { + let f = setup(); + holds(&f, || guards::positive_amount(&f.e, 1)); + refuses(&f, VaultError::InvalidAmount, || { + guards::positive_amount(&f.e, 0) + }); + refuses(&f, VaultError::InvalidAmount, || { + guards::positive_amount(&f.e, -1) + }); +} + +#[test] +fn within_cap_refuses_a_deposit_past_the_cap() { + let f = setup(); + holds(&f, || guards::within_cap(&f.e, i128::MAX)); + + f.vault.set_deposit_cap(&Some(1_000), &f.admin); + let investor = f.investor(400); + f.vault.request_deposit(&investor, &400); + holds(&f, || guards::within_cap(&f.e, 600)); + refuses(&f, VaultError::DepositCapExceeded, || { + guards::within_cap(&f.e, 601) + }); + refuses(&f, VaultError::AmountTooLarge, || { + guards::within_cap(&f.e, i128::MAX) + }); +} + +#[test] +fn within_free_reserve_refuses_more_than_the_free_reserve() { + let f = setup(); + let _holder = f.holder(500); + + holds(&f, || guards::within_free_reserve(&f.e, 1_000)); + refuses(&f, VaultError::ReserveCommittedToExits, || { + guards::within_free_reserve(&f.e, 1_001) + }); +} + +#[test] +fn no_open_deposit_refuses_a_second_request_in_the_epoch() { + let f = setup(); + let investor = f.investor(1_000); + let current = f.vault.current_epoch(); + holds(&f, || guards::no_open_deposit(&f.e, current, &investor)); + + f.vault.request_deposit(&investor, &400); + refuses(&f, VaultError::RequestOutstanding, || { + guards::no_open_deposit(&f.e, current, &investor) + }); + holds(&f, || guards::no_open_deposit(&f.e, current + 1, &investor)); +} + +#[test] +fn no_open_redeem_refuses_a_second_request_in_the_epoch() { + let f = setup(); + let holder = f.holder(500); + let current = f.vault.current_epoch(); + holds(&f, || guards::no_open_redeem(&f.e, current, &holder)); + + f.vault.request_redeem(&holder, &100); + refuses(&f, VaultError::RequestOutstanding, || { + guards::no_open_redeem(&f.e, current, &holder) + }); + holds(&f, || guards::no_open_redeem(&f.e, current + 1, &holder)); +} + +#[test] +fn not_claimed_refuses_a_claimed_request() { + let f = setup(); + holds(&f, || guards::not_claimed(&f.e, false)); + refuses(&f, VaultError::AlreadyClaimed, || { + guards::not_claimed(&f.e, true) + }); +} + +/// Beyond the whole balance the guard stands aside: the token's own check +/// refuses that request. +#[test] +fn needs_no_frozen_shares_refuses_a_request_that_reaches_frozen_shares() { + let f = setup(); + let holder = f.holder(500); + f.share.freeze_partial_tokens(&holder, &400, &f.compliance); + + holds(&f, || guards::needs_no_frozen_shares(&f.e, &holder, 100)); + refuses(&f, VaultError::Frozen, || { + guards::needs_no_frozen_shares(&f.e, &holder, 101) + }); + refuses(&f, VaultError::Frozen, || { + guards::needs_no_frozen_shares(&f.e, &holder, 500) + }); + holds(&f, || guards::needs_no_frozen_shares(&f.e, &holder, 501)); +} + +#[test] +fn refund_covered_refuses_a_refund_beyond_the_free_reserve() { + let f = setup(); + let _holder = f.holder(500); + + holds(&f, || guards::refund_covered(&f.e, 1_000)); + refuses(&f, VaultError::ClaimNotCovered, || { + guards::refund_covered(&f.e, 1_001) + }); +} + +#[test] +fn payout_covered_refuses_a_payout_beyond_the_liquid_reserve() { + let f = setup(); + let _holder = f.holder(500); + let investor = f.investor(300); + f.vault.request_deposit(&investor, &300); + + holds(&f, || guards::payout_covered(&f.e, 1_000)); + refuses(&f, VaultError::ClaimNotCovered, || { + guards::payout_covered(&f.e, 1_001) + }); +} + +#[test] +fn eligible_to_receive_refuses_a_holder_the_token_would_not_take() { + let f = setup(); + let listed = f.investor(0); + holds(&f, || guards::eligible_to_receive(&f.e, &listed, 1)); + + let delisted = f.unverified_investor(0); + refuses(&f, VaultError::HolderNotEligible, || { + guards::eligible_to_receive(&f.e, &delisted, 1) + }); + + let frozen = f.investor(0); + f.share.set_address_frozen(&frozen, &true, &f.compliance); + refuses(&f, VaultError::HolderNotEligible, || { + guards::eligible_to_receive(&f.e, &frozen, 1) + }); + + let module = f.e.register(RefusesEveryTransfer, ()); + f.add_compliance_module(ComplianceHook::CanTransfer, &module); + refuses(&f, VaultError::HolderNotEligible, || { + guards::eligible_to_receive(&f.e, &listed, 1) + }); +} + +#[test] +fn epoch_open_refuses_a_sealed_epoch() { + let f = setup(); + let open = epoch(&f, f.vault.current_epoch()); + holds(&f, || guards::epoch_open(&f.e, &open)); + + let sealed = epoch(&f, f.close_epoch()); + refuses(&f, VaultError::EpochNotOpen, || { + guards::epoch_open(&f.e, &sealed) + }); +} + +#[test] +fn epoch_fulfilled_refuses_an_epoch_not_yet_priced() { + let f = setup(); + let id = f.close_epoch(); + let sealed = epoch(&f, id); + refuses(&f, VaultError::EpochNotFulfilled, || { + guards::epoch_fulfilled(&f.e, &sealed) + }); + + f.fulfill_epoch_at(id, wad(1)); + let priced = epoch(&f, id); + holds(&f, || guards::epoch_fulfilled(&f.e, &priced)); +} + +#[test] +fn not_priced_refuses_a_priced_epoch() { + let f = setup(); + let id = f.close_epoch(); + let sealed = epoch(&f, id); + holds(&f, || guards::not_priced(&f.e, &sealed)); + + f.fulfill_epoch_at(id, wad(1)); + let priced = epoch(&f, id); + refuses(&f, VaultError::AlreadyPriced, || { + guards::not_priced(&f.e, &priced) + }); +} + +#[test] +fn no_price_available_refuses_a_sealed_epoch_the_vault_could_price() { + let f = setup(); + let open = epoch(&f, f.vault.current_epoch()); + holds(&f, || guards::no_price_available(&f.e, &open)); + + let id = f.close_epoch(); + let sealed = epoch(&f, id); + holds(&f, || guards::no_price_available(&f.e, &sealed)); + + f.attest(wad(1)); + refuses(&f, VaultError::PriceAvailable, || { + guards::no_price_available(&f.e, &sealed) + }); + + f.vault.pause(&f.guardian); + holds(&f, || guards::no_price_available(&f.e, &sealed)); + f.vault.unpause(&f.admin); + + wind_down_now(&f); + f.attest(wad(1)); + holds(&f, || guards::no_price_available(&f.e, &sealed)); +} + +#[test] +fn fulfilable_refuses_an_epoch_the_vault_cannot_price_yet() { + let f = setup(); + let open = epoch(&f, f.vault.current_epoch()); + refuses(&f, VaultError::EpochNotPending, || { + guards::fulfilable(&f.e, &open) + }); + + let unpriced = epoch(&f, f.close_epoch()); + refuses(&f, VaultError::FeedNotValid, || { + guards::fulfilable(&f.e, &unpriced) + }); + f.attest(wad(1)); + holds(&f, || guards::fulfilable(&f.e, &unpriced)); + + f.advance(10); + let behind_the_report = epoch(&f, f.close_epoch()); + refuses(&f, VaultError::AttestationBeforeClose, || { + guards::fulfilable(&f.e, &behind_the_report) + }); + + f.vault.set_notice(&3_600, &f.admin); + let within_the_notice = epoch(&f, f.close_epoch()); + refuses(&f, VaultError::NoticeNotElapsed, || { + guards::fulfilable(&f.e, &within_the_notice) + }); +} + +#[test] +fn allowed_to_refuses_anything_but_going_ahead() { + let f = setup(); + let listed = f.investor(0); + let delisted = f.unverified_investor(0); + let frozen = f.investor(0); + f.share.set_address_frozen(&frozen, &true, &f.compliance); + + holds(&f, || { + guards::allowed_to(&f.e, Exit::RequestDeposit, &listed) + }); + refuses(&f, VaultError::NotAllowlisted, || { + guards::allowed_to(&f.e, Exit::RequestDeposit, &delisted) + }); + refuses(&f, VaultError::Frozen, || { + guards::allowed_to(&f.e, Exit::RequestDeposit, &frozen) + }); + + holds(&f, || { + guards::allowed_to(&f.e, Exit::RequestRedeem, &delisted) + }); + refuses(&f, VaultError::Frozen, || { + guards::allowed_to(&f.e, Exit::RequestRedeem, &frozen) + }); + + refuses(&f, VaultError::NotAllowlisted, || { + guards::allowed_to(&f.e, Exit::ClaimDeposit, &delisted) + }); +} + +#[test] +fn not_refused_refuses_a_refusal_or_a_hold() { + let f = setup(); + for decision in [ + Decision::Proceed, + Decision::ConvertToExit, + Decision::Surrender, + ] { + holds(&f, || { + guards::not_refused(&f.e, decision, Standing::Delisted) + }); + } + + refuses(&f, VaultError::NotAllowlisted, || { + guards::not_refused(&f.e, Decision::Refuse, Standing::Delisted) + }); + refuses(&f, VaultError::Frozen, || { + guards::not_refused(&f.e, Decision::Hold, Standing::Frozen) + }); +} diff --git a/contracts/async-vault/src/test/mod.rs b/contracts/async-vault/src/test/mod.rs index 7b719b32..9eb755bc 100644 --- a/contracts/async-vault/src/test/mod.rs +++ b/contracts/async-vault/src/test/mod.rs @@ -9,6 +9,7 @@ mod deposit_cap; mod epochs; mod events; mod governance; +mod guards; mod health; mod lock_policy; mod multi_epoch; diff --git a/contracts/async-vault/src/test/share_returns.rs b/contracts/async-vault/src/test/share_returns.rs index 92cadfa5..41fc12bf 100644 --- a/contracts/async-vault/src/test/share_returns.rs +++ b/contracts/async-vault/src/test/share_returns.rs @@ -5,7 +5,7 @@ use stellar_tokens::rwa::compliance::ComplianceHook; use super::*; #[contract] -struct RefusesEveryTransfer; +pub(super) struct RefusesEveryTransfer; #[contractimpl] impl RefusesEveryTransfer { diff --git a/contracts/async-vault/src/treasury.rs b/contracts/async-vault/src/treasury.rs index e1846150..9163e358 100644 --- a/contracts/async-vault/src/treasury.rs +++ b/contracts/async-vault/src/treasury.rs @@ -2,6 +2,7 @@ use soroban_sdk::{panic_with_error, token::TokenClient, Address, Env}; use crate::error::VaultError; use crate::event::{CustodianSet, Deployed, Funded}; +use crate::guards; use crate::keys::DataKey; use crate::state; use crate::wind_down; @@ -47,17 +48,11 @@ pub(crate) fn deposited_capital(e: &Env) -> i128 { } pub(crate) fn deploy(e: &Env, assets: i128) -> i128 { - wind_down::refuse_if_active(e); - if assets <= 0 { - panic_with_error!(e, VaultError::InvalidAmount); - } - + guards::not_winding_down(e); + guards::positive_amount(e, assets); let custodian = state::get_addr_opt(e, &DataKey::Custodian) .unwrap_or_else(|| panic_with_error!(e, VaultError::CustodianNotSet)); - - if assets > free_reserve(e) { - panic_with_error!(e, VaultError::ReserveCommittedToExits); - } + guards::within_free_reserve(e, assets); let net_deployed = state::net_deployed(e) .checked_add(assets) @@ -79,10 +74,7 @@ pub(crate) fn deploy(e: &Env, assets: i128) -> i128 { pub(crate) fn fund(e: &Env, caller: &Address, assets: i128) -> i128 { caller.require_auth(); - - if assets <= 0 { - panic_with_error!(e, VaultError::InvalidAmount); - } + guards::positive_amount(e, assets); let net_deployed = state::net_deployed(e) .checked_sub(assets) diff --git a/contracts/async-vault/src/upgrade.rs b/contracts/async-vault/src/upgrade.rs index 28000229..e045be5e 100644 --- a/contracts/async-vault/src/upgrade.rs +++ b/contracts/async-vault/src/upgrade.rs @@ -1,8 +1,6 @@ -use soroban_sdk::{panic_with_error, Env}; +use soroban_sdk::Env; -use crate::error::VaultError; use crate::keys::DataKey; -use crate::state; /// Seven days. Short enough to fix a defect in a week, long enough that an /// investor who dislikes the change has time to act on it. @@ -18,16 +16,3 @@ pub(crate) fn delay(e: &Env) -> u64 { pub(crate) fn set_delay(e: &Env, secs: u64) { strata_storage::set_instance(e, &DataKey::UpgradeDelay, &secs); } - -/// An exit takes a full notice, so a delay shorter than one is not a window. -pub(crate) fn check_delay(e: &Env, secs: u64) { - if secs < MIN_UPGRADE_DELAY { - panic_with_error!(e, VaultError::UpgradeDelayTooShort); - } - if secs > MAX_UPGRADE_DELAY { - panic_with_error!(e, VaultError::UpgradeDelayTooLong); - } - if secs < state::notice(e) { - panic_with_error!(e, VaultError::UpgradeDelayBelowNotice); - } -} diff --git a/contracts/async-vault/src/wind_down.rs b/contracts/async-vault/src/wind_down.rs index f7642ad9..ebb2ecc4 100644 --- a/contracts/async-vault/src/wind_down.rs +++ b/contracts/async-vault/src/wind_down.rs @@ -7,8 +7,9 @@ use crate::event::{ WindDownActivated, WindDownClaimed, WindDownDelaySet, WindDownProposalCancelled, WindDownProposed, WindDownRoundFinalized, }; +use crate::guards; use crate::keys::DataKey; -use crate::lock_policy::{self, Decision, Exit, Standing}; +use crate::lock_policy::{self, Exit, Standing}; use crate::state; use crate::treasury; @@ -53,18 +54,7 @@ pub(crate) fn delay(e: &Env) -> u64 { strata_storage::get_instance(e, &DataKey::WindDownDelay).unwrap_or(MIN_WIND_DOWN_DELAY) } -#[cfg_attr(feature = "short_wind_down", allow(clippy::absurd_extreme_comparisons))] -pub(crate) fn check_delay(e: &Env, secs: u64) { - if secs < MIN_WIND_DOWN_DELAY { - panic_with_error!(e, VaultError::WindDownDelayTooShort); - } - if secs > MAX_WIND_DOWN_DELAY { - panic_with_error!(e, VaultError::WindDownDelayTooLong); - } -} - pub(crate) fn set_delay(e: &Env, secs: u64) { - check_delay(e, secs); strata_storage::set_instance(e, &DataKey::WindDownDelay, &secs); WindDownDelaySet { secs }.publish(e); } @@ -87,25 +77,9 @@ pub(crate) fn is_active(e: &Env) -> bool { } } -/// Called at the top of every entrypoint the wind-down closes. -pub(crate) fn refuse_if_active(e: &Env) { - if is_active(e) { - panic_with_error!(e, VaultError::WindDownActive); - } -} - pub(crate) fn propose(e: &Env) { - match info(e) { - Some(WindDownInfo { - status: WindDownStatus::Active, - .. - }) => panic_with_error!(e, VaultError::WindDownActive), - Some(WindDownInfo { - status: WindDownStatus::Proposed, - .. - }) => panic_with_error!(e, VaultError::WindDownAlreadyProposed), - None => {} - } + guards::not_winding_down(e); + guards::no_wind_down_proposed(e); let active_at = e.ledger().timestamp().saturating_add(delay(e)); strata_storage::set_instance( @@ -122,17 +96,8 @@ pub(crate) fn propose(e: &Env) { } pub(crate) fn cancel_proposal(e: &Env) { - match info(e) { - Some(WindDownInfo { - status: WindDownStatus::Proposed, - .. - }) => {} - Some(WindDownInfo { - status: WindDownStatus::Active, - .. - }) => panic_with_error!(e, VaultError::WindDownActive), - None => panic_with_error!(e, VaultError::WindDownNotProposed), - } + guards::not_winding_down(e); + guards::wind_down_proposed(e); e.storage().instance().remove(&DataKey::WindDown); WindDownProposalCancelled {}.publish(e); @@ -140,13 +105,8 @@ pub(crate) fn cancel_proposal(e: &Env) { pub(crate) fn activate(e: &Env) { let mut wd = info(e).unwrap_or_else(|| panic_with_error!(e, VaultError::WindDownNotProposed)); - - if wd.status == WindDownStatus::Active { - panic_with_error!(e, VaultError::WindDownActive); - } - if e.ledger().timestamp() < wd.active_at { - panic_with_error!(e, VaultError::WindDownDelayNotElapsed); - } + guards::not_winding_down(e); + guards::wind_down_delay_elapsed(e, &wd); wd.status = WindDownStatus::Active; strata_storage::set_instance(e, &DataKey::WindDown, &wd); @@ -201,9 +161,7 @@ pub(crate) fn credit(e: &Env, holder: &Address, shares: i128) { pub(crate) fn finalize_round(e: &Env) -> i128 { let mut wd = info(e).unwrap_or_else(|| panic_with_error!(e, VaultError::WindDownNotActive)); - if wd.status != WindDownStatus::Active { - panic_with_error!(e, VaultError::WindDownNotActive); - } + guards::winding_down(e); let snapshot = if wd.round == 0 { let s = snapshot_supply(e); @@ -212,17 +170,12 @@ pub(crate) fn finalize_round(e: &Env) -> i128 { } else { supply_snapshot(e) }; - - if snapshot <= 0 { - panic_with_error!(e, VaultError::NothingToDistribute); - } + guards::something_to_distribute(e, snapshot); let pot = treasury::free_reserve(e); let delta = checked_mul_div_floor(e, &pot, &WAD_SCALE, &snapshot) .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)); - if delta <= 0 { - panic_with_error!(e, VaultError::NothingToDistribute); - } + guards::something_to_distribute(e, delta); let acc_before = acc(e); let acc_per_share = acc_before @@ -288,19 +241,8 @@ pub(crate) fn claimable(e: &Env, holder: &Address) -> i128 { pub(crate) fn claim(e: &Env, holder: &Address) -> i128 { holder.require_auth(); - - let round = info(e).map(|wd| wd.round).unwrap_or(0); - if round == 0 { - panic_with_error!(e, VaultError::DistributionNotStarted); - } - - let standing = lock_policy::standing(e, holder); - match lock_policy::decide(Exit::ClaimWindDown, standing, is_active(e)) { - Decision::Proceed => {} - Decision::Refuse | Decision::Hold | Decision::ConvertToExit | Decision::Surrender => { - panic_with_error!(e, lock_policy::refused_with(standing)) - } - } + guards::distribution_started(e); + guards::allowed_to(e, Exit::ClaimWindDown, holder); let vault = e.current_contract_address(); let share_token = state::get_addr(e, &DataKey::ShareToken); @@ -317,10 +259,7 @@ pub(crate) fn claim(e: &Env, holder: &Address) -> i128 { let earned = checked_mul_div_floor(e, &p.entitlement, &acc(e), &WAD_SCALE) .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)); let assets = earned - p.paid; - - if surrendered == 0 && assets == 0 { - panic_with_error!(e, VaultError::NoEntitlement); - } + guards::has_entitlement(e, surrendered, assets); p.paid = earned; strata_storage::set_persistent(e, &DataKey::WindDownPosition(holder.clone()), &p); From 78d849bdae539b3cc22de35e85230a1bf641c962 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:15:54 -0300 Subject: [PATCH 073/147] refactor: name the token's preconditions as guards --- contracts/share-token/src/contract.rs | 53 ++--- contracts/share-token/src/governance.rs | 37 +--- contracts/share-token/src/guards.rs | 93 +++++++++ contracts/share-token/src/lib.rs | 3 + contracts/share-token/src/test_guards.rs | 243 +++++++++++++++++++++++ 5 files changed, 360 insertions(+), 69 deletions(-) create mode 100644 contracts/share-token/src/guards.rs create mode 100644 contracts/share-token/src/test_guards.rs diff --git a/contracts/share-token/src/contract.rs b/contracts/share-token/src/contract.rs index bbff2999..c7aefcc8 100644 --- a/contracts/share-token/src/contract.rs +++ b/contracts/share-token/src/contract.rs @@ -1,6 +1,7 @@ use crate::error::ShareTokenError; use crate::event::LegalTransfer; use crate::governance::{self, TokenChange, TokenPendingChange}; +use crate::guards; use crate::roles::COMPLIANCE_ROLE; use soroban_sdk::{ @@ -12,27 +13,13 @@ use stellar_contract_utils::upgradeable; use stellar_macros::{only_admin, only_role}; use stellar_tokens::{ fungible::{Base, ContractOverrides, FungibleToken}, - rwa::{identity_verification::IdentityVerifierClient, RWAToken, RWA}, + rwa::{RWAToken, RWA}, }; use strata_bindings::VaultBinding; /// Longest order reference a legal transfer accepts, in bytes. pub const MAX_REFERENCE_LEN: u32 = 128; -/// The co-signing admin must be a different account from the caller. -fn require_admin_co_sign(e: &Env, caller: &Address) { - let admin = access_control::enforce_admin_auth(e); - if admin == *caller { - panic_with_error!(e, ShareTokenError::CoSignRequired); - } -} - -fn refuse_vault_address(e: &Env, account: &Address) { - if strata_timelock::setup::source(e).as_ref() == Some(account) { - panic_with_error!(e, ShareTokenError::VaultAddress); - } -} - #[contract] pub struct ShareToken; @@ -123,13 +110,11 @@ impl ShareToken { caller: Address, ) { strata_storage::bump_instance(e); - require_admin_co_sign(e, &caller); - if reference.is_empty() || reference.len() > MAX_REFERENCE_LEN { - panic_with_error!(e, ShareTokenError::InvalidReference); - } - refuse_vault_address(e, &from); - refuse_vault_address(e, &to); - IdentityVerifierClient::new(e, &RWA::identity_verifier(e)).verify_identity(&to); + guards::admin_co_signs(e, &caller); + guards::valid_reference(e, &reference); + guards::not_vault_address(e, &from); + guards::not_vault_address(e, &to); + guards::identity_verified(e, &to); RWA::forced_transfer(e, &from, &to, amount); LegalTransfer { from, @@ -200,7 +185,7 @@ impl FungibleToken for ShareToken { impl AccessControl for ShareToken { /// Instant during setup; after it, propose the grant through the timelock. fn grant_role(e: &Env, account: Address, role: Symbol, caller: Address) { - governance::require_setup_open(e); + guards::direct_change_allowed(e); strata_storage::bump_instance(e); access_control::grant_role(e, &account, &role, &caller); } @@ -208,7 +193,7 @@ impl AccessControl for ShareToken { /// Instant for `compliance`. Revoking `manager` waits like a grant, /// because the vault needs it to pay claims. fn revoke_role(e: &Env, account: Address, role: Symbol, caller: Address) { - governance::require_revoke_allowed(e, &role); + guards::direct_revoke_allowed(e, &role); strata_storage::bump_instance(e); access_control::revoke_role(e, &account, &role, &caller); } @@ -216,9 +201,7 @@ impl AccessControl for ShareToken { /// 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); - } + guards::direct_hand_over_allowed(e, live_until_ledger); strata_storage::bump_instance(e); access_control::transfer_admin_role(e, &new_admin, live_until_ledger); } @@ -267,23 +250,21 @@ impl RWAToken for ShareToken { operator: Address, ) -> bool { strata_storage::bump_instance(e); - require_admin_co_sign(e, &operator); - refuse_vault_address(e, &old_account); - refuse_vault_address(e, &new_account); + guards::admin_co_signs(e, &operator); + guards::not_vault_address(e, &old_account); + guards::not_vault_address(e, &new_account); RWA::recover_balance(e, &old_account, &new_account) } #[only_role(operator, "compliance")] fn set_address_frozen(e: &Env, user_address: Address, freeze: bool, operator: Address) { strata_storage::bump_instance(e); - if freeze { - refuse_vault_address(e, &user_address); - } + guards::not_freezing_vault(e, &user_address, freeze); RWA::set_address_frozen(e, &user_address, freeze); } #[only_role(operator, "compliance")] fn freeze_partial_tokens(e: &Env, user_address: Address, amount: i128, operator: Address) { strata_storage::bump_instance(e); - refuse_vault_address(e, &user_address); + guards::not_vault_address(e, &user_address); RWA::freeze_partial_tokens(e, &user_address, amount); } #[only_role(operator, "compliance")] @@ -294,14 +275,14 @@ impl RWAToken for ShareToken { #[only_admin] fn set_compliance(e: &Env, compliance: Address, operator: Address) { operator.require_auth(); - governance::require_setup_open(e); + guards::direct_change_allowed(e); strata_storage::bump_instance(e); RWA::set_compliance(e, &compliance); } #[only_admin] fn set_identity_verifier(e: &Env, identity_verifier: Address, operator: Address) { operator.require_auth(); - governance::require_setup_open(e); + guards::direct_change_allowed(e); strata_storage::bump_instance(e); RWA::set_identity_verifier(e, &identity_verifier); } diff --git a/contracts/share-token/src/governance.rs b/contracts/share-token/src/governance.rs index 0407a211..8e75c84d 100644 --- a/contracts/share-token/src/governance.rs +++ b/contracts/share-token/src/governance.rs @@ -9,7 +9,7 @@ use strata_timelock::{queue, setup, Clock, Refusal, TimelockHost}; use crate::error::ShareTokenError; use crate::event::{ChangeApplied, ChangeCancelled, ChangeProposed, SetupFinished, VaultBound}; -use crate::roles::MANAGER_ROLE; +use crate::guards; use crate::ShareToken; /// Seven days of five-second ledgers: how long the next admin has to accept a @@ -63,28 +63,6 @@ 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, ShareTokenError::UseTimelock); - } -} - -/// Taking `manager` away stops every claim, so after setup it waits like a -/// grant. Taking any other role away moves no value and stays instant. -pub(crate) fn require_revoke_allowed(e: &Env, role: &Symbol) { - if *role == MANAGER_ROLE { - require_setup_open(e); - } -} - -fn upgrade_pending(e: &Env) -> bool { - queue::open_ids(e).iter().any(|id| { - queue::pending::(e, id) - .is_some_and(|(change, _)| matches!(change, TokenChange::Upgrade(_))) - }) -} - pub(crate) fn bind_vault(e: &Env, vault: &Address) { setup::bind_source(e, vault).unwrap_or_else(|r| refuse(e, r)); VaultBound { @@ -93,22 +71,15 @@ pub(crate) fn bind_vault(e: &Env, vault: &Address) { .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, ShareTokenError::AdminTransferPending); - } + guards::setup_open(e); + guards::no_hand_over_pending(e); setup::finish::(e).unwrap_or_else(|r| refuse(e, r)); SetupFinished {}.publish(e); } pub(crate) fn propose(e: &Env, change: TokenChange) -> u32 { - if matches!(change, TokenChange::Upgrade(_)) && upgrade_pending(e) { - panic_with_error!(e, ShareTokenError::UpgradeAlreadyPending); - } + guards::no_upgrade_pending(e, &change); 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)); diff --git a/contracts/share-token/src/guards.rs b/contracts/share-token/src/guards.rs new file mode 100644 index 00000000..d0682923 --- /dev/null +++ b/contracts/share-token/src/guards.rs @@ -0,0 +1,93 @@ +use soroban_sdk::{panic_with_error, Address, Env, String, Symbol}; +use stellar_access::{ + access_control::{self, AccessControlStorageKey}, + role_transfer, +}; +use stellar_tokens::rwa::{identity_verification::IdentityVerifierClient, RWA}; +use strata_timelock::{queue, setup}; + +use crate::error::ShareTokenError; +use crate::governance::TokenChange; +use crate::roles::MANAGER_ROLE; +use crate::MAX_REFERENCE_LEN; + +pub(crate) fn setup_open(e: &Env) { + if !setup::is_open(e) { + panic_with_error!(e, ShareTokenError::SetupClosed); + } +} + +/// No nominee from setup may accept once changes wait, so a hand-over offered +/// during setup holds it open. +pub(crate) fn no_hand_over_pending(e: &Env) { + if role_transfer::has_active_pending_transfer(e, &AccessControlStorageKey::PendingAdmin) { + panic_with_error!(e, ShareTokenError::AdminTransferPending); + } +} + +/// Direct admin calls are instant only before setup finishes. +pub(crate) fn direct_change_allowed(e: &Env) { + if !setup::is_open(e) { + panic_with_error!(e, ShareTokenError::UseTimelock); + } +} + +/// Taking `manager` away stops every claim, so after setup it waits like a +/// grant. Taking any other role away moves no value and stays instant. +pub(crate) fn direct_revoke_allowed(e: &Env, role: &Symbol) { + if *role == MANAGER_ROLE { + direct_change_allowed(e); + } +} + +/// Only an offer needs setup open; cancelling one (`0`) keeps the admin. +pub(crate) fn direct_hand_over_allowed(e: &Env, live_until_ledger: u32) { + if live_until_ledger != 0 { + direct_change_allowed(e); + } +} + +pub(crate) fn no_upgrade_pending(e: &Env, change: &TokenChange) { + if matches!(change, TokenChange::Upgrade(_)) && upgrade_pending(e) { + panic_with_error!(e, ShareTokenError::UpgradeAlreadyPending); + } +} + +fn upgrade_pending(e: &Env) -> bool { + queue::open_ids(e).iter().any(|id| { + queue::pending::(e, id) + .is_some_and(|(change, _)| matches!(change, TokenChange::Upgrade(_))) + }) +} + +/// The admin signs too, and is a different account from the caller. +pub(crate) fn admin_co_signs(e: &Env, caller: &Address) { + let admin = access_control::enforce_admin_auth(e); + if admin == *caller { + panic_with_error!(e, ShareTokenError::CoSignRequired); + } +} + +pub(crate) fn valid_reference(e: &Env, reference: &String) { + if reference.is_empty() || reference.len() > MAX_REFERENCE_LEN { + panic_with_error!(e, ShareTokenError::InvalidReference); + } +} + +pub(crate) fn not_vault_address(e: &Env, account: &Address) { + if setup::source(e).as_ref() == Some(account) { + panic_with_error!(e, ShareTokenError::VaultAddress); + } +} + +/// Unfreezing the vault's address stays possible; freezing it never is. +pub(crate) fn not_freezing_vault(e: &Env, account: &Address, freeze: bool) { + if freeze { + not_vault_address(e, account); + } +} + +/// Fails with the identity verifier's own error. +pub(crate) fn identity_verified(e: &Env, account: &Address) { + IdentityVerifierClient::new(e, &RWA::identity_verifier(e)).verify_identity(account); +} diff --git a/contracts/share-token/src/lib.rs b/contracts/share-token/src/lib.rs index a4ac4d7f..5dbd9b0e 100644 --- a/contracts/share-token/src/lib.rs +++ b/contracts/share-token/src/lib.rs @@ -3,6 +3,7 @@ mod contract; mod error; mod event; mod governance; +mod guards; mod roles; pub use contract::*; pub use error::ShareTokenError; @@ -15,3 +16,5 @@ pub use governance::{TokenChange, TokenPendingChange, ADMIN_ACCEPT_LEDGERS}; mod test; #[cfg(test)] mod test_governance; +#[cfg(test)] +mod test_guards; diff --git a/contracts/share-token/src/test_guards.rs b/contracts/share-token/src/test_guards.rs new file mode 100644 index 00000000..608ba7f4 --- /dev/null +++ b/contracts/share-token/src/test_guards.rs @@ -0,0 +1,243 @@ +//! Each guard passes while its condition holds and refuses with its error once +//! it does not. + +use soroban_sdk::{ + contract, contractimpl, symbol_short, testutils::Address as _, Address, BytesN, Env, + InvokeError, String, Symbol, +}; +use stellar_tokens::rwa::RWAError; +use strata_timelock::{Clock, MIN_DELAY}; + +use crate::guards; +use crate::{ShareToken, ShareTokenClient, ShareTokenError, TokenChange, MAX_REFERENCE_LEN}; +use identity_verifier::{IdentityVerifier, IdentityVerifierClient}; + +/// Plays the vault: the token binds only to an address that reports a clock. +#[contract] +struct VaultStub; + +#[contractimpl(contracttrait)] +impl strata_timelock::ClockSource for VaultStub { + fn timelock_clock(_e: &Env) -> Clock { + Clock { + delay: MIN_DELAY, + exit_window: 0, + paused_secs: 0, + paused: false, + winding_down: false, + } + } +} + +struct Fixture<'a> { + e: Env, + token: ShareTokenClient<'a>, + identity: IdentityVerifierClient<'a>, + vault: Address, + admin: Address, + compliance_authority: Address, +} + +impl Fixture<'_> { + fn finish_setup(&self) { + self.token.bind_vault(&self.vault, &self.admin); + self.token.finish_setup(&self.admin); + } +} + +fn setup<'a>() -> Fixture<'a> { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance_authority = Address::generate(&e); + let identity = IdentityVerifierClient::new(&e, &e.register(IdentityVerifier, (admin.clone(),))); + let token = ShareTokenClient::new( + &e, + &e.register( + ShareToken, + ( + String::from_str(&e, "Strata Vault USDC"), + String::from_str(&e, "bvUSDC"), + admin.clone(), + compliance_authority.clone(), + Address::generate(&e), + identity.address.clone(), + ), + ), + ); + let vault = e.register(VaultStub, ()); + Fixture { + e, + token, + identity, + vault, + admin, + compliance_authority, + } +} + +/// Runs `guard` inside the token, as an entrypoint would. +fn run(f: &Fixture, guard: impl FnOnce()) -> Result<(), Result> { + f.e.try_as_contract::<(), soroban_sdk::Error>(&f.token.address, guard) +} + +fn holds(f: &Fixture, guard: impl FnOnce()) { + assert_eq!(run(f, guard), Ok(())); +} + +fn refuses(f: &Fixture, error: impl Into, guard: impl FnOnce()) { + assert_eq!(run(f, guard), Err(Ok(error.into()))); +} + +#[test] +fn setup_open_refuses_once_setup_has_finished() { + let f = setup(); + holds(&f, || guards::setup_open(&f.e)); + + f.finish_setup(); + refuses(&f, ShareTokenError::SetupClosed, || { + guards::setup_open(&f.e) + }); +} + +#[test] +fn no_hand_over_pending_refuses_while_an_offer_is_open() { + let f = setup(); + holds(&f, || guards::no_hand_over_pending(&f.e)); + + let next = Address::generate(&f.e); + f.token + .transfer_admin_role(&next, &(f.e.ledger().sequence() + 100)); + refuses(&f, ShareTokenError::AdminTransferPending, || { + guards::no_hand_over_pending(&f.e) + }); + + f.token.transfer_admin_role(&next, &0); + holds(&f, || guards::no_hand_over_pending(&f.e)); +} + +#[test] +fn no_upgrade_pending_refuses_a_second_upgrade() { + let f = setup(); + f.finish_setup(); + let upgrade = TokenChange::Upgrade(BytesN::from_array(&f.e, &[1; 32])); + let grant = TokenChange::GrantRole(symbol_short!("manager"), Address::generate(&f.e)); + holds(&f, || guards::no_upgrade_pending(&f.e, &upgrade)); + + f.token.propose_change(&upgrade, &f.admin); + refuses(&f, ShareTokenError::UpgradeAlreadyPending, || { + guards::no_upgrade_pending(&f.e, &upgrade) + }); + holds(&f, || guards::no_upgrade_pending(&f.e, &grant)); +} + +#[test] +fn admin_co_signs_refuses_one_account_signing_as_both() { + let f = setup(); + holds(&f, || guards::admin_co_signs(&f.e, &f.compliance_authority)); + refuses(&f, ShareTokenError::CoSignRequired, || { + guards::admin_co_signs(&f.e, &f.admin) + }); + + f.e.set_auths(&[]); + assert!(run(&f, || guards::admin_co_signs(&f.e, &f.compliance_authority)).is_err()); +} + +#[test] +fn valid_reference_refuses_an_empty_or_overlong_reference() { + let f = setup(); + let bytes = [b'x'; MAX_REFERENCE_LEN as usize + 1]; + let reference = |len: u32| String::from_bytes(&f.e, &bytes[..len as usize]); + let (empty, shortest, longest, overlong) = ( + reference(0), + reference(1), + reference(MAX_REFERENCE_LEN), + reference(MAX_REFERENCE_LEN + 1), + ); + + holds(&f, || guards::valid_reference(&f.e, &shortest)); + holds(&f, || guards::valid_reference(&f.e, &longest)); + refuses(&f, ShareTokenError::InvalidReference, || { + guards::valid_reference(&f.e, &empty) + }); + refuses(&f, ShareTokenError::InvalidReference, || { + guards::valid_reference(&f.e, &overlong) + }); +} + +#[test] +fn not_vault_address_refuses_the_bound_vault() { + let f = setup(); + let holder = Address::generate(&f.e); + holds(&f, || guards::not_vault_address(&f.e, &f.vault)); + + f.token.bind_vault(&f.vault, &f.admin); + refuses(&f, ShareTokenError::VaultAddress, || { + guards::not_vault_address(&f.e, &f.vault) + }); + holds(&f, || guards::not_vault_address(&f.e, &holder)); +} + +#[test] +fn not_freezing_vault_refuses_only_a_freeze_of_the_vault() { + let f = setup(); + let holder = Address::generate(&f.e); + f.token.bind_vault(&f.vault, &f.admin); + + refuses(&f, ShareTokenError::VaultAddress, || { + guards::not_freezing_vault(&f.e, &f.vault, true) + }); + holds(&f, || guards::not_freezing_vault(&f.e, &f.vault, false)); + holds(&f, || guards::not_freezing_vault(&f.e, &holder, true)); +} + +#[test] +fn identity_verified_refuses_an_account_the_verifier_rejects() { + let f = setup(); + let listed = Address::generate(&f.e); + let stranger = Address::generate(&f.e); + f.identity.allow(&listed, &true, &f.admin); + + holds(&f, || guards::identity_verified(&f.e, &listed)); + refuses(&f, RWAError::IdentityVerificationFailed, || { + guards::identity_verified(&f.e, &stranger) + }); +} + +#[test] +fn direct_change_allowed_refuses_after_setup() { + let f = setup(); + holds(&f, || guards::direct_change_allowed(&f.e)); + + f.finish_setup(); + refuses(&f, ShareTokenError::UseTimelock, || { + guards::direct_change_allowed(&f.e) + }); +} + +#[test] +fn direct_revoke_allowed_refuses_only_the_manager_role_after_setup() { + let f = setup(); + let compliance = Symbol::new(&f.e, "compliance"); + holds(&f, || { + guards::direct_revoke_allowed(&f.e, &symbol_short!("manager")) + }); + + f.finish_setup(); + holds(&f, || guards::direct_revoke_allowed(&f.e, &compliance)); + refuses(&f, ShareTokenError::UseTimelock, || { + guards::direct_revoke_allowed(&f.e, &symbol_short!("manager")) + }); +} + +#[test] +fn direct_hand_over_allowed_refuses_only_an_offer_after_setup() { + let f = setup(); + holds(&f, || guards::direct_hand_over_allowed(&f.e, 100)); + + f.finish_setup(); + holds(&f, || guards::direct_hand_over_allowed(&f.e, 0)); + refuses(&f, ShareTokenError::UseTimelock, || { + guards::direct_hand_over_allowed(&f.e, 100) + }); +} From 714c96b030a55ae5fdd7555a4b9bab8d69069331 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:18:03 -0300 Subject: [PATCH 074/147] refactor: name the oracle's missing config error --- contracts/nav-oracle/src/lib.rs | 3 +++ contracts/nav-oracle/src/state.rs | 7 +++++-- contracts/nav-oracle/src/test.rs | 13 +++++++++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/contracts/nav-oracle/src/lib.rs b/contracts/nav-oracle/src/lib.rs index 12eecb99..3484529b 100644 --- a/contracts/nav-oracle/src/lib.rs +++ b/contracts/nav-oracle/src/lib.rs @@ -83,6 +83,9 @@ pub enum OracleError { /// once it is accepted, cancelled or expired, so no nominee from setup can /// accept after changes start to wait. AdminTransferPending = 3025, + /// The configuration the constructor writes is absent from instance + /// storage. + NotInitialized = 3026, } #[contractevent] diff --git a/contracts/nav-oracle/src/state.rs b/contracts/nav-oracle/src/state.rs index 81786870..d0e74860 100644 --- a/contracts/nav-oracle/src/state.rs +++ b/contracts/nav-oracle/src/state.rs @@ -1,4 +1,6 @@ -use soroban_sdk::{contracttype, symbol_short, Env, Symbol}; +use soroban_sdk::{contracttype, panic_with_error, symbol_short, Env, Symbol}; + +use crate::OracleError; /// The three-state feed health // @@ -53,7 +55,8 @@ pub(crate) fn set_config(e: &Env, config: &OracleConfig) { } pub(crate) fn get_config(e: &Env) -> OracleConfig { - strata_storage::get_instance(e, &DataKey::Config).unwrap() + strata_storage::get_instance(e, &DataKey::Config) + .unwrap_or_else(|| panic_with_error!(e, OracleError::NotInitialized)) } pub(crate) fn set_nav_paused(e: &Env, paused: bool) { diff --git a/contracts/nav-oracle/src/test.rs b/contracts/nav-oracle/src/test.rs index bd435db8..29eb21d0 100644 --- a/contracts/nav-oracle/src/test.rs +++ b/contracts/nav-oracle/src/test.rs @@ -667,3 +667,16 @@ fn the_oracle_answers_the_feed_interface_the_vault_reads() { assert_eq!(feed.attested_at(), 10_000); assert_eq!(feed.freshness_duration(), 3600); } + +#[test] +fn a_missing_config_is_refused_by_name() { + let f = setup(); + f.e.as_contract(&f.oracle.address, || { + f.e.storage().instance().remove(&DataKey::Config) + }); + + assert_eq!( + f.oracle.try_config(), + Err(Ok(crate::OracleError::NotInitialized.into())) + ); +} From b90360247a6593a32376c272f062c175875eae30 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:19:51 -0300 Subject: [PATCH 075/147] refactor: name oracle preconditions as guards --- contracts/nav-oracle/src/governance.rs | 42 +-- contracts/nav-oracle/src/guards.rs | 132 +++++++++ contracts/nav-oracle/src/lib.rs | 79 ++--- contracts/nav-oracle/src/test_guards.rs | 367 ++++++++++++++++++++++++ 4 files changed, 525 insertions(+), 95 deletions(-) create mode 100644 contracts/nav-oracle/src/guards.rs create mode 100644 contracts/nav-oracle/src/test_guards.rs diff --git a/contracts/nav-oracle/src/governance.rs b/contracts/nav-oracle/src/governance.rs index aafa501d..42d40bd7 100644 --- a/contracts/nav-oracle/src/governance.rs +++ b/contracts/nav-oracle/src/governance.rs @@ -6,10 +6,11 @@ use stellar_access::{ use stellar_contract_utils::upgradeable; use strata_timelock::{queue, setup, Clock, Refusal, TimelockHost}; +use crate::guards; use crate::state::OracleConfig; use crate::{ - store_config, validate_config, ChangeApplied, ChangeCancelled, ChangeProposed, - NavOracleContract, OracleError, SetupFinished, VaultBound, + store_config, ChangeApplied, ChangeCancelled, ChangeProposed, NavOracleContract, OracleError, + SetupFinished, VaultBound, }; /// Seven days of five-second ledgers: how long the next admin has to accept a @@ -75,20 +76,6 @@ 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 { @@ -97,25 +84,16 @@ pub(crate) fn bind_vault(e: &Env, vault: &Address) { .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); - } + guards::setup_open(e); + guards::no_hand_over_pending(e); 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); - } + guards::change_in_bounds(e, &change); + guards::no_upgrade_pending(e, &change); 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)); @@ -139,6 +117,7 @@ pub(crate) fn apply(e: &Env, id: u32) { 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)); + guards::change_in_bounds(e, &change); match &change { OracleChange::Upgrade(hash) => upgradeable::upgrade(e, hash), OracleChange::GrantRole(role, account) => { @@ -148,10 +127,7 @@ pub(crate) fn apply(e: &Env, id: u32) { 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); - } + OracleChange::Config(config) => store_config(e, config), } ChangeApplied { id, change }.publish(e); } diff --git a/contracts/nav-oracle/src/guards.rs b/contracts/nav-oracle/src/guards.rs new file mode 100644 index 00000000..1349322f --- /dev/null +++ b/contracts/nav-oracle/src/guards.rs @@ -0,0 +1,132 @@ +use soroban_sdk::{panic_with_error, Env}; +use stellar_access::{access_control::AccessControlStorageKey, role_transfer}; +use strata_timelock::{queue, setup}; + +use crate::state::{self, get_config, NavReport, OracleConfig, OracleState}; +use crate::{ + compute_state, in_band, shortens_freshness_only, OracleChange, OracleError, BPS_DENOM, + MAX_ANSWER, MAX_FRESHNESS_SECS, MIN_COOLDOWN_SECS, +}; + +pub(crate) fn valid_config(e: &Env, cfg: &OracleConfig) { + if cfg.freshness_duration == 0 + || cfg.freshness_duration > MAX_FRESHNESS_SECS + || cfg.cooldown_secs < MIN_COOLDOWN_SECS + || cfg.cooldown_secs > cfg.freshness_duration + || cfg.min_answer <= 0 + || cfg.max_answer < cfg.min_answer + || cfg.max_answer > MAX_ANSWER + || !bps_in_range(cfg.max_up_bps) + || !bps_in_range(cfg.max_down_bps) + { + panic_with_error!(e, OracleError::InvalidConfig); + } +} + +fn bps_in_range(bps: u32) -> bool { + bps > 0 && bps as i128 <= BPS_DENOM +} + +pub(crate) fn setup_open(e: &Env) { + if !setup::is_open(e) { + panic_with_error!(e, OracleError::SetupClosed); + } +} + +/// No nominee from setup may accept once changes wait, so a hand-over offered +/// during setup holds it open. +pub(crate) fn no_hand_over_pending(e: &Env) { + if role_transfer::has_active_pending_transfer(e, &AccessControlStorageKey::PendingAdmin) { + panic_with_error!(e, OracleError::AdminTransferPending); + } +} + +/// Direct admin calls are instant only before setup finishes. +pub(crate) fn direct_change_allowed(e: &Env) { + if !setup::is_open(e) { + panic_with_error!(e, OracleError::UseTimelock); + } +} + +/// Only an offer needs setup open; cancelling one (`0`) keeps the admin. +pub(crate) fn direct_hand_over_allowed(e: &Env, live_until_ledger: u32) { + if live_until_ledger != 0 { + direct_change_allowed(e); + } +} + +pub(crate) fn direct_config_allowed(e: &Env, config: &OracleConfig) { + if !setup::is_open(e) && !shortens_freshness_only(&get_config(e), config) { + panic_with_error!(e, OracleError::UseTimelock); + } +} + +/// The bounds a change must meet when it is proposed and again when it +/// applies. +pub(crate) fn change_in_bounds(e: &Env, change: &OracleChange) { + if let OracleChange::Config(config) = change { + valid_config(e, config); + } +} + +pub(crate) fn no_upgrade_pending(e: &Env, change: &OracleChange) { + if matches!(change, OracleChange::Upgrade(_)) && upgrade_pending(e) { + panic_with_error!(e, OracleError::UpgradeAlreadyPending); + } +} + +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 consumable(e: &Env) { + if compute_state(e) != OracleState::Valid { + panic_with_error!(e, OracleError::NotConsumable); + } +} + +pub(crate) fn nav_not_paused(e: &Env) { + if state::nav_paused(e) { + panic_with_error!(e, OracleError::NavPaused); + } +} + +pub(crate) fn nav_paused(e: &Env) { + if !state::nav_paused(e) { + panic_with_error!(e, OracleError::NavNotPaused); + } +} + +pub(crate) fn nav_in_band(e: &Env, cfg: &OracleConfig, nav_per_share: i128) { + if !in_band(cfg, nav_per_share) { + panic_with_error!(e, OracleError::NavOutOfBand); + } +} + +pub(crate) fn expires_in_future(e: &Env, expires_at: u64) { + if expires_at <= e.ledger().timestamp() { + panic_with_error!(e, OracleError::ExpiresInPast); + } +} + +pub(crate) fn cooldown_elapsed(e: &Env, cfg: &OracleConfig, last: &NavReport) { + if e.ledger().timestamp() < last.timestamp.saturating_add(cfg.cooldown_secs) { + panic_with_error!(e, OracleError::CooldownActive); + } +} + +pub(crate) fn within_deviation(e: &Env, cfg: &OracleConfig, last: &NavReport, nav_per_share: i128) { + let bps = if nav_per_share >= last.nav_per_share { + cfg.max_up_bps + } else { + cfg.max_down_bps + }; + let diff = (nav_per_share - last.nav_per_share).abs(); + let bound = last.nav_per_share * (bps as i128) / BPS_DENOM; + if diff > bound { + panic_with_error!(e, OracleError::DeviationExceeded); + } +} diff --git a/contracts/nav-oracle/src/lib.rs b/contracts/nav-oracle/src/lib.rs index 3484529b..bda8316e 100644 --- a/contracts/nav-oracle/src/lib.rs +++ b/contracts/nav-oracle/src/lib.rs @@ -1,6 +1,7 @@ #![no_std] mod governance; +mod guards; mod state; use soroban_sdk::{ @@ -144,25 +145,6 @@ pub struct ConfigSet { pub max_answer: i128, } -fn bps_in_range(bps: u32) -> bool { - bps > 0 && bps as i128 <= BPS_DENOM -} - -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 - || cfg.cooldown_secs > cfg.freshness_duration - || cfg.min_answer <= 0 - || cfg.max_answer < cfg.min_answer - || cfg.max_answer > MAX_ANSWER - || !bps_in_range(cfg.max_up_bps) - || !bps_in_range(cfg.max_down_bps) - { - panic_with_error!(e, OracleError::InvalidConfig); - } -} - pub(crate) fn store_config(e: &Env, config: &OracleConfig) { state::set_config(e, config); ConfigSet { @@ -204,7 +186,7 @@ impl AccessControl for NavOracleContract { /// 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); + guards::direct_change_allowed(e); strata_storage::bump_instance(e); access_control::grant_role(e, &account, &role, &caller); } @@ -212,9 +194,7 @@ impl AccessControl for NavOracleContract { /// 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); - } + guards::direct_hand_over_allowed(e, live_until_ledger); strata_storage::bump_instance(e); access_control::transfer_admin_role(e, &new_admin, live_until_ledger); } @@ -253,7 +233,7 @@ impl NavOracleContract { guardian: Address, config: OracleConfig, ) { - validate_config(e, &config); + guards::valid_config(e, &config); access_control::set_admin(e, &admin); access_control::grant_role_no_auth(e, &attester, &ATTESTER_ROLE, &admin); access_control::grant_role_no_auth(e, &guardian, &GUARDIAN_ROLE, &admin); @@ -324,44 +304,22 @@ impl NavOracleContract { } pub fn ensure_consumable(e: &Env) { - if compute_state(e) != OracleState::Valid { - panic_with_error!(e, OracleError::NotConsumable); - } + guards::consumable(e); } #[only_role(caller, "attester")] pub fn attest(e: &Env, report: NavReport, caller: Address) { strata_storage::bump_instance(e); - if nav_paused(e) { - panic_with_error!(e, OracleError::NavPaused); - } + guards::nav_not_paused(e); let cfg = get_config(e); - let now = e.ledger().timestamp(); - - if !in_band(&cfg, report.nav_per_share) { - panic_with_error!(e, OracleError::NavOutOfBand); - } - if report.expires_at <= now { - // should never happen - panic_with_error!(e, OracleError::ExpiresInPast); - } - - if let Some(prev) = get_latest(e) { - if now < prev.timestamp.saturating_add(cfg.cooldown_secs) { - panic_with_error!(e, OracleError::CooldownActive); - } - let bps = if report.nav_per_share >= prev.nav_per_share { - cfg.max_up_bps - } else { - cfg.max_down_bps - }; - let diff = (report.nav_per_share - prev.nav_per_share).abs(); - let bound = prev.nav_per_share * (bps as i128) / BPS_DENOM; - if diff > bound { - panic_with_error!(e, OracleError::DeviationExceeded); - } + guards::nav_in_band(e, &cfg, report.nav_per_share); + guards::expires_in_future(e, report.expires_at); + if let Some(last) = get_latest(e) { + guards::cooldown_elapsed(e, &cfg, &last); + guards::within_deviation(e, &cfg, &last, report.nav_per_share); } + let now = e.ledger().timestamp(); let stored = NavReport { nav_per_share: report.nav_per_share, timestamp: now, @@ -398,9 +356,7 @@ impl NavOracleContract { #[only_admin] pub fn clear_latest(e: &Env, caller: Address) { caller.require_auth(); - if !nav_paused(e) { - panic_with_error!(e, OracleError::NavNotPaused); - } + guards::nav_paused(e); state::clear_latest(e); LatestCleared {}.publish(e); } @@ -414,11 +370,8 @@ impl NavOracleContract { /// waits. Propose it as `OracleChange::Config`. #[only_admin] pub fn set_config(e: &Env, config: OracleConfig) { - validate_config(e, &config); - if !strata_timelock::setup::is_open(e) && !shortens_freshness_only(&get_config(e), &config) - { - panic_with_error!(e, OracleError::UseTimelock); - } + guards::valid_config(e, &config); + guards::direct_config_allowed(e, &config); store_config(e, &config); } } @@ -475,3 +428,5 @@ impl NavOracleContract { mod test; #[cfg(test)] mod test_governance; +#[cfg(test)] +mod test_guards; diff --git a/contracts/nav-oracle/src/test_guards.rs b/contracts/nav-oracle/src/test_guards.rs new file mode 100644 index 00000000..be24b5b2 --- /dev/null +++ b/contracts/nav-oracle/src/test_guards.rs @@ -0,0 +1,367 @@ +//! Each guard passes while its condition holds and refuses with its error once +//! it does not. + +use soroban_sdk::{ + contract, contractimpl, symbol_short, + testutils::{Address as _, Ledger as _}, + Address, BytesN, Env, InvokeError, +}; +use strata_timelock::{Clock, MIN_DELAY}; + +use crate::guards; +use crate::{ + NavOracleContract, NavOracleContractClient, NavReport, OracleChange, OracleConfig, OracleError, + MAX_ANSWER, MAX_FRESHNESS_SECS, +}; + +const SCALE: i128 = 1_000_000_000_000_000_000; + +/// Plays the vault: the oracle binds only to an address that reports a clock. +#[contract] +struct VaultStub; + +#[contractimpl(contracttrait)] +impl strata_timelock::ClockSource for VaultStub { + fn timelock_clock(_e: &Env) -> Clock { + Clock { + delay: MIN_DELAY, + exit_window: 0, + paused_secs: 0, + paused: false, + winding_down: false, + } + } +} + +struct Fixture<'a> { + e: Env, + oracle: NavOracleContractClient<'a>, + vault: Address, + admin: Address, + attester: Address, + guardian: Address, +} + +impl Fixture<'_> { + fn finish_setup(&self) { + self.oracle.bind_vault(&self.vault, &self.admin); + self.oracle.finish_setup(&self.admin); + } + + fn attest(&self, nav_per_share: i128) { + self.oracle.attest( + &NavReport { + nav_per_share, + expires_at: u64::MAX, + timestamp: 0, + }, + &self.attester, + ); + } +} + +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 setup<'a>() -> 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 = NavOracleContractClient::new( + &e, + &e.register( + NavOracleContract, + (admin.clone(), attester.clone(), guardian.clone(), config()), + ), + ); + let vault = e.register(VaultStub, ()); + Fixture { + e, + oracle, + vault, + admin, + attester, + guardian, + } +} + +/// Runs `guard` inside the oracle, as an entrypoint would. +fn run(f: &Fixture, guard: impl FnOnce()) -> Result<(), Result> { + f.e.try_as_contract::<(), soroban_sdk::Error>(&f.oracle.address, guard) +} + +fn holds(f: &Fixture, guard: impl FnOnce()) { + assert_eq!(run(f, guard), Ok(())); +} + +fn refuses(f: &Fixture, error: OracleError, guard: impl FnOnce()) { + assert_eq!(run(f, guard), Err(Ok(error.into()))); +} + +fn report_at(nav_per_share: i128, timestamp: u64) -> NavReport { + NavReport { + nav_per_share, + expires_at: u64::MAX, + timestamp, + } +} + +#[test] +fn valid_config_refuses_a_config_outside_the_bounds() { + let f = setup(); + holds(&f, || guards::valid_config(&f.e, &config())); + + for invalid in [ + OracleConfig { + freshness_duration: 0, + ..config() + }, + OracleConfig { + freshness_duration: MAX_FRESHNESS_SECS + 1, + ..config() + }, + OracleConfig { + cooldown_secs: 0, + ..config() + }, + OracleConfig { + cooldown_secs: 3_601, + ..config() + }, + OracleConfig { + min_answer: 0, + ..config() + }, + OracleConfig { + max_answer: SCALE / 4, + ..config() + }, + OracleConfig { + max_answer: MAX_ANSWER + 1, + ..config() + }, + OracleConfig { + max_up_bps: 0, + ..config() + }, + OracleConfig { + max_down_bps: 10_001, + ..config() + }, + ] { + refuses(&f, OracleError::InvalidConfig, || { + guards::valid_config(&f.e, &invalid) + }); + } +} + +#[test] +fn setup_open_refuses_once_setup_has_finished() { + let f = setup(); + holds(&f, || guards::setup_open(&f.e)); + + f.finish_setup(); + refuses(&f, OracleError::SetupClosed, || guards::setup_open(&f.e)); +} + +#[test] +fn no_hand_over_pending_refuses_while_an_offer_is_open() { + let f = setup(); + holds(&f, || guards::no_hand_over_pending(&f.e)); + + let next = Address::generate(&f.e); + f.oracle + .transfer_admin_role(&next, &(f.e.ledger().sequence() + 100)); + refuses(&f, OracleError::AdminTransferPending, || { + guards::no_hand_over_pending(&f.e) + }); + + f.oracle.transfer_admin_role(&next, &0); + holds(&f, || guards::no_hand_over_pending(&f.e)); +} + +#[test] +fn direct_change_allowed_refuses_after_setup() { + let f = setup(); + holds(&f, || guards::direct_change_allowed(&f.e)); + + f.finish_setup(); + refuses(&f, OracleError::UseTimelock, || { + guards::direct_change_allowed(&f.e) + }); +} + +#[test] +fn direct_hand_over_allowed_refuses_only_an_offer_after_setup() { + let f = setup(); + holds(&f, || guards::direct_hand_over_allowed(&f.e, 100)); + + f.finish_setup(); + holds(&f, || guards::direct_hand_over_allowed(&f.e, 0)); + refuses(&f, OracleError::UseTimelock, || { + guards::direct_hand_over_allowed(&f.e, 100) + }); +} + +#[test] +fn direct_config_allowed_refuses_all_but_a_shorter_window_after_setup() { + let f = setup(); + let longer_window = OracleConfig { + freshness_duration: 7_200, + ..config() + }; + let shorter_window = OracleConfig { + freshness_duration: 1_800, + ..config() + }; + let tighter_cap = OracleConfig { + max_up_bps: 500, + ..config() + }; + holds(&f, || guards::direct_config_allowed(&f.e, &longer_window)); + + f.finish_setup(); + holds(&f, || guards::direct_config_allowed(&f.e, &config())); + holds(&f, || guards::direct_config_allowed(&f.e, &shorter_window)); + refuses(&f, OracleError::UseTimelock, || { + guards::direct_config_allowed(&f.e, &longer_window) + }); + refuses(&f, OracleError::UseTimelock, || { + guards::direct_config_allowed(&f.e, &tighter_cap) + }); +} + +#[test] +fn change_in_bounds_refuses_an_invalid_config() { + let f = setup(); + let upgrade = OracleChange::Upgrade(BytesN::from_array(&f.e, &[1; 32])); + let grant = OracleChange::GrantRole(symbol_short!("attester"), Address::generate(&f.e)); + let unbounded = OracleChange::Config(OracleConfig { + max_answer: MAX_ANSWER + 1, + ..config() + }); + + holds(&f, || guards::change_in_bounds(&f.e, &upgrade)); + holds(&f, || guards::change_in_bounds(&f.e, &grant)); + holds(&f, || { + guards::change_in_bounds(&f.e, &OracleChange::Config(config())) + }); + refuses(&f, OracleError::InvalidConfig, || { + guards::change_in_bounds(&f.e, &unbounded) + }); +} + +#[test] +fn no_upgrade_pending_refuses_a_second_upgrade() { + let f = setup(); + f.finish_setup(); + let upgrade = OracleChange::Upgrade(BytesN::from_array(&f.e, &[1; 32])); + let grant = OracleChange::GrantRole(symbol_short!("attester"), Address::generate(&f.e)); + holds(&f, || guards::no_upgrade_pending(&f.e, &upgrade)); + + f.oracle.propose_change(&upgrade, &f.admin); + refuses(&f, OracleError::UpgradeAlreadyPending, || { + guards::no_upgrade_pending(&f.e, &upgrade) + }); + holds(&f, || guards::no_upgrade_pending(&f.e, &grant)); +} + +#[test] +fn consumable_refuses_a_feed_that_is_not_valid() { + let f = setup(); + refuses(&f, OracleError::NotConsumable, || guards::consumable(&f.e)); + + f.attest(SCALE); + holds(&f, || guards::consumable(&f.e)); + + f.oracle.pause_nav(&f.guardian); + refuses(&f, OracleError::NotConsumable, || guards::consumable(&f.e)); +} + +#[test] +fn nav_not_paused_refuses_while_the_nav_is_paused() { + let f = setup(); + holds(&f, || guards::nav_not_paused(&f.e)); + + f.oracle.pause_nav(&f.guardian); + refuses(&f, OracleError::NavPaused, || guards::nav_not_paused(&f.e)); +} + +#[test] +fn nav_paused_refuses_until_the_nav_is_paused() { + let f = setup(); + refuses(&f, OracleError::NavNotPaused, || guards::nav_paused(&f.e)); + + f.oracle.pause_nav(&f.guardian); + holds(&f, || guards::nav_paused(&f.e)); +} + +#[test] +fn nav_in_band_refuses_a_value_outside_the_band() { + let f = setup(); + let cfg = config(); + holds(&f, || guards::nav_in_band(&f.e, &cfg, cfg.min_answer)); + holds(&f, || guards::nav_in_band(&f.e, &cfg, cfg.max_answer)); + refuses(&f, OracleError::NavOutOfBand, || { + guards::nav_in_band(&f.e, &cfg, cfg.min_answer - 1) + }); + refuses(&f, OracleError::NavOutOfBand, || { + guards::nav_in_band(&f.e, &cfg, cfg.max_answer + 1) + }); +} + +#[test] +fn expires_in_future_refuses_a_report_born_stale() { + let f = setup(); + let now = f.e.ledger().timestamp(); + holds(&f, || guards::expires_in_future(&f.e, now + 1)); + refuses(&f, OracleError::ExpiresInPast, || { + guards::expires_in_future(&f.e, now) + }); +} + +#[test] +fn cooldown_elapsed_refuses_a_report_inside_the_cooldown() { + let f = setup(); + let cfg = config(); + let now = f.e.ledger().timestamp(); + let elapsed = report_at(SCALE, now - cfg.cooldown_secs); + let running = report_at(SCALE, now - cfg.cooldown_secs + 1); + + holds(&f, || guards::cooldown_elapsed(&f.e, &cfg, &elapsed)); + refuses(&f, OracleError::CooldownActive, || { + guards::cooldown_elapsed(&f.e, &cfg, &running) + }); +} + +#[test] +fn within_deviation_refuses_a_move_past_either_cap() { + let f = setup(); + let cfg = config(); + let last = report_at(SCALE, 0); + let ten_percent = SCALE / 10; + + holds(&f, || { + guards::within_deviation(&f.e, &cfg, &last, SCALE + ten_percent) + }); + holds(&f, || { + guards::within_deviation(&f.e, &cfg, &last, SCALE - ten_percent) + }); + refuses(&f, OracleError::DeviationExceeded, || { + guards::within_deviation(&f.e, &cfg, &last, SCALE + ten_percent + 1) + }); + refuses(&f, OracleError::DeviationExceeded, || { + guards::within_deviation(&f.e, &cfg, &last, SCALE - ten_percent - 1) + }); +} From 7bf0774b94321f850c73e338ede7b5d0d2a3ebd4 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:22:09 -0300 Subject: [PATCH 076/147] test: name the vault tests after the holder --- contracts/async-vault/src/test/multi_epoch.rs | 2 +- contracts/async-vault/src/test/share_returns.rs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/contracts/async-vault/src/test/multi_epoch.rs b/contracts/async-vault/src/test/multi_epoch.rs index 0e19b678..c8996cba 100644 --- a/contracts/async-vault/src/test/multi_epoch.rs +++ b/contracts/async-vault/src/test/multi_epoch.rs @@ -82,7 +82,7 @@ fn the_rounding_residue_stays_in_the_reserve_once_the_epoch_settles() { /// outstanding-request check is keyed per epoch. Claims are taken newest first /// here, which today is allowed. #[test] -fn one_controller_redeems_across_two_epochs() { +fn one_holder_redeems_across_two_epochs() { let f = setup(); let a = f.holder(500); diff --git a/contracts/async-vault/src/test/share_returns.rs b/contracts/async-vault/src/test/share_returns.rs index 41fc12bf..799d38a2 100644 --- a/contracts/async-vault/src/test/share_returns.rs +++ b/contracts/async-vault/src/test/share_returns.rs @@ -34,7 +34,7 @@ fn a_delisted_vault_still_returns_escrowed_shares() { } #[test] -fn a_delisted_controller_cannot_take_shares_back() { +fn a_delisted_holder_cannot_take_shares_back() { let f = setup(); let holder = f.holder(500); let epoch = f.vault.request_redeem(&holder, &200); @@ -49,7 +49,7 @@ fn a_delisted_controller_cannot_take_shares_back() { } #[test] -fn a_frozen_controller_cannot_take_shares_back() { +fn a_frozen_holder_cannot_take_shares_back() { let f = setup(); let holder = f.holder(500); let epoch = f.vault.request_redeem(&holder, &200); @@ -121,7 +121,7 @@ fn a_partial_freeze_does_not_stop_a_return() { } #[test] -fn a_delisted_controller_with_a_worthless_exit_keeps_the_request() { +fn a_delisted_holder_with_a_worthless_exit_keeps_the_request() { let f = setup(); let holder = f.holder(10); let epoch = f.vault.request_redeem(&holder, &1); From f516dd370edd5ea587ffb405ee512a6f6b2467c6 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:16:23 -0300 Subject: [PATCH 077/147] refactor: name compliance preconditions as guards --- contracts/compliance/src/governance.rs | 17 +--- contracts/compliance/src/guards.rs | 33 +++++++ contracts/compliance/src/lib.rs | 13 ++- contracts/compliance/src/test.rs | 1 + contracts/compliance/src/test/guards.rs | 116 ++++++++++++++++++++++++ 5 files changed, 159 insertions(+), 21 deletions(-) create mode 100644 contracts/compliance/src/guards.rs create mode 100644 contracts/compliance/src/test/guards.rs diff --git a/contracts/compliance/src/governance.rs b/contracts/compliance/src/governance.rs index 657f157c..b158b049 100644 --- a/contracts/compliance/src/governance.rs +++ b/contracts/compliance/src/governance.rs @@ -6,6 +6,7 @@ use stellar_access::{ use stellar_tokens::rwa::{compliance, compliance::ComplianceHook, utils::token_binder}; use strata_timelock::{queue, setup, Clock, Refusal, TimelockHost}; +use crate::guards; use crate::{ ChangeApplied, ChangeCancelled, ChangeProposed, Compliance, ComplianceContractError, SetupFinished, VaultBound, @@ -63,13 +64,6 @@ fn vault_clock(e: &Env) -> Clock { ::clock(e).unwrap_or_else(|r| refuse(e, r)) } -/// Direct rule changes are instant only before setup finishes. -pub(crate) fn require_setup_open(e: &Env) { - if !setup::is_open(e) { - panic_with_error!(e, ComplianceContractError::UseTimelock); - } -} - pub(crate) fn bind_vault(e: &Env, vault: &Address) { setup::bind_source(e, vault).unwrap_or_else(|r| refuse(e, r)); VaultBound { @@ -78,14 +72,9 @@ pub(crate) fn bind_vault(e: &Env, vault: &Address) { .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, ComplianceContractError::AdminTransferPending); - } + guards::setup_open(e); + guards::no_hand_over_pending(e); setup::finish::(e).unwrap_or_else(|r| refuse(e, r)); SetupFinished {}.publish(e); } diff --git a/contracts/compliance/src/guards.rs b/contracts/compliance/src/guards.rs new file mode 100644 index 00000000..70cfb52d --- /dev/null +++ b/contracts/compliance/src/guards.rs @@ -0,0 +1,33 @@ +use soroban_sdk::{panic_with_error, Env}; +use stellar_access::{access_control::AccessControlStorageKey, role_transfer}; +use strata_timelock::setup; + +use crate::ComplianceContractError; + +pub(crate) fn setup_open(e: &Env) { + if !setup::is_open(e) { + panic_with_error!(e, ComplianceContractError::SetupClosed); + } +} + +/// No nominee from setup may accept once changes wait, so a hand-over offered +/// during setup holds it open. +pub(crate) fn no_hand_over_pending(e: &Env) { + if role_transfer::has_active_pending_transfer(e, &AccessControlStorageKey::PendingAdmin) { + panic_with_error!(e, ComplianceContractError::AdminTransferPending); + } +} + +/// Direct rule changes are instant only before setup finishes. +pub(crate) fn direct_change_allowed(e: &Env) { + if !setup::is_open(e) { + panic_with_error!(e, ComplianceContractError::UseTimelock); + } +} + +/// Only an offer needs setup open; cancelling one (`0`) keeps the admin. +pub(crate) fn direct_hand_over_allowed(e: &Env, live_until_ledger: u32) { + if live_until_ledger != 0 { + direct_change_allowed(e); + } +} diff --git a/contracts/compliance/src/lib.rs b/contracts/compliance/src/lib.rs index 8340e0ee..bb61ec4d 100644 --- a/contracts/compliance/src/lib.rs +++ b/contracts/compliance/src/lib.rs @@ -3,6 +3,7 @@ #![no_std] mod governance; +mod guards; use soroban_sdk::{ contract, contracterror, contractevent, contractimpl, contractmeta, Address, Env, Vec, @@ -138,9 +139,7 @@ impl Compliance { /// pending hand-over (`live_until_ledger == 0`) keeps the current admin, /// so it stays instant. pub fn transfer_admin_role(e: &Env, new_admin: Address, live_until_ledger: u32) { - if live_until_ledger != 0 { - governance::require_setup_open(e); - } + guards::direct_hand_over_allowed(e, live_until_ledger); strata_storage::bump_instance(e); access_control::transfer_admin_role(e, &new_admin, live_until_ledger); } @@ -172,14 +171,14 @@ impl VaultBinding for Compliance { impl token_binder::TokenBinder for Compliance { #[only_admin] fn bind_token(e: &Env, token: Address, _operator: Address) { - governance::require_setup_open(e); + guards::direct_change_allowed(e); strata_storage::bump_instance(e); token_binder::bind_token(e, &token); } #[only_admin] fn unbind_token(e: &Env, token: Address, _operator: Address) { - governance::require_setup_open(e); + guards::direct_change_allowed(e); strata_storage::bump_instance(e); token_binder::unbind_token(e, &token); } @@ -193,14 +192,14 @@ impl token_binder::TokenBinder for Compliance { impl compliance::Compliance for Compliance { #[only_admin] fn add_module_to(e: &Env, hook: ComplianceHook, module: Address, _operator: Address) { - governance::require_setup_open(e); + guards::direct_change_allowed(e); strata_storage::bump_instance(e); compliance::storage::add_module_to(e, hook, module); } #[only_admin] fn remove_module_from(e: &Env, hook: ComplianceHook, module: Address, _operator: Address) { - governance::require_setup_open(e); + guards::direct_change_allowed(e); strata_storage::bump_instance(e); compliance::storage::remove_module_from(e, hook, module); } diff --git a/contracts/compliance/src/test.rs b/contracts/compliance/src/test.rs index 07de0b74..35d5d75d 100644 --- a/contracts/compliance/src/test.rs +++ b/contracts/compliance/src/test.rs @@ -1,4 +1,5 @@ mod changes; +mod guards; use soroban_sdk::{ testutils::{storage::Instance as _, Address as _, Ledger as _}, diff --git a/contracts/compliance/src/test/guards.rs b/contracts/compliance/src/test/guards.rs new file mode 100644 index 00000000..4ab9bee9 --- /dev/null +++ b/contracts/compliance/src/test/guards.rs @@ -0,0 +1,116 @@ +//! Each guard passes while its condition holds and refuses with its error once +//! it does not. + +use soroban_sdk::{contract, contractimpl, testutils::Address as _, Address, Env, InvokeError}; +use strata_timelock::{Clock, MIN_DELAY}; + +use crate::guards; +use crate::{Compliance, ComplianceClient, ComplianceContractError}; + +/// Plays the vault: the contract binds only to an address that reports a clock. +#[contract] +struct VaultStub; + +#[contractimpl(contracttrait)] +impl strata_timelock::ClockSource for VaultStub { + fn timelock_clock(_e: &Env) -> Clock { + Clock { + delay: MIN_DELAY, + exit_window: 0, + paused_secs: 0, + paused: false, + winding_down: false, + } + } +} + +struct Fixture<'a> { + e: Env, + rules: ComplianceClient<'a>, + vault: Address, + admin: Address, +} + +impl Fixture<'_> { + fn finish_setup(&self) { + self.rules.bind_vault(&self.vault, &self.admin); + self.rules.finish_setup(&self.admin); + } +} + +fn setup<'a>() -> Fixture<'a> { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let rules = ComplianceClient::new(&e, &e.register(Compliance, (admin.clone(),))); + let vault = e.register(VaultStub, ()); + Fixture { + e, + rules, + vault, + admin, + } +} + +/// Runs `guard` inside the compliance contract, as an entrypoint would. +fn run(f: &Fixture, guard: impl FnOnce()) -> Result<(), Result> { + f.e.try_as_contract::<(), soroban_sdk::Error>(&f.rules.address, guard) +} + +fn holds(f: &Fixture, guard: impl FnOnce()) { + assert_eq!(run(f, guard), Ok(())); +} + +fn refuses(f: &Fixture, error: ComplianceContractError, guard: impl FnOnce()) { + assert_eq!(run(f, guard), Err(Ok(error.into()))); +} + +#[test] +fn setup_open_refuses_once_setup_has_finished() { + let f = setup(); + holds(&f, || guards::setup_open(&f.e)); + + f.finish_setup(); + refuses(&f, ComplianceContractError::SetupClosed, || { + guards::setup_open(&f.e) + }); +} + +#[test] +fn no_hand_over_pending_refuses_while_an_offer_is_open() { + let f = setup(); + holds(&f, || guards::no_hand_over_pending(&f.e)); + + let next = Address::generate(&f.e); + f.rules + .transfer_admin_role(&next, &(f.e.ledger().sequence() + 100)); + refuses(&f, ComplianceContractError::AdminTransferPending, || { + guards::no_hand_over_pending(&f.e) + }); + + f.rules.transfer_admin_role(&next, &0); + holds(&f, || guards::no_hand_over_pending(&f.e)); +} + +#[test] +fn direct_change_allowed_refuses_after_setup() { + let f = setup(); + holds(&f, || guards::direct_change_allowed(&f.e)); + + f.finish_setup(); + refuses(&f, ComplianceContractError::UseTimelock, || { + guards::direct_change_allowed(&f.e) + }); +} + +#[test] +fn direct_hand_over_allowed_refuses_only_an_offer_after_setup() { + let f = setup(); + holds(&f, || guards::direct_hand_over_allowed(&f.e, 100)); + + f.finish_setup(); + holds(&f, || guards::direct_hand_over_allowed(&f.e, 0)); + refuses(&f, ComplianceContractError::UseTimelock, || { + guards::direct_hand_over_allowed(&f.e, 100) + }); +} From 5c250470839659ab7a7d3211763e99948f4aa809 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:18:45 -0300 Subject: [PATCH 078/147] refactor: fail loudly on a broken wind-down payout --- contracts/async-vault/src/test/wind_down.rs | 24 +++++++++++++++++++++ contracts/async-vault/src/wind_down.rs | 4 ++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/contracts/async-vault/src/test/wind_down.rs b/contracts/async-vault/src/test/wind_down.rs index dda3f1ab..077d053b 100644 --- a/contracts/async-vault/src/test/wind_down.rs +++ b/contracts/async-vault/src/test/wind_down.rs @@ -867,3 +867,27 @@ fn a_snapshot_with_more_shares_to_burn_than_exist_fails_loudly() { VaultError::AccountingMismatch, ); } + +#[test] +fn a_payout_below_what_was_already_paid_fails_loudly() { + let f = setup(); + let _holder = f.holder(500); + wind_down_now(&f); + f.vault.finalize_wind_down_round(); + let newcomer = f.investor(0); + f.e.as_contract(&f.vault.address, || { + strata_storage::set_persistent( + &f.e, + &crate::keys::DataKey::WindDownPosition(newcomer.clone()), + &crate::WindDownPosition { + entitlement: 0, + paid: 1, + }, + ) + }); + + refused( + f.vault.try_claim_wind_down(&newcomer), + VaultError::AccountingMismatch, + ); +} diff --git a/contracts/async-vault/src/wind_down.rs b/contracts/async-vault/src/wind_down.rs index ebb2ecc4..dbf2ae98 100644 --- a/contracts/async-vault/src/wind_down.rs +++ b/contracts/async-vault/src/wind_down.rs @@ -189,7 +189,7 @@ pub(crate) fn finalize_round(e: &Env) -> i128 { checked_mul_div_floor(e, &snapshot, acc_value, &WAD_SCALE) .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)) }; - let credited = promised(&acc_per_share) - promised(&acc_before); + let credited = state::reduce(e, promised(&acc_per_share), promised(&acc_before)); let owed_total = owed(e) .checked_add(credited) @@ -258,7 +258,7 @@ pub(crate) fn claim(e: &Env, holder: &Address) -> i128 { let earned = checked_mul_div_floor(e, &p.entitlement, &acc(e), &WAD_SCALE) .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)); - let assets = earned - p.paid; + let assets = state::reduce(e, earned, p.paid); guards::has_entitlement(e, surrendered, assets); p.paid = earned; From c34d1691d39985f2ba97cf7f755303d653af6edc Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:20:57 -0300 Subject: [PATCH 079/147] refactor: check guards before the instance bump --- contracts/nav-oracle/src/lib.rs | 2 +- contracts/share-token/src/contract.rs | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/contracts/nav-oracle/src/lib.rs b/contracts/nav-oracle/src/lib.rs index bda8316e..2570e741 100644 --- a/contracts/nav-oracle/src/lib.rs +++ b/contracts/nav-oracle/src/lib.rs @@ -309,7 +309,6 @@ impl NavOracleContract { #[only_role(caller, "attester")] pub fn attest(e: &Env, report: NavReport, caller: Address) { - strata_storage::bump_instance(e); guards::nav_not_paused(e); let cfg = get_config(e); guards::nav_in_band(e, &cfg, report.nav_per_share); @@ -319,6 +318,7 @@ impl NavOracleContract { guards::within_deviation(e, &cfg, &last, report.nav_per_share); } + strata_storage::bump_instance(e); let now = e.ledger().timestamp(); let stored = NavReport { nav_per_share: report.nav_per_share, diff --git a/contracts/share-token/src/contract.rs b/contracts/share-token/src/contract.rs index c7aefcc8..f5a70045 100644 --- a/contracts/share-token/src/contract.rs +++ b/contracts/share-token/src/contract.rs @@ -109,12 +109,12 @@ impl ShareToken { reference: String, caller: Address, ) { - strata_storage::bump_instance(e); guards::admin_co_signs(e, &caller); guards::valid_reference(e, &reference); guards::not_vault_address(e, &from); guards::not_vault_address(e, &to); guards::identity_verified(e, &to); + strata_storage::bump_instance(e); RWA::forced_transfer(e, &from, &to, amount); LegalTransfer { from, @@ -249,22 +249,22 @@ impl RWAToken for ShareToken { new_account: Address, operator: Address, ) -> bool { - strata_storage::bump_instance(e); guards::admin_co_signs(e, &operator); guards::not_vault_address(e, &old_account); guards::not_vault_address(e, &new_account); + strata_storage::bump_instance(e); RWA::recover_balance(e, &old_account, &new_account) } #[only_role(operator, "compliance")] fn set_address_frozen(e: &Env, user_address: Address, freeze: bool, operator: Address) { - strata_storage::bump_instance(e); guards::not_freezing_vault(e, &user_address, freeze); + strata_storage::bump_instance(e); RWA::set_address_frozen(e, &user_address, freeze); } #[only_role(operator, "compliance")] fn freeze_partial_tokens(e: &Env, user_address: Address, amount: i128, operator: Address) { - strata_storage::bump_instance(e); guards::not_vault_address(e, &user_address); + strata_storage::bump_instance(e); RWA::freeze_partial_tokens(e, &user_address, amount); } #[only_role(operator, "compliance")] From fa9072db9329980f90fbc0455da9807727ecbc1d Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:23:03 -0300 Subject: [PATCH 080/147] refactor: tighten guard names, docs and tests --- contracts/async-vault/src/governance.rs | 2 +- contracts/async-vault/src/guards.rs | 17 ++++++---------- contracts/async-vault/src/test/guards.rs | 17 ++++++---------- contracts/async-vault/src/test/wind_down.rs | 2 +- contracts/async-vault/src/wind_down.rs | 2 +- contracts/nav-oracle/src/governance.rs | 2 +- contracts/nav-oracle/src/guards.rs | 3 ++- contracts/nav-oracle/src/test_guards.rs | 8 ++++---- contracts/share-token/src/governance.rs | 2 +- contracts/share-token/src/guards.rs | 3 ++- contracts/share-token/src/test_guards.rs | 22 ++++++++++++++------- 11 files changed, 40 insertions(+), 40 deletions(-) diff --git a/contracts/async-vault/src/governance.rs b/contracts/async-vault/src/governance.rs index 38bf99dc..45c44728 100644 --- a/contracts/async-vault/src/governance.rs +++ b/contracts/async-vault/src/governance.rs @@ -187,7 +187,7 @@ pub(crate) fn propose(e: &Env, change: VaultChange) -> u32 { guards::not_winding_down(e); guards::setup_finished(e); guards::change_in_bounds(e, &change); - guards::no_code_change_pending(e, &change); + guards::not_a_second_code_change(e, &change); let id = queue::propose::(e, &change).unwrap_or_else(|r| refuse(e, r)); ChangeProposed { id, diff --git a/contracts/async-vault/src/guards.rs b/contracts/async-vault/src/guards.rs index dd3cb048..71029fda 100644 --- a/contracts/async-vault/src/guards.rs +++ b/contracts/async-vault/src/guards.rs @@ -30,7 +30,8 @@ pub(crate) fn roles_distinct(e: &Env, roles: &VaultRoles) { } } -/// Called at the top of every entrypoint the wind-down closes. +/// The first check in every entrypoint body the wind-down closes; `apply_change` +/// is closed by the timelock's own refusal instead. pub(crate) fn not_winding_down(e: &Env) { if wind_down::is_active(e) { panic_with_error!(e, VaultError::WindDownActive); @@ -160,7 +161,7 @@ pub(crate) fn change_in_bounds(e: &Env, change: &VaultChange) { } /// One code change at a time, so a standing eta is never quietly replaced. -pub(crate) fn no_code_change_pending(e: &Env, change: &VaultChange) { +pub(crate) fn not_a_second_code_change(e: &Env, change: &VaultChange) { if changes_code(change) && code_change_pending(e) { panic_with_error!(e, VaultError::UpgradeProposalExists); } @@ -282,9 +283,7 @@ pub(crate) fn needs_no_frozen_shares(e: &Env, holder: &Address, shares: i128) { } } -/// Pricing already released the epoch's escrow into the reserve, so a refund -/// is an extra payout rather than part of committed. It comes out of free -/// reserve and must not reach money owed to a priced exit. +/// A refund after pricing comes out of free reserve, never out of money owed to exits. pub(crate) fn refund_covered(e: &Env, amount: i128) { if treasury::free_reserve(e) < amount { panic_with_error!(e, VaultError::ClaimNotCovered); @@ -329,10 +328,7 @@ pub(crate) fn not_priced(e: &Env, epoch: &EpochInfo) { } } -/// Cancellation is an escape hatch, not a choice. Once the feed can price a -/// sealed epoch, the price is knowable and the only way out is to take it. -/// Fulfilment is closed during a wind-down, so this epoch will never take a -/// price. There is nothing to decline. +/// A readable price closes cancellation, except in a wind-down, which prices nothing. pub(crate) fn no_price_available(e: &Env, epoch: &EpochInfo) { if !wind_down::is_active(e) && epoch::is_priceable(e, epoch) { panic_with_error!(e, VaultError::PriceAvailable); @@ -353,8 +349,7 @@ pub(crate) fn allowed_to(e: &Env, exit: Exit, holder: &Address) { } } -/// The lock policy neither refuses nor holds the exit: it goes ahead, or its -/// value moves to an exit. +/// The lock policy neither refuses nor holds the exit. pub(crate) fn not_refused(e: &Env, decision: Decision, standing: Standing) { if matches!(decision, Decision::Refuse | Decision::Hold) { panic_with_error!(e, lock_policy::refused_with(standing)); diff --git a/contracts/async-vault/src/test/guards.rs b/contracts/async-vault/src/test/guards.rs index dea5eaa8..9cfabafa 100644 --- a/contracts/async-vault/src/test/guards.rs +++ b/contracts/async-vault/src/test/guards.rs @@ -5,6 +5,7 @@ use soroban_sdk::InvokeError; use stellar_tokens::rwa::compliance::ComplianceHook; use super::share_returns::RefusesEveryTransfer; +use super::wind_down::wind_down_now; use super::*; use crate::guards; use crate::lock_policy::{Decision, Exit, Standing}; @@ -27,12 +28,6 @@ fn epoch(f: &Fixture, id: u64) -> EpochInfo { f.vault.get_epoch(&id).unwrap() } -fn wind_down_now(f: &Fixture) { - f.vault.propose_wind_down(&f.admin); - f.advance(MIN_WIND_DOWN_DELAY); - f.vault.activate_wind_down(); -} - #[test] fn roles_distinct_refuses_one_account_in_two_seats_kept_apart() { let f = setup(); @@ -323,21 +318,21 @@ fn change_in_bounds_checks_the_bounds_of_each_change() { } #[test] -fn no_code_change_pending_refuses_a_second_code_change() { +fn not_a_second_code_change_refuses_a_code_change_while_one_waits() { let f = setup(); let upgrade = VaultChange::Upgrade(BytesN::from_array(&f.e, &[1; 32])); - holds(&f, || guards::no_code_change_pending(&f.e, &upgrade)); + holds(&f, || guards::not_a_second_code_change(&f.e, &upgrade)); f.vault .propose_change(&VaultChange::UpgradeDelay(MIN_UPGRADE_DELAY), &f.admin); refuses(&f, VaultError::UpgradeProposalExists, || { - guards::no_code_change_pending(&f.e, &upgrade) + guards::not_a_second_code_change(&f.e, &upgrade) }); refuses(&f, VaultError::UpgradeProposalExists, || { - guards::no_code_change_pending(&f.e, &VaultChange::UpgradeDelay(MAX_UPGRADE_DELAY)) + guards::not_a_second_code_change(&f.e, &VaultChange::UpgradeDelay(MAX_UPGRADE_DELAY)) }); holds(&f, || { - guards::no_code_change_pending(&f.e, &VaultChange::Notice(0)) + guards::not_a_second_code_change(&f.e, &VaultChange::Notice(0)) }); } diff --git a/contracts/async-vault/src/test/wind_down.rs b/contracts/async-vault/src/test/wind_down.rs index 077d053b..d8a28c77 100644 --- a/contracts/async-vault/src/test/wind_down.rs +++ b/contracts/async-vault/src/test/wind_down.rs @@ -178,7 +178,7 @@ fn a_delay_below_the_minimum_is_refused() { } /// Proposes, waits and activates. Returns nothing; the vault is active after. -fn wind_down_now(f: &Fixture) { +pub(super) fn wind_down_now(f: &Fixture) { f.vault.propose_wind_down(&f.admin); f.advance(MIN_WIND_DOWN_DELAY); f.vault.activate_wind_down(); diff --git a/contracts/async-vault/src/wind_down.rs b/contracts/async-vault/src/wind_down.rs index dbf2ae98..71edb7aa 100644 --- a/contracts/async-vault/src/wind_down.rs +++ b/contracts/async-vault/src/wind_down.rs @@ -104,8 +104,8 @@ pub(crate) fn cancel_proposal(e: &Env) { } pub(crate) fn activate(e: &Env) { - let mut wd = info(e).unwrap_or_else(|| panic_with_error!(e, VaultError::WindDownNotProposed)); guards::not_winding_down(e); + let mut wd = info(e).unwrap_or_else(|| panic_with_error!(e, VaultError::WindDownNotProposed)); guards::wind_down_delay_elapsed(e, &wd); wd.status = WindDownStatus::Active; diff --git a/contracts/nav-oracle/src/governance.rs b/contracts/nav-oracle/src/governance.rs index 42d40bd7..153bc649 100644 --- a/contracts/nav-oracle/src/governance.rs +++ b/contracts/nav-oracle/src/governance.rs @@ -93,7 +93,7 @@ pub(crate) fn finish_setup(e: &Env) { pub(crate) fn propose(e: &Env, change: OracleChange) -> u32 { guards::change_in_bounds(e, &change); - guards::no_upgrade_pending(e, &change); + guards::not_a_second_upgrade(e, &change); 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)); diff --git a/contracts/nav-oracle/src/guards.rs b/contracts/nav-oracle/src/guards.rs index 1349322f..7d9436d7 100644 --- a/contracts/nav-oracle/src/guards.rs +++ b/contracts/nav-oracle/src/guards.rs @@ -69,7 +69,8 @@ pub(crate) fn change_in_bounds(e: &Env, change: &OracleChange) { } } -pub(crate) fn no_upgrade_pending(e: &Env, change: &OracleChange) { +/// One upgrade at a time, so a standing eta is never quietly replaced. +pub(crate) fn not_a_second_upgrade(e: &Env, change: &OracleChange) { if matches!(change, OracleChange::Upgrade(_)) && upgrade_pending(e) { panic_with_error!(e, OracleError::UpgradeAlreadyPending); } diff --git a/contracts/nav-oracle/src/test_guards.rs b/contracts/nav-oracle/src/test_guards.rs index be24b5b2..934ff514 100644 --- a/contracts/nav-oracle/src/test_guards.rs +++ b/contracts/nav-oracle/src/test_guards.rs @@ -263,18 +263,18 @@ fn change_in_bounds_refuses_an_invalid_config() { } #[test] -fn no_upgrade_pending_refuses_a_second_upgrade() { +fn not_a_second_upgrade_refuses_an_upgrade_while_one_waits() { let f = setup(); f.finish_setup(); let upgrade = OracleChange::Upgrade(BytesN::from_array(&f.e, &[1; 32])); let grant = OracleChange::GrantRole(symbol_short!("attester"), Address::generate(&f.e)); - holds(&f, || guards::no_upgrade_pending(&f.e, &upgrade)); + holds(&f, || guards::not_a_second_upgrade(&f.e, &upgrade)); f.oracle.propose_change(&upgrade, &f.admin); refuses(&f, OracleError::UpgradeAlreadyPending, || { - guards::no_upgrade_pending(&f.e, &upgrade) + guards::not_a_second_upgrade(&f.e, &upgrade) }); - holds(&f, || guards::no_upgrade_pending(&f.e, &grant)); + holds(&f, || guards::not_a_second_upgrade(&f.e, &grant)); } #[test] diff --git a/contracts/share-token/src/governance.rs b/contracts/share-token/src/governance.rs index 8e75c84d..bc4aa2ca 100644 --- a/contracts/share-token/src/governance.rs +++ b/contracts/share-token/src/governance.rs @@ -79,7 +79,7 @@ pub(crate) fn finish_setup(e: &Env) { } pub(crate) fn propose(e: &Env, change: TokenChange) -> u32 { - guards::no_upgrade_pending(e, &change); + guards::not_a_second_upgrade(e, &change); 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)); diff --git a/contracts/share-token/src/guards.rs b/contracts/share-token/src/guards.rs index d0682923..4f09e2cb 100644 --- a/contracts/share-token/src/guards.rs +++ b/contracts/share-token/src/guards.rs @@ -47,7 +47,8 @@ pub(crate) fn direct_hand_over_allowed(e: &Env, live_until_ledger: u32) { } } -pub(crate) fn no_upgrade_pending(e: &Env, change: &TokenChange) { +/// One upgrade at a time, so a standing eta is never quietly replaced. +pub(crate) fn not_a_second_upgrade(e: &Env, change: &TokenChange) { if matches!(change, TokenChange::Upgrade(_)) && upgrade_pending(e) { panic_with_error!(e, ShareTokenError::UpgradeAlreadyPending); } diff --git a/contracts/share-token/src/test_guards.rs b/contracts/share-token/src/test_guards.rs index 608ba7f4..48a3f995 100644 --- a/contracts/share-token/src/test_guards.rs +++ b/contracts/share-token/src/test_guards.rs @@ -2,8 +2,10 @@ //! it does not. use soroban_sdk::{ - contract, contractimpl, symbol_short, testutils::Address as _, Address, BytesN, Env, - InvokeError, String, Symbol, + contract, contractimpl, symbol_short, + testutils::Address as _, + xdr::{ScErrorCode, ScErrorType}, + Address, BytesN, Env, InvokeError, String, Symbol, }; use stellar_tokens::rwa::RWAError; use strata_timelock::{Clock, MIN_DELAY}; @@ -117,18 +119,18 @@ fn no_hand_over_pending_refuses_while_an_offer_is_open() { } #[test] -fn no_upgrade_pending_refuses_a_second_upgrade() { +fn not_a_second_upgrade_refuses_an_upgrade_while_one_waits() { let f = setup(); f.finish_setup(); let upgrade = TokenChange::Upgrade(BytesN::from_array(&f.e, &[1; 32])); let grant = TokenChange::GrantRole(symbol_short!("manager"), Address::generate(&f.e)); - holds(&f, || guards::no_upgrade_pending(&f.e, &upgrade)); + holds(&f, || guards::not_a_second_upgrade(&f.e, &upgrade)); f.token.propose_change(&upgrade, &f.admin); refuses(&f, ShareTokenError::UpgradeAlreadyPending, || { - guards::no_upgrade_pending(&f.e, &upgrade) + guards::not_a_second_upgrade(&f.e, &upgrade) }); - holds(&f, || guards::no_upgrade_pending(&f.e, &grant)); + holds(&f, || guards::not_a_second_upgrade(&f.e, &grant)); } #[test] @@ -140,7 +142,13 @@ fn admin_co_signs_refuses_one_account_signing_as_both() { }); f.e.set_auths(&[]); - assert!(run(&f, || guards::admin_co_signs(&f.e, &f.compliance_authority)).is_err()); + assert_eq!( + run(&f, || guards::admin_co_signs(&f.e, &f.compliance_authority)), + Err(Ok(soroban_sdk::Error::from_type_and_code( + ScErrorType::Auth, + ScErrorCode::InvalidAction + ))) + ); } #[test] From 09898dfc80fd3284b86f51ac02e21cba777874d1 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:07:48 -0300 Subject: [PATCH 081/147] fix: ask the verifier's admin to sign once --- contracts/async-vault/src/test/compliance.rs | 22 ++--- contracts/async-vault/src/test/delisted.rs | 14 +-- contracts/async-vault/src/test/lock_policy.rs | 4 +- contracts/async-vault/src/test/mod.rs | 6 +- contracts/async-vault/src/test/settlement.rs | 2 +- .../async-vault/src/test/share_returns.rs | 6 +- contracts/identity-verifier/src/lib.rs | 3 +- contracts/identity-verifier/src/test.rs | 87 ++++++++++++++++--- contracts/share-token/src/test.rs | 16 ++-- contracts/share-token/src/test_guards.rs | 2 +- scripts/harness/deploy.ts | 1 - scripts/harness/guards.test.ts | 4 - scripts/harness/happy-path.test.ts | 1 - scripts/harness/seed.ts | 1 - scripts/harness/wind-down.test.ts | 1 - 15 files changed, 113 insertions(+), 57 deletions(-) diff --git a/contracts/async-vault/src/test/compliance.rs b/contracts/async-vault/src/test/compliance.rs index ffd539a7..6513e769 100644 --- a/contracts/async-vault/src/test/compliance.rs +++ b/contracts/async-vault/src/test/compliance.rs @@ -24,7 +24,7 @@ fn an_investor_who_was_never_allowlisted_is_refused_before_any_cash_moves() { fn a_delisted_investor_cannot_request_a_deposit() { let f = setup(); let investor = f.investor(1_000); - f.identity.allow(&investor, &false, &f.compliance); + f.identity.allow(&investor, &false); refused( f.vault.try_request_deposit(&investor, &400), @@ -48,9 +48,9 @@ fn relisting_after_pricing_lets_the_claim_through() { let investor = f.investor(1_000); f.vault.request_deposit(&investor, &400); f.fulfill_epoch(wad(2)); - f.identity.allow(&investor, &false, &f.compliance); + f.identity.allow(&investor, &false); - f.identity.allow(&investor, &true, &f.compliance); + f.identity.allow(&investor, &true); assert_eq!(f.vault.claim_deposit(&investor, &1), 200); assert_eq!(f.shares(&investor), 200); @@ -65,7 +65,7 @@ fn a_holder_delisted_after_pricing_is_paid_through_a_wind_down() { f.fulfill_epoch(wad(2)); assert_eq!(f.vault.cancellable_escrow(), 0); assert_eq!(f.vault.pending_mint_shares(), 200); - f.identity.allow(&investor, &false, &f.compliance); + f.identity.allow(&investor, &false); refused( f.vault.try_cancel_deposit(&investor, &epoch), @@ -119,7 +119,7 @@ fn a_delisted_holder_can_still_queue_an_exit() { let user = f.holder(500); let epoch = f.vault.current_epoch(); - f.identity.allow(&user, &false, &f.compliance); + f.identity.allow(&user, &false); assert_eq!(f.vault.request_redeem(&user, &200), epoch); assert_eq!(f.shares(&f.vault.address), 200); @@ -132,7 +132,7 @@ fn a_delisted_holder_can_still_claim_their_exit() { let epoch = f.vault.request_redeem(&user, &200); f.fulfill_epoch(wad(2)); - f.identity.allow(&user, &false, &f.compliance); + f.identity.allow(&user, &false); assert_eq!(f.vault.claim_redeem(&user, &epoch), 400); assert_eq!(f.balance(&user), 400); @@ -145,7 +145,7 @@ fn a_delisted_investor_can_cancel_deposit_and_receive_asset() { let epoch = f.vault.request_deposit(&investor, &400); // Investor gets delisted - f.identity.allow(&investor, &false, &f.compliance); + f.identity.allow(&investor, &false); // Delisted investor can still cancel deposit and receive settlement asset assert_eq!(f.vault.cancel_deposit(&investor, &epoch), 400); @@ -159,7 +159,7 @@ fn a_delisted_holder_cannot_cancel_redemption_back_to_shares() { let epoch = f.vault.request_redeem(&user, &200); // Delisted before pricing - f.identity.allow(&user, &false, &f.compliance); + f.identity.allow(&user, &false); refused( f.vault.try_cancel_redeem(&user, &epoch), @@ -202,11 +202,11 @@ fn a_frozen_or_delisted_holder_cannot_transfer_shares() { assert_eq!(f.shares(&recipient), 100); // Delisted sender cannot transfer - f.identity.allow(&user, &false, &f.compliance); + f.identity.allow(&user, &false); assert!(f.share.try_transfer(&user, &recipient, &100).is_err()); // Restore allowlist, then freeze sender - f.identity.allow(&user, &true, &f.compliance); + f.identity.allow(&user, &true); f.share.set_address_frozen(&user, &true, &f.compliance); assert!(f.share.try_transfer(&user, &recipient, &100).is_err()); } @@ -321,7 +321,7 @@ fn a_frozen_holder_is_not_moved_into_the_exit_queue() { let investor = f.investor(1_000); let epoch = f.vault.request_deposit(&investor, &400); f.fulfill_epoch(wad(2)); - f.identity.allow(&investor, &false, &f.compliance); + f.identity.allow(&investor, &false); f.share.set_address_frozen(&investor, &true, &f.compliance); refused( diff --git a/contracts/async-vault/src/test/delisted.rs b/contracts/async-vault/src/test/delisted.rs index cead24d1..aeae7cd3 100644 --- a/contracts/async-vault/src/test/delisted.rs +++ b/contracts/async-vault/src/test/delisted.rs @@ -9,7 +9,7 @@ fn delisted_with_a_priced_deposit(f: &Fixture) -> (Address, u64) { let investor = f.investor(1_000); let epoch = f.vault.request_deposit(&investor, &400); f.fulfill_epoch(wad(2)); - f.identity.allow(&investor, &false, &f.compliance); + f.identity.allow(&investor, &false); (investor, epoch) } @@ -88,7 +88,7 @@ fn a_conversion_adds_to_a_redemption_already_open_in_the_epoch() { f.fulfill_epoch(wad(2)); let open = f.vault.current_epoch(); f.vault.request_redeem(&investor, &100); - f.identity.allow(&investor, &false, &f.compliance); + f.identity.allow(&investor, &false); f.vault.claim_deposit(&investor, &epoch); @@ -243,7 +243,7 @@ fn a_delisted_holders_unpriced_redemption_joins_the_wind_down_when_cancelled() { let investor = f.holder(200); let other = f.holder(200); let epoch = f.vault.request_redeem(&investor, &200); - f.identity.allow(&investor, &false, &f.compliance); + f.identity.allow(&investor, &false); f.vault.propose_wind_down(&f.admin); f.advance(MIN_WIND_DOWN_DELAY); f.vault.activate_wind_down(); @@ -261,8 +261,8 @@ fn a_conversion_waits_while_the_vault_is_delisted() { let investor = f.investor(1_000); let epoch = f.vault.request_deposit(&investor, &1_000); f.fulfill_epoch(wad(2)); - f.identity.allow(&investor, &false, &f.compliance); - f.identity.allow(&f.vault.address, &false, &f.compliance); + f.identity.allow(&investor, &false); + f.identity.allow(&f.vault.address, &false); assert_eq!( f.vault.try_claim_deposit(&investor, &epoch), @@ -270,7 +270,7 @@ fn a_conversion_waits_while_the_vault_is_delisted() { ); assert!(f.vault.get_deposit_request(&epoch, &investor).is_some()); - f.identity.allow(&f.vault.address, &true, &f.compliance); + f.identity.allow(&f.vault.address, &true); assert_eq!(f.vault.claim_deposit(&investor, &epoch), 0); let open = f.vault.current_epoch(); assert_eq!( @@ -309,7 +309,7 @@ proptest! { for (_, i) in claims { let who = &investors[i]; if delisted[i] { - f.identity.allow(who, &false, &f.compliance); + f.identity.allow(who, &false); } let queued = || f.vault.get_redeem_request(&open, who).map_or(0, |r| r.shares); let before = queued(); diff --git a/contracts/async-vault/src/test/lock_policy.rs b/contracts/async-vault/src/test/lock_policy.rs index 40133a85..09a8ccfd 100644 --- a/contracts/async-vault/src/test/lock_policy.rs +++ b/contracts/async-vault/src/test/lock_policy.rs @@ -266,7 +266,7 @@ fn standing_is_delisted_for_an_address_that_was_never_allowlisted() { fn standing_is_delisted_for_a_holder_listed_then_removed() { let f = setup(); let holder = f.investor(0); - f.identity.allow(&holder, &false, &f.compliance); + f.identity.allow(&holder, &false); assert_eq!( f.e.as_contract(&f.vault.address, || lock_policy::standing(&f.e, &holder)), @@ -290,7 +290,7 @@ fn standing_is_frozen_for_a_frozen_holder_on_the_allowlist() { fn standing_is_frozen_for_a_holder_both_frozen_and_delisted() { let f = setup(); let holder = f.investor(0); - f.identity.allow(&holder, &false, &f.compliance); + f.identity.allow(&holder, &false); f.share.set_address_frozen(&holder, &true, &f.compliance); assert_eq!( diff --git a/contracts/async-vault/src/test/mod.rs b/contracts/async-vault/src/test/mod.rs index 9eb755bc..29d8700f 100644 --- a/contracts/async-vault/src/test/mod.rs +++ b/contracts/async-vault/src/test/mod.rs @@ -100,7 +100,7 @@ impl Fixture<'_> { fn investor(&self, funded: i128) -> Address { let who = self.unverified_investor(funded); - self.identity.allow(&who, &true, &self.compliance); + self.identity.allow(&who, &true); who } @@ -367,14 +367,14 @@ fn build<'a>( if miswire == Miswire::PreMinted { // The token's admin mints during the token's own setup, then steps back. let holder = Address::generate(&e); - identity.allow(&holder, &true, &compliance_authority); + identity.allow(&holder, &true); share.grant_role(&admin, &symbol_short!("manager"), &admin); share.mint(&holder, &100, &admin); share.revoke_role(&admin, &symbol_short!("manager"), &admin); } // Converting a delisted holder's deposit mints into the vault's own escrow. if miswire != Miswire::NotAllowlisted { - identity.allow(&contract_id, &true, &compliance_authority); + identity.allow(&contract_id, &true); } if miswire == Miswire::VaultFrozen { // Until `bind_vault`, the token does not know this address is its vault. diff --git a/contracts/async-vault/src/test/settlement.rs b/contracts/async-vault/src/test/settlement.rs index 9dfd4288..ad3d15e9 100644 --- a/contracts/async-vault/src/test/settlement.rs +++ b/contracts/async-vault/src/test/settlement.rs @@ -193,7 +193,7 @@ fn a_request_that_cannot_settle_keeps_its_epoch_open() { let epoch = f.vault.request_redeem(&a, &1); f.vault.request_redeem(&b, &3); f.fulfill_epoch(wad(1) / 2); - f.identity.allow(&a, &false, &f.compliance); + f.identity.allow(&a, &false); refused( f.vault.try_claim_redeem(&a, &epoch), diff --git a/contracts/async-vault/src/test/share_returns.rs b/contracts/async-vault/src/test/share_returns.rs index 799d38a2..bba8dbc5 100644 --- a/contracts/async-vault/src/test/share_returns.rs +++ b/contracts/async-vault/src/test/share_returns.rs @@ -26,7 +26,7 @@ fn a_delisted_vault_still_returns_escrowed_shares() { let holder = f.holder(500); let epoch = f.vault.request_redeem(&holder, &200); - f.identity.allow(&f.vault.address, &false, &f.compliance); + f.identity.allow(&f.vault.address, &false); assert!(!f.vault.health().allowlisted); assert_eq!(f.vault.cancel_redeem(&holder, &epoch), 200); @@ -38,7 +38,7 @@ fn a_delisted_holder_cannot_take_shares_back() { let f = setup(); let holder = f.holder(500); let epoch = f.vault.request_redeem(&holder, &200); - f.identity.allow(&holder, &false, &f.compliance); + f.identity.allow(&holder, &false); refused( f.vault.try_cancel_redeem(&holder, &epoch), @@ -127,7 +127,7 @@ fn a_delisted_holder_with_a_worthless_exit_keeps_the_request() { let epoch = f.vault.request_redeem(&holder, &1); // Priced at half a unit per share, one share pays nothing. f.fulfill_epoch(WAD_SCALE / 2); - f.identity.allow(&holder, &false, &f.compliance); + f.identity.allow(&holder, &false); refused( f.vault.try_claim_redeem(&holder, &epoch), diff --git a/contracts/identity-verifier/src/lib.rs b/contracts/identity-verifier/src/lib.rs index 83a677c1..bfb7af99 100644 --- a/contracts/identity-verifier/src/lib.rs +++ b/contracts/identity-verifier/src/lib.rs @@ -31,8 +31,7 @@ impl IdentityVerifier { /// Adds or removes an account from the allowlist. #[only_admin] - pub fn allow(e: &Env, account: Address, allowed: bool, caller: Address) { - caller.require_auth(); + pub fn allow(e: &Env, account: Address, allowed: bool) { strata_storage::bump_instance(e); strata_storage::set_persistent(e, &DataKey::Allowed(account), &allowed); } diff --git a/contracts/identity-verifier/src/test.rs b/contracts/identity-verifier/src/test.rs index 7b9190a1..714fae3c 100644 --- a/contracts/identity-verifier/src/test.rs +++ b/contracts/identity-verifier/src/test.rs @@ -1,13 +1,77 @@ +extern crate std; + use super::*; use soroban_sdk::{ - testutils::{storage::Instance as _, storage::Persistent as _, Address as _, Ledger as _}, - Address, Env, + testutils::{ + storage::Instance as _, storage::Persistent as _, Address as _, AuthorizedFunction, + AuthorizedInvocation, Ledger as _, MockAuth, MockAuthInvoke, + }, + Address, Env, IntoVal, Symbol, Val, Vec, }; use strata_storage::{INSTANCE_EXTEND_AMOUNT, PERSISTENT_EXTEND_AMOUNT}; const DAY_IN_LEDGERS: u32 = 17_280; use stellar_tokens::rwa::identity_verification::IdentityVerifierClient as IdClient; +/// Signs one `allow` call as `who` and nothing else. +fn sign_only_as(e: &Env, verifier: &Address, who: &Address, args: Vec) { + e.mock_auths(&[MockAuth { + address: who, + invoke: &MockAuthInvoke { + contract: verifier, + fn_name: "allow", + args, + sub_invokes: &[], + }, + }]); +} + +/// The last call asked `who` alone to sign, once, on exactly this call. +fn signed_once_by(e: &Env, verifier: &Address, who: &Address, fn_name: &str, args: Vec) { + assert_eq!( + e.auths(), + std::vec![( + who.clone(), + AuthorizedInvocation { + function: AuthorizedFunction::Contract(( + verifier.clone(), + Symbol::new(e, fn_name), + args, + )), + sub_invocations: std::vec![], + }, + )] + ); +} + +#[test] +fn each_admin_call_asks_the_admin_for_one_signature() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let investor = Address::generate(&e); + let id = e.register(IdentityVerifier, (admin.clone(),)); + + IdentityVerifierClient::new(&e, &id).allow(&investor, &true); + signed_once_by( + &e, + &id, + &admin, + "allow", + (investor.clone(), true).into_val(&e), + ); + + let topics = Address::generate(&e); + IdClient::new(&e, &id).set_claim_topics_and_issuers(&topics, &admin); + signed_once_by( + &e, + &id, + &admin, + "set_claim_topics_and_issuers", + (topics, admin.clone()).into_val(&e), + ); +} + #[test] fn only_admin_can_allow() { let e = Env::default(); @@ -22,22 +86,23 @@ fn only_admin_can_allow() { assert!(!client.is_allowed(&investor)); // Stranger cannot allow. - assert!(client.try_allow(&investor, &true, &stranger).is_err()); + sign_only_as(&e, &id, &stranger, (investor.clone(), true).into_val(&e)); + assert!(client.try_allow(&investor, &true).is_err()); assert!(!client.is_allowed(&investor)); // Admin allows. e.mock_all_auths(); - client.allow(&investor, &true, &admin); + client.allow(&investor, &true); assert!(client.is_allowed(&investor)); // Stranger cannot disallow. - e.set_auths(&[]); - assert!(client.try_allow(&investor, &false, &stranger).is_err()); + sign_only_as(&e, &id, &stranger, (investor.clone(), false).into_val(&e)); + assert!(client.try_allow(&investor, &false).is_err()); assert!(client.is_allowed(&investor)); // Admin disallows. e.mock_all_auths(); - client.allow(&investor, &false, &admin); + client.allow(&investor, &false); assert!(!client.is_allowed(&investor)); } @@ -56,7 +121,7 @@ fn verify_identity_checks_allowlist() { assert!(id_client.try_verify_identity(&investor).is_err()); // Once allowed, verification succeeds. - client.allow(&investor, &true, &admin); + client.allow(&investor, &true); id_client.verify_identity(&investor); } @@ -80,7 +145,7 @@ fn allowing_writes_the_entry_with_the_full_ttl() { let investor = Address::generate(&e); let id = e.register(IdentityVerifier, (admin.clone(),)); - IdentityVerifierClient::new(&e, &id).allow(&investor, &true, &admin); + IdentityVerifierClient::new(&e, &id).allow(&investor, &true); assert_eq!(allowed_ttl(&e, &id, &investor), PERSISTENT_EXTEND_AMOUNT); } @@ -93,7 +158,7 @@ fn checking_an_allowed_account_restores_the_full_ttl() { let investor = Address::generate(&e); let id = e.register(IdentityVerifier, (admin.clone(),)); let client = IdentityVerifierClient::new(&e, &id); - client.allow(&investor, &true, &admin); + client.allow(&investor, &true); age(&e, 2 * DAY_IN_LEDGERS); assert!(client.is_allowed(&investor)); @@ -127,7 +192,7 @@ fn allowing_and_checking_restore_the_instance_ttl() { age(&e, 2 * DAY_IN_LEDGERS); assert!(instance_ttl(&e, &id) < INSTANCE_EXTEND_AMOUNT); - client.allow(&investor, &true, &admin); + client.allow(&investor, &true); assert_eq!(instance_ttl(&e, &id), INSTANCE_EXTEND_AMOUNT); age(&e, 2 * DAY_IN_LEDGERS); diff --git a/contracts/share-token/src/test.rs b/contracts/share-token/src/test.rs index 7a4a67b0..be526da7 100644 --- a/contracts/share-token/src/test.rs +++ b/contracts/share-token/src/test.rs @@ -41,7 +41,7 @@ fn manager_mints_to_allowlisted_account() { token.grant_role(&manager, &symbol_short!("manager"), &admin); compliance.bind_token(&token.address, &admin); // compliance rejects ops from unbound tokens - identity.allow(&receiver, &true, &admin); + identity.allow(&receiver, &true); token.mint(&receiver, &100, &manager); @@ -281,7 +281,7 @@ fn the_compliance_authority_freezes_and_moves_balances() { let (f, identity) = setup_live(&e); let holder = Address::generate(&e); let target = Address::generate(&e); - identity.allow(&holder, &true, &f.admin); + identity.allow(&holder, &true); f.token.mint(&holder, &1_000, &f.manager); f.token @@ -298,7 +298,7 @@ fn the_compliance_authority_freezes_and_moves_balances() { .unfreeze_partial_tokens(&holder, &400, &f.compliance_authority); assert_eq!(f.token.get_frozen_tokens(&holder), 0); - identity.allow(&target, &true, &f.admin); + identity.allow(&target, &true); f.token.legal_transfer( &holder, &target, @@ -315,7 +315,7 @@ fn the_compliance_authority_reaches_recovery() { let (f, identity) = setup_live(&e); let lost = Address::generate(&e); let replacement = Address::generate(&e); - identity.allow(&replacement, &true, &f.admin); + identity.allow(&replacement, &true); // The role check passes; the testnet verifier names no recovery target. assert_eq!( @@ -368,7 +368,7 @@ fn the_manager_escrows_but_does_not_intervene() { let (f, identity) = setup_live(&e); let holder = Address::generate(&e); let new_holder = Address::generate(&e); - identity.allow(&holder, &true, &f.admin); + identity.allow(&holder, &true); f.token.mint(&holder, &1_000, &f.manager); assert_unauthorized(f.token.try_set_address_frozen(&holder, &true, &f.manager)); @@ -437,8 +437,8 @@ fn decayed_live_token(e: &Env) -> (Fixture<'_>, Address, Address) { let (f, identity) = setup_live(e); let holder = Address::generate(e); let other = Address::generate(e); - identity.allow(&holder, &true, &f.admin); - identity.allow(&other, &true, &f.admin); + identity.allow(&holder, &true); + identity.allow(&other, &true); f.token.mint(&holder, &100, &f.manager); age(e, TWO_DAYS_IN_LEDGERS); @@ -576,7 +576,7 @@ mod legal_transfer { let destination = Address::generate(e); let vault = e.register(VaultStub, ()); for who in [&holder, &destination, &vault] { - identity.allow(who, &true, &f.admin); + identity.allow(who, &true); } f.token.mint(&holder, &1_000, &f.manager); f.token.mint(&vault, &500, &f.manager); diff --git a/contracts/share-token/src/test_guards.rs b/contracts/share-token/src/test_guards.rs index 48a3f995..42c7bbbb 100644 --- a/contracts/share-token/src/test_guards.rs +++ b/contracts/share-token/src/test_guards.rs @@ -204,7 +204,7 @@ fn identity_verified_refuses_an_account_the_verifier_rejects() { let f = setup(); let listed = Address::generate(&f.e); let stranger = Address::generate(&f.e); - f.identity.allow(&listed, &true, &f.admin); + f.identity.allow(&listed, &true); holds(&f, || guards::identity_verified(&f.e, &listed)); refuses(&f, RWAError::IdentityVerificationFailed, || { diff --git a/scripts/harness/deploy.ts b/scripts/harness/deploy.ts index a0f68701..de09550e 100644 --- a/scripts/harness/deploy.ts +++ b/scripts/harness/deploy.ts @@ -177,7 +177,6 @@ async function grantAndVerify( await verifier.allow({ account: vault, allowed: true, - caller: accounts.compliance.publicKey(), }) ).signAndSend() const allowed = (await verifier.is_allowed({ account: vault })).result diff --git a/scripts/harness/guards.test.ts b/scripts/harness/guards.test.ts index 5cdc3ab9..d02ebbf3 100644 --- a/scripts/harness/guards.test.ts +++ b/scripts/harness/guards.test.ts @@ -62,7 +62,6 @@ beforeAll(async () => { verifier.allow({ account: holder.publicKey(), allowed: true, - caller: compliance.publicKey(), }), ) } @@ -167,7 +166,6 @@ describe("the compliance authority's reach", () => { identityVerifier(c, governance).allow({ account: outsider.publicKey(), allowed: true, - caller: governance.publicKey(), }), ), ).rejects.toThrow(/signatures/) @@ -619,7 +617,6 @@ describe("a de-listed holder", () => { identityVerifier(c, compliance).allow({ account: first.publicKey(), allowed: false, - caller: compliance.publicKey(), }), ) expectRefused( @@ -649,7 +646,6 @@ describe("a de-listed holder", () => { identityVerifier(c, compliance).allow({ account: first.publicKey(), allowed: true, - caller: compliance.publicKey(), }), ) expect( diff --git a/scripts/harness/happy-path.test.ts b/scripts/harness/happy-path.test.ts index 164cffc7..00691e7e 100644 --- a/scripts/harness/happy-path.test.ts +++ b/scripts/harness/happy-path.test.ts @@ -71,7 +71,6 @@ describe("setup", () => { verifier.allow({ account: holder.publicKey(), allowed: true, - caller: compliance.publicKey(), }), ) expect( diff --git a/scripts/harness/seed.ts b/scripts/harness/seed.ts index c8285936..e524afec 100644 --- a/scripts/harness/seed.ts +++ b/scripts/harness/seed.ts @@ -105,7 +105,6 @@ async function ensureAllowlist( verifier.allow({ account: holder.publicKey(), allowed: true, - caller: c.keys.compliance.publicKey(), }), ) console.log(` ✓ ${name} allowlisted`) diff --git a/scripts/harness/wind-down.test.ts b/scripts/harness/wind-down.test.ts index 7dd0eccb..3d47383d 100644 --- a/scripts/harness/wind-down.test.ts +++ b/scripts/harness/wind-down.test.ts @@ -71,7 +71,6 @@ beforeAll(async () => { identityVerifier(c, c.keys.compliance).allow({ account: holder.publicKey(), allowed: true, - caller: c.keys.compliance.publicKey(), }), ) }) From 620960027cf801c779edbf06218c0be69f4040ed Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:17:46 -0300 Subject: [PATCH 082/147] refactor: drop unused caller from compliance --- contracts/async-vault/src/test/mod.rs | 17 +- contracts/compliance/src/lib.rs | 10 +- contracts/compliance/src/test/changes.rs | 270 +++++++++++++++++------ contracts/compliance/src/test/guards.rs | 14 +- scripts/harness/deploy.ts | 4 +- scripts/harness/guards.test.ts | 9 +- 6 files changed, 224 insertions(+), 100 deletions(-) diff --git a/contracts/async-vault/src/test/mod.rs b/contracts/async-vault/src/test/mod.rs index 29d8700f..846da89c 100644 --- a/contracts/async-vault/src/test/mod.rs +++ b/contracts/async-vault/src/test/mod.rs @@ -176,12 +176,9 @@ impl Fixture<'_> { /// After setup, a module lands only through the compliance contract's timelock. fn add_compliance_module(&self, hook: ComplianceHook, module: &Address) { let rules = ComplianceClient::new(&self.e, &self.share.compliance()); - let id = rules.propose_change( - &ComplianceChange::AddModule(hook, module.clone()), - &self.admin, - ); + let id = rules.propose_change(&ComplianceChange::AddModule(hook, module.clone())); self.advance(MIN_UPGRADE_DELAY); - rules.apply_change(&id, &self.admin); + rules.apply_change(&id); } } @@ -407,13 +404,13 @@ fn build<'a>( match miswire { Miswire::ComplianceUnbound => {} Miswire::ComplianceBoundElsewhere => { - compliance.bind_vault(®ister_with(&e, distinct_roles(&e)), &admin); - compliance.finish_setup(&admin); + compliance.bind_vault(®ister_with(&e, distinct_roles(&e))); + compliance.finish_setup(); } - Miswire::ComplianceSetupOpen => compliance.bind_vault(&contract_id, &admin), + Miswire::ComplianceSetupOpen => compliance.bind_vault(&contract_id), _ => { - compliance.bind_vault(&contract_id, &admin); - compliance.finish_setup(&admin); + compliance.bind_vault(&contract_id); + compliance.finish_setup(); } } let vault = AsyncVaultClient::new(&e, &contract_id); diff --git a/contracts/compliance/src/lib.rs b/contracts/compliance/src/lib.rs index bb61ec4d..34f9b4d0 100644 --- a/contracts/compliance/src/lib.rs +++ b/contracts/compliance/src/lib.rs @@ -97,7 +97,7 @@ impl Compliance { /// Names the vault whose clock this contract's timelock reads. Once, and /// only during setup. #[only_admin] - pub fn bind_vault(e: &Env, vault: Address, _caller: Address) { + pub fn bind_vault(e: &Env, vault: Address) { strata_storage::bump_instance(e); governance::bind_vault(e, &vault); } @@ -105,20 +105,20 @@ impl Compliance { /// Ends the window in which module and binding changes are instant. It /// never reopens. #[only_admin] - pub fn finish_setup(e: &Env, _caller: Address) { + pub fn finish_setup(e: &Env) { strata_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: ComplianceChange, _caller: Address) -> u32 { + pub fn propose_change(e: &Env, change: ComplianceChange) -> u32 { strata_storage::bump_instance(e); governance::propose(e, change) } #[only_admin] - pub fn cancel_change(e: &Env, id: u32, _caller: Address) { + pub fn cancel_change(e: &Env, id: u32) { strata_storage::bump_instance(e); governance::cancel(e, id); } @@ -126,7 +126,7 @@ impl Compliance { /// 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) { + pub fn apply_change(e: &Env, id: u32) { strata_storage::bump_instance(e); governance::apply(e, id); } diff --git a/contracts/compliance/src/test/changes.rs b/contracts/compliance/src/test/changes.rs index 89734b4f..1a5ba221 100644 --- a/contracts/compliance/src/test/changes.rs +++ b/contracts/compliance/src/test/changes.rs @@ -1,7 +1,12 @@ +extern crate std; + use soroban_sdk::{ contract, contractimpl, symbol_short, - testutils::{Address as _, Events as _, Ledger as _, MockAuth, MockAuthInvoke}, - vec, Address, Env, Event as _, IntoVal, Val, Vec, + testutils::{ + Address as _, AuthorizedFunction, AuthorizedInvocation, Events as _, Ledger as _, MockAuth, + MockAuthInvoke, + }, + vec, Address, Env, Event as _, IntoVal, Symbol, Val, Vec, }; use stellar_access::role_transfer::RoleTransferError; use stellar_tokens::rwa::compliance::ComplianceHook; @@ -60,13 +65,13 @@ struct Fixture<'a> { impl Fixture<'_> { fn propose(&self, change: &ComplianceChange) -> u32 { - self.rules.propose_change(change, &self.admin) + self.rules.propose_change(change) } fn land(&self, change: &ComplianceChange) { let id = self.propose(change); self.e.ledger().with_mut(|l| l.timestamp += MIN_DELAY); - self.rules.apply_change(&id, &self.admin); + self.rules.apply_change(&id); } /// Signs the next call to the compliance contract as `who` and nothing @@ -83,6 +88,24 @@ impl Fixture<'_> { }]); } + /// The last call asked `who` alone to sign, once, on exactly this call. + fn signed_once_by(&self, who: &Address, fn_name: &str, args: Vec) { + assert_eq!( + self.e.auths(), + std::vec![( + who.clone(), + AuthorizedInvocation { + function: AuthorizedFunction::Contract(( + self.rules.address.clone(), + Symbol::new(&self.e, fn_name), + args, + )), + sub_invocations: std::vec![], + }, + )] + ); + } + /// 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()) { @@ -116,13 +139,13 @@ fn unbound<'a>() -> Fixture<'a> { /// A compliance contract bound to a vault clock, with setup still open. fn bound<'a>() -> Fixture<'a> { let f = unbound(); - f.rules.bind_vault(&f.clock.address, &f.admin); + f.rules.bind_vault(&f.clock.address); f } fn finished<'a>() -> Fixture<'a> { let f = bound(); - f.rules.finish_setup(&f.admin); + f.rules.finish_setup(); f } @@ -148,7 +171,7 @@ fn setup_cannot_finish_without_a_vault() { let f = unbound(); refused( - f.rules.try_finish_setup(&f.admin), + f.rules.try_finish_setup(), ComplianceContractError::VaultNotBound, ); assert!(f.rules.setup_open()); @@ -158,18 +181,18 @@ fn setup_cannot_finish_without_a_vault() { fn a_second_vault_or_a_late_vault_is_refused() { let f = bound(); refused( - f.rules.try_bind_vault(&Address::generate(&f.e), &f.admin), + f.rules.try_bind_vault(&Address::generate(&f.e)), ComplianceContractError::SetupClosed, ); - f.rules.finish_setup(&f.admin); + f.rules.finish_setup(); refused( - f.rules.try_bind_vault(&Address::generate(&f.e), &f.admin), + f.rules.try_bind_vault(&Address::generate(&f.e)), ComplianceContractError::SetupClosed, ); refused( - f.rules.try_finish_setup(&f.admin), + f.rules.try_finish_setup(), ComplianceContractError::SetupClosed, ); assert_eq!(f.rules.vault(), Some(f.clock.address.clone())); @@ -179,10 +202,7 @@ fn a_second_vault_or_a_late_vault_is_refused() { fn an_address_that_reports_no_clock_cannot_be_bound() { let f = unbound(); - assert!(f - .rules - .try_bind_vault(&Address::generate(&f.e), &f.admin) - .is_err()); + assert!(f.rules.try_bind_vault(&Address::generate(&f.e)).is_err()); assert_eq!(f.rules.vault(), None); } @@ -196,8 +216,8 @@ fn the_contract_answers_the_view_the_vault_reads() { assert_eq!(view.vault(), None); assert!(view.setup_open()); - f.rules.bind_vault(&f.clock.address, &f.admin); - f.rules.finish_setup(&f.admin); + f.rules.bind_vault(&f.clock.address); + f.rules.finish_setup(); assert_eq!(view.vault(), Some(f.clock.address.clone())); assert!(!view.setup_open()); @@ -206,16 +226,19 @@ fn the_contract_answers_the_view_the_vault_reads() { #[test] fn only_the_admin_binds_and_finishes() { let f = unbound(); - f.e.set_auths(&[]); - assert!(f - .rules - .try_bind_vault(&f.clock.address, &Address::generate(&f.e)) - .is_err()); + let stranger = Address::generate(&f.e); + f.sign_only_as( + &stranger, + "bind_vault", + (f.clock.address.clone(),).into_val(&f.e), + ); + assert!(f.rules.try_bind_vault(&f.clock.address).is_err()); assert_eq!(f.rules.vault(), None); let f = bound(); - f.e.set_auths(&[]); - assert!(f.rules.try_finish_setup(&Address::generate(&f.e)).is_err()); + let stranger = Address::generate(&f.e); + f.sign_only_as(&stranger, "finish_setup", Vec::new(&f.e)); + assert!(f.rules.try_finish_setup().is_err()); assert!(f.rules.setup_open()); } @@ -249,10 +272,8 @@ fn after_setup_rule_changes_wait_for_the_timelock() { fn proposals_wait_until_setup_is_finished() { let f = bound(); refused( - f.rules.try_propose_change( - &ComplianceChange::BindToken(Address::generate(&f.e)), - &f.admin, - ), + f.rules + .try_propose_change(&ComplianceChange::BindToken(Address::generate(&f.e))), ComplianceContractError::SetupOpen, ); assert_eq!(f.rules.pending_changes().len(), 0); @@ -264,7 +285,7 @@ fn a_proposed_binding_lands_after_the_delay() { let token = Address::generate(&f.e); let change = ComplianceChange::BindToken(token.clone()); - let id = f.rules.propose_change(&change, &f.admin); + let id = f.rules.propose_change(&change); assert_eq!( f.rules.pending_changes(), @@ -278,12 +299,12 @@ fn a_proposed_binding_lands_after_the_delay() { ] ); refused( - f.rules.try_apply_change(&id, &f.admin), + f.rules.try_apply_change(&id), ComplianceContractError::ChangeNotReady, ); f.e.ledger().set_timestamp(10_000 + MIN_DELAY); - f.rules.apply_change(&id, &f.admin); + f.rules.apply_change(&id); assert_eq!(f.rules.linked_tokens(), vec![&f.e, token]); assert_eq!(f.rules.pending_changes().len(), 0); @@ -298,7 +319,7 @@ fn every_rule_change_lands_through_the_timelock() { f.rules.bind_token(&token, &f.admin); f.rules .add_module_to(&ComplianceHook::CanTransfer, &early, &f.admin); - f.rules.finish_setup(&f.admin); + f.rules.finish_setup(); f.land(&ComplianceChange::RemoveModule( ComplianceHook::CanTransfer, @@ -323,17 +344,17 @@ fn every_rule_change_lands_through_the_timelock() { fn nothing_lands_while_the_vault_is_paused_or_winding_down() { let f = finished(); let module = Address::generate(&f.e); - let id = f.rules.propose_change( - &ComplianceChange::AddModule(ComplianceHook::CanCreate, module), - &f.admin, - ); + let id = f.rules.propose_change(&ComplianceChange::AddModule( + ComplianceHook::CanCreate, + module, + )); f.e.ledger().set_timestamp(10_000 + MIN_DELAY); let mut paused = running(); paused.paused = true; f.clock.set(&paused); refused( - f.rules.try_apply_change(&id, &f.admin), + f.rules.try_apply_change(&id), ComplianceContractError::VaultPaused, ); @@ -341,14 +362,12 @@ fn nothing_lands_while_the_vault_is_paused_or_winding_down() { winding.winding_down = true; f.clock.set(&winding); refused( - f.rules.try_apply_change(&id, &f.admin), + f.rules.try_apply_change(&id), ComplianceContractError::WindDownActive, ); refused( - f.rules.try_propose_change( - &ComplianceChange::BindToken(Address::generate(&f.e)), - &f.admin, - ), + f.rules + .try_propose_change(&ComplianceChange::BindToken(Address::generate(&f.e))), ComplianceContractError::WindDownActive, ); } @@ -365,12 +384,12 @@ fn a_vault_pause_pushes_the_change_back() { assert_eq!(f.rules.pending_changes().get(0).unwrap().eta, eta); f.e.ledger().set_timestamp(eta - 1); refused( - f.rules.try_apply_change(&id, &f.admin), + f.rules.try_apply_change(&id), ComplianceContractError::ChangeNotReady, ); f.e.ledger().set_timestamp(eta); - f.rules.apply_change(&id, &f.admin); + f.rules.apply_change(&id); } #[test] @@ -384,7 +403,7 @@ fn a_change_shorter_than_a_raised_notice_never_lands() { f.e.ledger().set_timestamp(10_000 + 30 * DAY_DURATION); refused( - f.rules.try_apply_change(&id, &f.admin), + f.rules.try_apply_change(&id), ComplianceContractError::DelayBelowNotice, ); } @@ -392,15 +411,14 @@ fn a_change_shorter_than_a_raised_notice_never_lands() { #[test] fn a_cancelled_change_never_lands() { let f = finished(); - let id = f.rules.propose_change( - &ComplianceChange::BindToken(Address::generate(&f.e)), - &f.admin, - ); + let id = f + .rules + .propose_change(&ComplianceChange::BindToken(Address::generate(&f.e))); - f.rules.cancel_change(&id, &f.admin); + f.rules.cancel_change(&id); refused( - f.rules.try_apply_change(&id, &f.admin), + f.rules.try_apply_change(&id), ComplianceContractError::ChangeNotFound, ); assert_eq!(f.rules.linked_tokens().len(), 0); @@ -414,10 +432,16 @@ fn only_the_admin_proposes_cancels_and_applies() { let id = f.propose(&change); f.e.ledger().set_timestamp(10_000 + MIN_DELAY); - f.e.set_auths(&[]); - assert!(f.rules.try_propose_change(&change, &stranger).is_err()); - assert!(f.rules.try_cancel_change(&id, &stranger).is_err()); - assert!(f.rules.try_apply_change(&id, &stranger).is_err()); + f.sign_only_as( + &stranger, + "propose_change", + (change.clone(),).into_val(&f.e), + ); + assert!(f.rules.try_propose_change(&change).is_err()); + f.sign_only_as(&stranger, "cancel_change", (id,).into_val(&f.e)); + assert!(f.rules.try_cancel_change(&id).is_err()); + f.sign_only_as(&stranger, "apply_change", (id,).into_val(&f.e)); + assert!(f.rules.try_apply_change(&id).is_err()); assert_eq!(f.rules.pending_changes().len(), 1); } @@ -430,7 +454,7 @@ fn proposing_and_applying_read_the_vault_clock_once() { assert_eq!(f.clock.reads(), before + 1); f.e.ledger().set_timestamp(10_000 + MIN_DELAY); - f.rules.apply_change(&id, &f.admin); + f.rules.apply_change(&id); assert_eq!(f.clock.reads(), before + 2); } @@ -451,7 +475,7 @@ fn proposing_and_applying_publish_the_change() { ); f.e.ledger().set_timestamp(10_000 + MIN_DELAY); - f.rules.apply_change(&id, &f.admin); + f.rules.apply_change(&id); let events = f.e.events().all().filter_by_contract(&f.rules.address); assert_eq!( events.events().last(), @@ -467,13 +491,14 @@ fn setup_cannot_finish_while_a_hand_over_is_pending() { .transfer_admin_role(&next, &(f.e.ledger().sequence() + 100)); refused( - f.rules.try_finish_setup(&f.admin), + f.rules.try_finish_setup(), ComplianceContractError::AdminTransferPending, ); f.rules.accept_admin_transfer(); assert_eq!(f.rules.get_admin(), Some(next.clone())); - f.rules.finish_setup(&next); + f.rules.finish_setup(); + f.signed_once_by(&next, "finish_setup", Vec::new(&f.e)); assert!(!f.rules.setup_open()); } @@ -532,7 +557,7 @@ fn a_cancelled_hand_over_no_longer_holds_setup_open() { .transfer_admin_role(&next, &(f.e.ledger().sequence() + 100)); f.rules.transfer_admin_role(&next, &0); - f.rules.finish_setup(&f.admin); + f.rules.finish_setup(); assert_eq!(f.rules.get_admin(), Some(f.admin.clone())); } @@ -549,9 +574,9 @@ fn after_setup_the_admin_hands_over_through_the_timelock() { let id = f .rules - .propose_change(&ComplianceChange::Admin(next.clone()), &f.admin); + .propose_change(&ComplianceChange::Admin(next.clone())); f.e.ledger().set_timestamp(10_000 + MIN_DELAY); - f.rules.apply_change(&id, &f.admin); + f.rules.apply_change(&id); assert_eq!(f.rules.get_admin(), Some(f.admin.clone())); f.rules.accept_admin_transfer(); @@ -611,16 +636,129 @@ fn every_admin_call_restores_the_instance_ttl() { .transfer_admin_role(&next, &(f.e.ledger().sequence() + ADMIN_ACCEPT_LEDGERS)) }); f.restores_the_instance(|| f.rules.transfer_admin_role(&next, &0)); - f.restores_the_instance(|| f.rules.bind_vault(&f.clock.address, &f.admin)); - f.restores_the_instance(|| f.rules.finish_setup(&f.admin)); + f.restores_the_instance(|| f.rules.bind_vault(&f.clock.address)); + f.restores_the_instance(|| f.rules.finish_setup()); let change = ComplianceChange::BindToken(Address::generate(&f.e)); let mut id = 0; f.restores_the_instance(|| id = f.propose(&change)); - f.restores_the_instance(|| f.rules.cancel_change(&id, &f.admin)); + f.restores_the_instance(|| f.rules.cancel_change(&id)); let id = f.propose(&ComplianceChange::Admin(next)); f.e.ledger().with_mut(|l| l.timestamp += MIN_DELAY); - f.restores_the_instance(|| f.rules.apply_change(&id, &f.admin)); + f.restores_the_instance(|| f.rules.apply_change(&id)); f.restores_the_instance(|| f.rules.accept_admin_transfer()); } + +#[test] +fn each_admin_call_asks_the_admin_for_one_signature() { + let f = unbound(); + let token = Address::generate(&f.e); + let module = Address::generate(&f.e); + + f.rules.bind_token(&token, &f.admin); + f.signed_once_by( + &f.admin, + "bind_token", + (token.clone(), f.admin.clone()).into_val(&f.e), + ); + f.rules.unbind_token(&token, &f.admin); + f.signed_once_by( + &f.admin, + "unbind_token", + (token.clone(), f.admin.clone()).into_val(&f.e), + ); + f.rules + .add_module_to(&ComplianceHook::CanTransfer, &module, &f.admin); + f.signed_once_by( + &f.admin, + "add_module_to", + (ComplianceHook::CanTransfer, module.clone(), f.admin.clone()).into_val(&f.e), + ); + f.rules + .remove_module_from(&ComplianceHook::CanTransfer, &module, &f.admin); + f.signed_once_by( + &f.admin, + "remove_module_from", + (ComplianceHook::CanTransfer, module, f.admin.clone()).into_val(&f.e), + ); + + f.rules.bind_vault(&f.clock.address); + f.signed_once_by( + &f.admin, + "bind_vault", + (f.clock.address.clone(),).into_val(&f.e), + ); + f.rules.finish_setup(); + f.signed_once_by(&f.admin, "finish_setup", Vec::new(&f.e)); + + let change = ComplianceChange::BindToken(token); + let id = f.rules.propose_change(&change); + f.signed_once_by(&f.admin, "propose_change", (change.clone(),).into_val(&f.e)); + f.rules.cancel_change(&id); + f.signed_once_by(&f.admin, "cancel_change", (id,).into_val(&f.e)); + + let id = f.rules.propose_change(&change); + f.e.ledger().with_mut(|l| l.timestamp += MIN_DELAY); + f.rules.apply_change(&id); + f.signed_once_by(&f.admin, "apply_change", (id,).into_val(&f.e)); + + let id = f + .rules + .propose_change(&ComplianceChange::Admin(Address::generate(&f.e))); + f.e.ledger().with_mut(|l| l.timestamp += MIN_DELAY); + f.rules.apply_change(&id); + f.signed_once_by(&f.admin, "apply_change", (id,).into_val(&f.e)); +} + +#[test] +fn a_hand_over_asks_each_side_for_one_signature() { + let f = bound(); + let next = Address::generate(&f.e); + let live_until = f.e.ledger().sequence() + 100; + + f.rules.transfer_admin_role(&next, &live_until); + f.signed_once_by( + &f.admin, + "transfer_admin_role", + (next.clone(), live_until).into_val(&f.e), + ); + f.rules.transfer_admin_role(&next, &0); + f.signed_once_by( + &f.admin, + "transfer_admin_role", + (next.clone(), 0_u32).into_val(&f.e), + ); + + f.rules.transfer_admin_role(&next, &live_until); + f.rules.accept_admin_transfer(); + f.signed_once_by(&next, "accept_admin_transfer", Vec::new(&f.e)); +} + +#[test] +fn each_hook_asks_the_bound_token_for_one_signature() { + let f = bound(); + let token = Address::generate(&f.e); + let from = Address::generate(&f.e); + let to = Address::generate(&f.e); + f.rules.bind_token(&token, &f.admin); + + f.rules.created(&to, &10, &token); + f.signed_once_by( + &token, + "created", + (to.clone(), 10_i128, token.clone()).into_val(&f.e), + ); + f.rules.transferred(&to, &from, &4, &token); + f.signed_once_by( + &token, + "transferred", + (to.clone(), from.clone(), 4_i128, token.clone()).into_val(&f.e), + ); + f.rules.destroyed(&from, &4, &token); + f.signed_once_by( + &token, + "destroyed", + (from, 4_i128, token.clone()).into_val(&f.e), + ); +} diff --git a/contracts/compliance/src/test/guards.rs b/contracts/compliance/src/test/guards.rs index 4ab9bee9..94594eb3 100644 --- a/contracts/compliance/src/test/guards.rs +++ b/contracts/compliance/src/test/guards.rs @@ -28,13 +28,12 @@ struct Fixture<'a> { e: Env, rules: ComplianceClient<'a>, vault: Address, - admin: Address, } impl Fixture<'_> { fn finish_setup(&self) { - self.rules.bind_vault(&self.vault, &self.admin); - self.rules.finish_setup(&self.admin); + self.rules.bind_vault(&self.vault); + self.rules.finish_setup(); } } @@ -42,14 +41,9 @@ fn setup<'a>() -> Fixture<'a> { let e = Env::default(); e.mock_all_auths(); let admin = Address::generate(&e); - let rules = ComplianceClient::new(&e, &e.register(Compliance, (admin.clone(),))); + let rules = ComplianceClient::new(&e, &e.register(Compliance, (admin,))); let vault = e.register(VaultStub, ()); - Fixture { - e, - rules, - vault, - admin, - } + Fixture { e, rules, vault } } /// Runs `guard` inside the compliance contract, as an entrypoint would. diff --git a/scripts/harness/deploy.ts b/scripts/harness/deploy.ts index de09550e..d855bc43 100644 --- a/scripts/harness/deploy.ts +++ b/scripts/harness/deploy.ts @@ -203,8 +203,8 @@ async function grantAndVerify( // Binding the token is instant only during setup, so the compliance // contract's setup closes after it. - await (await compliance.bind_vault({ vault, caller })).signAndSend() - await (await compliance.finish_setup({ caller })).signAndSend() + await (await compliance.bind_vault({ vault })).signAndSend() + await (await compliance.finish_setup()).signAndSend() if ((await compliance.setup_open()).result) throw new Error("the compliance contract's setup did not finish") console.log("compliance bound to the vault, setup finished") diff --git a/scripts/harness/guards.test.ts b/scripts/harness/guards.test.ts index d02ebbf3..5b261b0e 100644 --- a/scripts/harness/guards.test.ts +++ b/scripts/harness/guards.test.ts @@ -322,15 +322,10 @@ describe("the compliance contract after setup", () => { const id = await send( rules.propose_change({ change: { tag: "AddModule", values: [canCreate, outsider.publicKey()] }, - caller: caller(), }), ) - expectRefused( - await rules.apply_change({ id, caller: caller() }), - rules, - "ChangeNotReady", - ) - await send(rules.cancel_change({ id, caller: caller() })) + expectRefused(await rules.apply_change({ id }), rules, "ChangeNotReady") + await send(rules.cancel_change({ id })) expect((await rules.pending_changes()).result).toEqual([]) }) }) From 86a0c3eccd44a34e91edbe1f51a24da02747ce78 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:28:57 -0300 Subject: [PATCH 083/147] fix: ask the oracle's admin to sign once --- contracts/async-vault/src/test/mod.rs | 10 +- .../async-vault/src/test/oracle_pricing.rs | 4 +- contracts/async-vault/src/test/oracle_swap.rs | 10 +- contracts/async-vault/src/test/setup.rs | 4 +- contracts/nav-oracle/src/lib.rs | 24 +- contracts/nav-oracle/src/test.rs | 48 ++- contracts/nav-oracle/src/test_governance.rs | 288 ++++++++++-------- contracts/nav-oracle/src/test_guards.rs | 10 +- scripts/harness/deploy.ts | 4 +- scripts/harness/guards.test.ts | 21 +- 10 files changed, 235 insertions(+), 188 deletions(-) diff --git a/contracts/async-vault/src/test/mod.rs b/contracts/async-vault/src/test/mod.rs index 846da89c..80f571d5 100644 --- a/contracts/async-vault/src/test/mod.rs +++ b/contracts/async-vault/src/test/mod.rs @@ -392,13 +392,13 @@ fn build<'a>( match miswire { Miswire::OracleUnbound => {} Miswire::OracleBoundElsewhere => { - oracle.bind_vault(®ister_with(&e, distinct_roles(&e)), &admin); - oracle.finish_setup(&admin); + oracle.bind_vault(®ister_with(&e, distinct_roles(&e))); + oracle.finish_setup(); } - Miswire::OracleSetupOpen => oracle.bind_vault(&contract_id, &admin), + Miswire::OracleSetupOpen => oracle.bind_vault(&contract_id), _ => { - oracle.bind_vault(&contract_id, &admin); - oracle.finish_setup(&admin); + oracle.bind_vault(&contract_id); + oracle.finish_setup(); } } match miswire { diff --git a/contracts/async-vault/src/test/oracle_pricing.rs b/contracts/async-vault/src/test/oracle_pricing.rs index 32a7773e..427e6b50 100644 --- a/contracts/async-vault/src/test/oracle_pricing.rs +++ b/contracts/async-vault/src/test/oracle_pricing.rs @@ -14,11 +14,11 @@ fn fulfill_is_rejected_while_the_nav_is_paused() { let f = setup(); f.close_epoch(); f.attest(wad(2)); - f.oracle.set_nav_paused(&true, &f.admin); + f.oracle.set_nav_paused(&true); refused(f.vault.try_fulfill_epoch(&1), VaultError::FeedNotValid); - f.oracle.set_nav_paused(&false, &f.admin); + f.oracle.set_nav_paused(&false); assert_eq!(f.vault.fulfill_epoch(&1), wad(2)); } diff --git a/contracts/async-vault/src/test/oracle_swap.rs b/contracts/async-vault/src/test/oracle_swap.rs index a383343c..de8fc869 100644 --- a/contracts/async-vault/src/test/oracle_swap.rs +++ b/contracts/async-vault/src/test/oracle_swap.rs @@ -48,8 +48,8 @@ fn unbound_feed<'a>(f: &Fixture<'a>) -> NavOracleContractClient<'a> { fn second_feed<'a>(f: &Fixture<'a>) -> NavOracleContractClient<'a> { let feed = unbound_feed(f); - feed.bind_vault(&f.vault.address, &f.admin); - feed.finish_setup(&f.admin); + feed.bind_vault(&f.vault.address); + feed.finish_setup(); feed } @@ -127,10 +127,10 @@ fn a_sealed_epoch_is_priced_by_the_feed_standing_at_fulfilment() { fn a_feed_outside_this_vault_is_never_proposed() { let f = setup(); let open = unbound_feed(&f); - open.bind_vault(&f.vault.address, &f.admin); + open.bind_vault(&f.vault.address); let elsewhere = unbound_feed(&f); - elsewhere.bind_vault(®ister_with(&f.e, distinct_roles(&f.e)), &f.admin); - elsewhere.finish_setup(&f.admin); + elsewhere.bind_vault(®ister_with(&f.e, distinct_roles(&f.e))); + elsewhere.finish_setup(); for feed in [ Address::generate(&f.e), diff --git a/contracts/async-vault/src/test/setup.rs b/contracts/async-vault/src/test/setup.rs index 3dad44f7..67433620 100644 --- a/contracts/async-vault/src/test/setup.rs +++ b/contracts/async-vault/src/test/setup.rs @@ -160,8 +160,8 @@ fn a_token_that_cannot_answer_fails_its_checks_instead_of_trapping() { }, ), ); - oracle.bind_vault(&contract_id, &admin); - oracle.finish_setup(&admin); + oracle.bind_vault(&contract_id); + oracle.finish_setup(); let vault = AsyncVaultClient::new(&e, &contract_id); diff --git a/contracts/nav-oracle/src/lib.rs b/contracts/nav-oracle/src/lib.rs index 2570e741..d7c7d4e3 100644 --- a/contracts/nav-oracle/src/lib.rs +++ b/contracts/nav-oracle/src/lib.rs @@ -250,31 +250,27 @@ impl NavOracleContract { /// 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(); + pub fn bind_vault(e: &Env, vault: Address) { strata_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(); + pub fn finish_setup(e: &Env) { strata_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(); + pub fn propose_change(e: &Env, change: OracleChange) -> u32 { strata_storage::bump_instance(e); governance::propose(e, change) } #[only_admin] - pub fn cancel_change(e: &Env, id: u32, caller: Address) { - caller.require_auth(); + pub fn cancel_change(e: &Env, id: u32) { strata_storage::bump_instance(e); governance::cancel(e, id); } @@ -282,8 +278,7 @@ impl NavOracleContract { /// 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(); + pub fn apply_change(e: &Env, id: u32) { strata_storage::bump_instance(e); governance::apply(e, id); } @@ -344,8 +339,7 @@ impl NavOracleContract { } #[only_admin] - pub fn set_nav_paused(e: &Env, paused: bool, caller: Address) { - caller.require_auth(); + pub fn set_nav_paused(e: &Env, paused: bool) { state::set_nav_paused(e, paused); NavPausedSet { paused }.publish(e); } @@ -354,8 +348,7 @@ impl NavOracleContract { /// its distance from the last. Only while the NAV is paused, so /// resuming is always a deliberate second act. #[only_admin] - pub fn clear_latest(e: &Env, caller: Address) { - caller.require_auth(); + pub fn clear_latest(e: &Env) { guards::nav_paused(e); state::clear_latest(e); LatestCleared {}.publish(e); @@ -410,8 +403,7 @@ pub enum ProbeError { #[contractimpl] impl NavOracleContract { #[only_admin] - pub fn migrate(e: &Env, caller: Address) { - caller.require_auth(); + pub fn migrate(e: &Env) { strata_storage::bump_instance(e); if upgradeable::get_schema_version(e) >= 2 { panic_with_error!(e, ProbeError::AlreadyMigrated); diff --git a/contracts/nav-oracle/src/test.rs b/contracts/nav-oracle/src/test.rs index 29eb21d0..ad5e3b0d 100644 --- a/contracts/nav-oracle/src/test.rs +++ b/contracts/nav-oracle/src/test.rs @@ -2,8 +2,10 @@ extern crate std; use soroban_sdk::{ symbol_short, - testutils::{storage::Persistent as _, Address as _, Events as _, Ledger as _}, - Address, Env, Event as _, + testutils::{ + storage::Persistent as _, Address as _, Events as _, Ledger as _, MockAuth, MockAuthInvoke, + }, + Address, Env, Event as _, IntoVal, }; use crate::state::DataKey; @@ -21,6 +23,21 @@ struct Fixture<'a> { guardian: Address, } +impl Fixture<'_> { + /// Signs one call to the oracle as `who` and nothing else. + fn sign_only_as(&self, who: &Address, fn_name: &str, args: soroban_sdk::Vec) { + self.e.mock_auths(&[MockAuth { + address: who, + invoke: &MockAuthInvoke { + contract: &self.oracle.address, + fn_name, + args, + sub_invokes: &[], + }, + }]); + } +} + fn config() -> OracleConfig { OracleConfig { freshness_duration: 3600, @@ -98,11 +115,11 @@ fn the_nav_pause_holds_regardless_of_freshness() { f.oracle.attest(&r, &f.attester); assert_eq!(f.oracle.state(), OracleState::Valid); - f.oracle.set_nav_paused(&true, &f.admin); + f.oracle.set_nav_paused(&true); assert_eq!(f.oracle.state(), OracleState::Paused); assert!(f.oracle.try_ensure_consumable().is_err()); - f.oracle.set_nav_paused(&false, &f.admin); + f.oracle.set_nav_paused(&false); assert_eq!(f.oracle.state(), OracleState::Valid); } @@ -315,13 +332,14 @@ fn the_guardian_pauses_the_nav_but_unpausing_needs_governance() { f.oracle.pause_nav(&f.guardian); assert_eq!(f.oracle.state(), OracleState::Paused); + f.sign_only_as(&f.guardian, "set_nav_paused", (false,).into_val(&f.e)); + assert!(f.oracle.try_set_nav_paused(&false).is_err()); f.e.set_auths(&[]); - assert!(f.oracle.try_set_nav_paused(&false, &f.guardian).is_err()); - assert!(f.oracle.try_set_nav_paused(&false, &f.admin).is_err()); + assert!(f.oracle.try_set_nav_paused(&false).is_err()); assert_eq!(f.oracle.state(), OracleState::Paused); f.e.mock_all_auths(); - f.oracle.set_nav_paused(&false, &f.admin); + f.oracle.set_nav_paused(&false); assert_eq!(f.oracle.state(), OracleState::Valid); } @@ -422,13 +440,13 @@ fn the_record_clears_only_while_the_nav_is_paused() { f.oracle .attest(&report(&f.e, SCALE, 1, 1_000_000), &f.attester); - assert!(f.oracle.try_clear_latest(&f.admin).is_err()); + assert!(f.oracle.try_clear_latest().is_err()); f.oracle.pause_nav(&f.guardian); - f.oracle.clear_latest(&f.admin); + f.oracle.clear_latest(); // With no stored record, one in-band value lands however far it sits. - f.oracle.set_nav_paused(&false, &f.admin); + f.oracle.set_nav_paused(&false); f.e.ledger().set_timestamp(10_100); let far = report(&f.e, SCALE * 50, 2, 1_000_000); f.oracle.attest(&far, &f.attester); @@ -443,10 +461,10 @@ fn only_admin_can_clear_latest() { f.oracle.attest(&r, &f.attester); f.oracle.pause_nav(&f.guardian); - f.e.set_auths(&[]); - assert!(f.oracle.try_clear_latest(&stranger).is_err()); - assert!(f.oracle.try_clear_latest(&f.attester).is_err()); - assert!(f.oracle.try_clear_latest(&f.guardian).is_err()); + for who in [&stranger, &f.attester, &f.guardian] { + f.sign_only_as(who, "clear_latest", soroban_sdk::Vec::new(&f.e)); + assert!(f.oracle.try_clear_latest().is_err()); + } } #[test] @@ -561,7 +579,7 @@ fn attesting_is_refused_while_the_nav_is_paused() { Some(Ok(crate::OracleError::NavPaused.into())) ); - f.oracle.set_nav_paused(&false, &f.admin); + f.oracle.set_nav_paused(&false); f.oracle .attest(&report(&f.e, SCALE, 0, 1_000_000), &f.attester); assert_eq!(f.oracle.state(), OracleState::Valid); diff --git a/contracts/nav-oracle/src/test_governance.rs b/contracts/nav-oracle/src/test_governance.rs index 842fa6ee..7779b8c1 100644 --- a/contracts/nav-oracle/src/test_governance.rs +++ b/contracts/nav-oracle/src/test_governance.rs @@ -1,9 +1,12 @@ +extern crate std; + use soroban_sdk::{ contract, contractimpl, symbol_short, testutils::{ - storage::Instance as _, Address as _, Events as _, Ledger as _, MockAuth, MockAuthInvoke, + storage::Instance as _, Address as _, AuthorizedFunction, AuthorizedInvocation, + Events as _, Ledger as _, MockAuth, MockAuthInvoke, }, - Address, BytesN, Env, Event as _, IntoVal, + Address, BytesN, Env, Event as _, IntoVal, Symbol, }; use stellar_access::role_transfer::RoleTransferError; use strata_storage::INSTANCE_EXTEND_AMOUNT; @@ -73,12 +76,12 @@ impl Fixture<'_> { } fn finish(&self) { - self.oracle.bind_vault(&self.vault.address, &self.admin); - self.oracle.finish_setup(&self.admin); + self.oracle.bind_vault(&self.vault.address); + self.oracle.finish_setup(); } fn propose(&self, change: OracleChange) -> u32 { - self.oracle.propose_change(&change, &self.admin) + self.oracle.propose_change(&change) } fn attest(&self, nav_per_share: i128) { @@ -89,7 +92,7 @@ impl Fixture<'_> { 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.oracle.apply_change(&id); self.e.ledger().sequence() } @@ -123,6 +126,29 @@ impl Fixture<'_> { }, }]); } + + /// The last call asked `who` alone to sign, once, on exactly this call. + fn signed_once_by( + &self, + who: &Address, + fn_name: &str, + args: soroban_sdk::Vec, + ) { + assert_eq!( + self.e.auths(), + std::vec![( + who.clone(), + AuthorizedInvocation { + function: AuthorizedFunction::Contract(( + self.oracle.address.clone(), + Symbol::new(&self.e, fn_name), + args, + )), + sub_invocations: std::vec![], + }, + )] + ); + } } fn config() -> OracleConfig { @@ -378,12 +404,9 @@ fn a_loosened_config_lands_through_the_timelock() { let id = f.propose(OracleChange::Config(loose.clone())); f.advance(MIN_DELAY - 1); - refused( - f.oracle.try_apply_change(&id, &f.admin), - OracleError::ChangeNotReady, - ); + refused(f.oracle.try_apply_change(&id), OracleError::ChangeNotReady); f.advance(1); - f.oracle.apply_change(&id, &f.admin); + f.oracle.apply_change(&id); assert_eq!(f.oracle.config(), loose); assert_eq!(f.oracle.pending_changes().len(), 0); @@ -399,8 +422,7 @@ fn an_invalid_config_cannot_be_proposed() { }; refused( - f.oracle - .try_propose_change(&OracleChange::Config(invalid), &f.admin), + f.oracle.try_propose_change(&OracleChange::Config(invalid)), OracleError::InvalidConfig, ); } @@ -424,10 +446,7 @@ fn a_config_outside_the_bounds_is_refused_when_it_applies() { }); f.advance(MIN_DELAY); - refused( - f.oracle.try_apply_change(&id, &f.admin), - OracleError::InvalidConfig, - ); + refused(f.oracle.try_apply_change(&id), OracleError::InvalidConfig); assert_eq!(f.oracle.config(), config()); assert_eq!(f.oracle.pending_changes().len(), 1); } @@ -436,21 +455,15 @@ fn a_config_outside_the_bounds_is_refused_when_it_applies() { 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, - ); + refused(f.oracle.try_finish_setup(), 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(), OracleError::SetupClosed); refused( - f.oracle.try_finish_setup(&f.admin), - OracleError::SetupClosed, - ); - refused( - f.oracle.try_bind_vault(&Address::generate(&f.e), &f.admin), + f.oracle.try_bind_vault(&Address::generate(&f.e)), OracleError::SetupClosed, ); } @@ -458,10 +471,10 @@ fn setup_is_open_until_finished_and_never_reopens() { #[test] fn a_second_vault_cannot_be_bound() { let f = setup(); - f.oracle.bind_vault(&f.vault.address, &f.admin); + f.oracle.bind_vault(&f.vault.address); refused( - f.oracle.try_bind_vault(&Address::generate(&f.e), &f.admin), + f.oracle.try_bind_vault(&Address::generate(&f.e)), OracleError::SetupClosed, ); assert_eq!(f.oracle.vault(), Some(f.vault.address.clone())); @@ -471,10 +484,7 @@ fn a_second_vault_cannot_be_bound() { 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!(f.oracle.try_bind_vault(&Address::generate(&f.e)).is_err()); assert_eq!(f.oracle.vault(), None); } @@ -486,22 +496,15 @@ fn only_the_admin_binds_and_finishes() { f.sign_only_as( &stranger, "bind_vault", - (f.vault.address.clone(), stranger.clone()).into_val(&f.e), + (f.vault.address.clone(),).into_val(&f.e), ); - assert!(f - .oracle - .try_bind_vault(&f.vault.address, &stranger) - .is_err()); + assert!(f.oracle.try_bind_vault(&f.vault.address).is_err()); assert_eq!(f.oracle.vault(), None); f.e.mock_all_auths(); - f.oracle.bind_vault(&f.vault.address, &f.admin); - f.sign_only_as( - &stranger, - "finish_setup", - (stranger.clone(),).into_val(&f.e), - ); - assert!(f.oracle.try_finish_setup(&stranger).is_err()); + f.oracle.bind_vault(&f.vault.address); + f.sign_only_as(&stranger, "finish_setup", soroban_sdk::Vec::new(&f.e)); + assert!(f.oracle.try_finish_setup().is_err()); assert!(f.oracle.setup_open()); } @@ -525,17 +528,17 @@ fn the_oracle_answers_the_view_the_vault_reads() { 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.bind_vault(&f.vault.address); f.oracle .transfer_admin_role(&next, &(f.e.ledger().sequence() + 100)); refused( - f.oracle.try_finish_setup(&f.admin), + f.oracle.try_finish_setup(), OracleError::AdminTransferPending, ); f.oracle.transfer_admin_role(&next, &0); - f.oracle.finish_setup(&f.admin); + f.oracle.finish_setup(); assert!(f.oracle.try_accept_admin_transfer().is_err()); assert_eq!(f.oracle.get_admin(), Some(f.admin.clone())); } @@ -543,12 +546,12 @@ fn setup_cannot_finish_while_a_hand_over_is_pending() { #[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.bind_vault(&f.vault.address); 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); + f.oracle.finish_setup(); assert!(!f.oracle.setup_open()); assert_eq!( @@ -563,8 +566,7 @@ fn nothing_is_proposed_during_setup() { let f = setup(); refused( - f.oracle - .try_propose_change(&OracleChange::Config(config()), &f.admin), + f.oracle.try_propose_change(&OracleChange::Config(config())), OracleError::SetupOpen, ); assert_eq!(f.oracle.pending_changes().len(), 0); @@ -583,7 +585,7 @@ fn grants_wait_after_setup_and_land_through_the_timelock() { let grant = f.propose(OracleChange::GrantRole(GUARDIAN_ROLE, next.clone())); f.advance(MIN_DELAY); - f.oracle.apply_change(&grant, &f.admin); + f.oracle.apply_change(&grant); f.oracle.pause_nav(&next); assert_eq!(f.oracle.state(), OracleState::Paused); @@ -628,7 +630,7 @@ fn a_revoke_lands_through_the_timelock() { let id = f.propose(OracleChange::RevokeRole(GUARDIAN_ROLE, f.guardian.clone())); f.advance(MIN_DELAY); - f.oracle.apply_change(&id, &f.admin); + f.oracle.apply_change(&id); assert!(f.oracle.has_role(&f.guardian, &GUARDIAN_ROLE).is_none()); } @@ -657,7 +659,7 @@ fn an_admin_hand_over_waits_but_cancelling_one_does_not() { let id = f.propose(OracleChange::Admin(next.clone())); f.advance(MIN_DELAY); - f.oracle.apply_change(&id, &f.admin); + f.oracle.apply_change(&id); f.oracle.transfer_admin_role(&next, &0); assert!(f.oracle.try_accept_admin_transfer().is_err()); @@ -672,26 +674,9 @@ fn an_applied_admin_change_starts_a_hand_over_the_next_admin_accepts() { 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); + // One admin signature covers the call; the hand-over inside asks for none. + f.sign_only_as(&f.admin, "apply_change", (id,).into_val(&f.e)); + f.oracle.apply_change(&id); f.e.mock_all_auths(); assert_eq!(f.oracle.get_admin(), Some(f.admin.clone())); @@ -736,9 +721,9 @@ fn the_nav_pause_and_clearing_stay_instant_after_setup() { 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); + f.oracle.set_nav_paused(&false); + f.oracle.set_nav_paused(&true); + f.oracle.clear_latest(); assert_eq!(f.oracle.state(), OracleState::Paused); assert_eq!( @@ -763,23 +748,17 @@ fn a_vault_pause_moves_the_eta_and_holds_the_change() { 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, - ); + refused(f.oracle.try_apply_change(&id), 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, - ); + refused(f.oracle.try_apply_change(&id), OracleError::ChangeNotReady); f.advance(3 * DAY_DURATION); - f.oracle.apply_change(&id, &f.admin); + f.oracle.apply_change(&id); assert_eq!(f.oracle.config().freshness_duration, 7_200); } @@ -795,15 +774,11 @@ fn nothing_is_proposed_or_applied_while_the_vault_winds_down() { 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), + f.oracle.try_propose_change(&OracleChange::Config(config())), OracleError::WindDownActive, ); - f.oracle.cancel_change(&id, &f.admin); + refused(f.oracle.try_apply_change(&id), OracleError::WindDownActive); + f.oracle.cancel_change(&id); } #[test] @@ -821,7 +796,7 @@ fn a_change_shorter_than_a_raised_notice_never_lands() { f.advance(30 * DAY_DURATION); refused( - f.oracle.try_apply_change(&id, &f.admin), + f.oracle.try_apply_change(&id), OracleError::DelayBelowNotice, ); } @@ -833,10 +808,8 @@ fn one_upgrade_waits_at_a_time() { 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, - ), + f.oracle + .try_propose_change(&OracleChange::Upgrade(BytesN::from_array(&f.e, &[9; 32]))), OracleError::UpgradeAlreadyPending, ); } @@ -853,21 +826,13 @@ fn only_the_admin_proposes_cancels_and_applies() { f.sign_only_as( &stranger, "propose_change", - (change.clone(), stranger.clone()).into_val(&f.e), + (change.clone(),).into_val(&f.e), ); - assert!(f.oracle.try_propose_change(&change, &stranger).is_err()); - f.sign_only_as( - &stranger, - "cancel_change", - (id, stranger.clone()).into_val(&f.e), - ); - assert!(f.oracle.try_cancel_change(&id, &stranger).is_err()); - f.sign_only_as( - &stranger, - "apply_change", - (id, stranger.clone()).into_val(&f.e), - ); - assert!(f.oracle.try_apply_change(&id, &stranger).is_err()); + assert!(f.oracle.try_propose_change(&change).is_err()); + f.sign_only_as(&stranger, "cancel_change", (id,).into_val(&f.e)); + assert!(f.oracle.try_cancel_change(&id).is_err()); + f.sign_only_as(&stranger, "apply_change", (id,).into_val(&f.e)); + assert!(f.oracle.try_apply_change(&id).is_err()); assert_eq!(f.oracle.pending_changes().len(), 1); } @@ -894,7 +859,7 @@ fn proposing_and_applying_publish_the_change() { ); f.advance(MIN_DELAY); - f.oracle.apply_change(&id, &f.admin); + f.oracle.apply_change(&id); assert_eq!( f.e.events().all().filter_by_contract(&f.oracle.address), [ @@ -959,17 +924,100 @@ fn setup_grants_hand_overs_and_changes_restore_the_instance_ttl() { .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)); + f.restores_the_instance(|| f.oracle.bind_vault(&f.vault.address)); + f.restores_the_instance(|| f.oracle.finish_setup()); 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)); + f.restores_the_instance(|| f.oracle.cancel_change(&id)); let id = f.propose(change); f.advance(MIN_DELAY); - f.restores_the_instance(|| f.oracle.apply_change(&id, &f.admin)); + f.restores_the_instance(|| f.oracle.apply_change(&id)); +} + +#[test] +fn each_admin_call_asks_the_admin_for_one_signature() { + let f = setup(); + let holder = Address::generate(&f.e); + let live_until = f.e.ledger().sequence() + ADMIN_ACCEPT_LEDGERS; + + f.oracle.set_config(&config()); + f.signed_once_by(&f.admin, "set_config", (config(),).into_val(&f.e)); + f.oracle.grant_role(&holder, &ATTESTER_ROLE, &f.admin); + f.signed_once_by( + &f.admin, + "grant_role", + (holder.clone(), ATTESTER_ROLE, f.admin.clone()).into_val(&f.e), + ); + f.oracle.revoke_role(&holder, &ATTESTER_ROLE, &f.admin); + f.signed_once_by( + &f.admin, + "revoke_role", + (holder.clone(), ATTESTER_ROLE, f.admin.clone()).into_val(&f.e), + ); + f.oracle.transfer_admin_role(&holder, &live_until); + f.signed_once_by( + &f.admin, + "transfer_admin_role", + (holder.clone(), live_until).into_val(&f.e), + ); + f.oracle.transfer_admin_role(&holder, &0); + f.signed_once_by( + &f.admin, + "transfer_admin_role", + (holder.clone(), 0_u32).into_val(&f.e), + ); + + f.oracle.bind_vault(&f.vault.address); + f.signed_once_by( + &f.admin, + "bind_vault", + (f.vault.address.clone(),).into_val(&f.e), + ); + f.oracle.finish_setup(); + f.signed_once_by(&f.admin, "finish_setup", soroban_sdk::Vec::new(&f.e)); + + let change = OracleChange::Config(config()); + let id = f.oracle.propose_change(&change); + f.signed_once_by(&f.admin, "propose_change", (change.clone(),).into_val(&f.e)); + f.oracle.cancel_change(&id); + f.signed_once_by(&f.admin, "cancel_change", (id,).into_val(&f.e)); + let id = f.oracle.propose_change(&change); + f.advance(MIN_DELAY); + f.oracle.apply_change(&id); + f.signed_once_by(&f.admin, "apply_change", (id,).into_val(&f.e)); + + f.oracle.pause_nav(&f.guardian); + f.oracle.clear_latest(); + f.signed_once_by(&f.admin, "clear_latest", soroban_sdk::Vec::new(&f.e)); + f.oracle.set_nav_paused(&false); + f.signed_once_by(&f.admin, "set_nav_paused", (false,).into_val(&f.e)); +} + +#[test] +fn each_role_call_asks_its_holder_for_one_signature() { + let f = setup(); + let next = Address::generate(&f.e); + + f.oracle.attest(&report(SCALE), &f.attester); + f.signed_once_by( + &f.attester, + "attest", + (report(SCALE), f.attester.clone()).into_val(&f.e), + ); + f.oracle.pause_nav(&f.guardian); + f.signed_once_by( + &f.guardian, + "pause_nav", + (f.guardian.clone(),).into_val(&f.e), + ); + + f.oracle + .transfer_admin_role(&next, &(f.e.ledger().sequence() + ADMIN_ACCEPT_LEDGERS)); + f.oracle.accept_admin_transfer(); + f.signed_once_by(&next, "accept_admin_transfer", soroban_sdk::Vec::new(&f.e)); } #[cfg(feature = "upgrade_wasm")] @@ -1041,15 +1089,15 @@ mod with_wasm { let id = f.propose(OracleChange::Upgrade(probe_hash)); f.advance(MIN_DELAY); - f.oracle.apply_change(&id, &f.admin); + f.oracle.apply_change(&id); let probe = oracle_probe::Client::new(&f.e, &f.oracle.address); assert!(probe.probe()); assert_eq!(probe.schema_version(), 1); - probe.migrate(&f.admin); + probe.migrate(); assert_eq!(probe.schema_version(), 2); assert_eq!( - probe.try_migrate(&f.admin), + probe.try_migrate(), Err(Ok(oracle_probe::ProbeError::AlreadyMigrated.into())) ); diff --git a/contracts/nav-oracle/src/test_guards.rs b/contracts/nav-oracle/src/test_guards.rs index 934ff514..63b8989f 100644 --- a/contracts/nav-oracle/src/test_guards.rs +++ b/contracts/nav-oracle/src/test_guards.rs @@ -37,15 +37,14 @@ struct Fixture<'a> { e: Env, oracle: NavOracleContractClient<'a>, vault: Address, - admin: Address, attester: Address, guardian: Address, } impl Fixture<'_> { fn finish_setup(&self) { - self.oracle.bind_vault(&self.vault, &self.admin); - self.oracle.finish_setup(&self.admin); + self.oracle.bind_vault(&self.vault); + self.oracle.finish_setup(); } fn attest(&self, nav_per_share: i128) { @@ -82,7 +81,7 @@ fn setup<'a>() -> Fixture<'a> { &e, &e.register( NavOracleContract, - (admin.clone(), attester.clone(), guardian.clone(), config()), + (admin, attester.clone(), guardian.clone(), config()), ), ); let vault = e.register(VaultStub, ()); @@ -90,7 +89,6 @@ fn setup<'a>() -> Fixture<'a> { e, oracle, vault, - admin, attester, guardian, } @@ -270,7 +268,7 @@ fn not_a_second_upgrade_refuses_an_upgrade_while_one_waits() { let grant = OracleChange::GrantRole(symbol_short!("attester"), Address::generate(&f.e)); holds(&f, || guards::not_a_second_upgrade(&f.e, &upgrade)); - f.oracle.propose_change(&upgrade, &f.admin); + f.oracle.propose_change(&upgrade); refuses(&f, OracleError::UpgradeAlreadyPending, || { guards::not_a_second_upgrade(&f.e, &upgrade) }); diff --git a/scripts/harness/deploy.ts b/scripts/harness/deploy.ts index d855bc43..b4bb482a 100644 --- a/scripts/harness/deploy.ts +++ b/scripts/harness/deploy.ts @@ -215,8 +215,8 @@ async function grantAndVerify( 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() + await (await feed.bind_vault({ vault })).signAndSend() + await (await feed.finish_setup()).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") diff --git a/scripts/harness/guards.test.ts b/scripts/harness/guards.test.ts index 5b261b0e..1a0b0b18 100644 --- a/scripts/harness/guards.test.ts +++ b/scripts/harness/guards.test.ts @@ -132,19 +132,18 @@ describe("an account with no role", () => { it("cannot set the NAV pause", async () => { await expect( - send(oracle(c, outsider).set_nav_paused({ paused: true, caller: who })), + send(oracle(c, outsider).set_nav_paused({ paused: true })), ).rejects.toThrow(/signatures/) }) it("cannot clear the latest report", async () => { await send(oracle(c, guardian).pause_nav({ caller: guardian.publicKey() })) - await expect( - send(oracle(c, outsider).clear_latest({ caller: who })), - ).rejects.toThrow(/signatures/) + await expect(send(oracle(c, outsider).clear_latest())).rejects.toThrow( + /signatures/, + ) await send( oracle(c, governance).set_nav_paused({ paused: false, - caller: governance.publicKey(), }), ) }) @@ -249,8 +248,6 @@ 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) @@ -287,15 +284,10 @@ describe("the oracle after setup", () => { 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() })) + expectRefused(await o.apply_change({ id }), o, "ChangeNotReady") + await send(o.cancel_change({ id })) expect((await o.pending_changes()).result).toEqual([]) }) }) @@ -742,7 +734,6 @@ describe("the notice period and valuation age rules", () => { await send( oracle(c, governance).set_nav_paused({ paused: false, - caller: governance.publicKey(), }), ) await send( From 38734a554d28c473ec65ea7d188e596da7f97cdb Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:39:24 -0300 Subject: [PATCH 084/147] fix: ask the token's admin to sign once The token's own admin calls drop the caller argument that made the admin sign twice. pause, unpause, set_compliance and set_identity_verifier keep the argument their OpenZeppelin trait fixes: they refuse any account but the admin with AdminRequired and ask it to sign once. --- contracts/async-vault/src/test/mod.rs | 10 +- contracts/async-vault/src/test/upgrade.rs | 10 +- contracts/nav-oracle/src/test_governance.rs | 2 +- contracts/share-token/src/contract.rs | 26 +- contracts/share-token/src/error.rs | 8 +- contracts/share-token/src/guards.rs | 7 + contracts/share-token/src/test.rs | 114 +++++++- contracts/share-token/src/test_governance.rs | 275 ++++++++++++------- contracts/share-token/src/test_guards.rs | 23 +- scripts/harness/deploy.ts | 4 +- scripts/harness/guards.test.ts | 15 +- 11 files changed, 348 insertions(+), 146 deletions(-) diff --git a/contracts/async-vault/src/test/mod.rs b/contracts/async-vault/src/test/mod.rs index 80f571d5..4024a4ff 100644 --- a/contracts/async-vault/src/test/mod.rs +++ b/contracts/async-vault/src/test/mod.rs @@ -380,13 +380,13 @@ fn build<'a>( match miswire { Miswire::TokenUnbound => {} Miswire::TokenBoundElsewhere => { - share.bind_vault(®ister_with(&e, distinct_roles(&e)), &admin); - share.finish_setup(&admin); + share.bind_vault(®ister_with(&e, distinct_roles(&e))); + share.finish_setup(); } - Miswire::TokenSetupOpen => share.bind_vault(&contract_id, &admin), + Miswire::TokenSetupOpen => share.bind_vault(&contract_id), _ => { - share.bind_vault(&contract_id, &admin); - share.finish_setup(&admin); + share.bind_vault(&contract_id); + share.finish_setup(); } } match miswire { diff --git a/contracts/async-vault/src/test/upgrade.rs b/contracts/async-vault/src/test/upgrade.rs index 5c4ed548..b9e4033b 100644 --- a/contracts/async-vault/src/test/upgrade.rs +++ b/contracts/async-vault/src/test/upgrade.rs @@ -502,19 +502,17 @@ mod with_wasm { assert_eq!(f.vault.request_deposit(&depositor, &500), epoch); let probe_hash = f.e.deployer().upload_contract_wasm(token_probe::WASM); - let id = f - .share - .propose_change(&TokenChange::Upgrade(probe_hash), &f.admin); + let id = f.share.propose_change(&TokenChange::Upgrade(probe_hash)); f.advance(MIN_UPGRADE_DELAY); - f.share.apply_change(&id, &f.admin); + f.share.apply_change(&id); let probe = token_probe::Client::new(&f.e, &f.share.address); assert!(probe.probe()); assert_eq!(probe.schema_version(), 1); - probe.migrate(&f.admin); + probe.migrate(); assert_eq!(probe.schema_version(), 2); assert_eq!( - probe.try_migrate(&f.admin), + probe.try_migrate(), Err(Ok(token_probe::ProbeError::AlreadyMigrated.into())) ); diff --git a/contracts/nav-oracle/src/test_governance.rs b/contracts/nav-oracle/src/test_governance.rs index 7779b8c1..a9c58379 100644 --- a/contracts/nav-oracle/src/test_governance.rs +++ b/contracts/nav-oracle/src/test_governance.rs @@ -114,7 +114,7 @@ impl Fixture<'_> { } /// Signs one call to the oracle as `who` and nothing else, so the call - /// carries the caller's signature but not the admin's. + /// carries that account's signature but not the admin's. fn sign_only_as(&self, who: &Address, fn_name: &str, args: soroban_sdk::Vec) { self.e.mock_auths(&[MockAuth { address: who, diff --git a/contracts/share-token/src/contract.rs b/contracts/share-token/src/contract.rs index f5a70045..28f98927 100644 --- a/contracts/share-token/src/contract.rs +++ b/contracts/share-token/src/contract.rs @@ -55,31 +55,27 @@ impl ShareToken { /// Names the vault whose clock this token's timelock reads. Once, and only /// during setup. #[only_admin] - pub fn bind_vault(e: &Env, vault: Address, caller: Address) { - caller.require_auth(); + pub fn bind_vault(e: &Env, vault: Address) { strata_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(); + pub fn finish_setup(e: &Env) { strata_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: TokenChange, caller: Address) -> u32 { - caller.require_auth(); + pub fn propose_change(e: &Env, change: TokenChange) -> u32 { strata_storage::bump_instance(e); governance::propose(e, change) } #[only_admin] - pub fn cancel_change(e: &Env, id: u32, caller: Address) { - caller.require_auth(); + pub fn cancel_change(e: &Env, id: u32) { strata_storage::bump_instance(e); governance::cancel(e, id); } @@ -87,8 +83,7 @@ impl ShareToken { /// 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(); + pub fn apply_change(e: &Env, id: u32) { strata_storage::bump_instance(e); governance::apply(e, id); } @@ -139,14 +134,14 @@ impl VaultBinding for ShareToken { #[contractimpl(contracttrait)] impl Pausable for ShareToken { - #[only_admin] fn pause(e: &Env, caller: Address) { + guards::caller_is_admin(e, &caller); caller.require_auth(); strata_storage::bump_instance(e); pausable::pause(e); } - #[only_admin] fn unpause(e: &Env, caller: Address) { + guards::caller_is_admin(e, &caller); caller.require_auth(); strata_storage::bump_instance(e); pausable::unpause(e); @@ -272,15 +267,15 @@ impl RWAToken for ShareToken { strata_storage::bump_instance(e); RWA::unfreeze_partial_tokens(e, &user_address, amount); } - #[only_admin] fn set_compliance(e: &Env, compliance: Address, operator: Address) { + guards::caller_is_admin(e, &operator); operator.require_auth(); guards::direct_change_allowed(e); strata_storage::bump_instance(e); RWA::set_compliance(e, &compliance); } - #[only_admin] fn set_identity_verifier(e: &Env, identity_verifier: Address, operator: Address) { + guards::caller_is_admin(e, &operator); operator.require_auth(); guards::direct_change_allowed(e); strata_storage::bump_instance(e); @@ -301,8 +296,7 @@ pub enum ProbeError { #[contractimpl] impl ShareToken { #[only_admin] - pub fn migrate(e: &Env, caller: Address) { - caller.require_auth(); + pub fn migrate(e: &Env) { strata_storage::bump_instance(e); if upgradeable::get_schema_version(e) >= 2 { panic_with_error!(e, ProbeError::AlreadyMigrated); diff --git a/contracts/share-token/src/error.rs b/contracts/share-token/src/error.rs index 31353a62..7d9779b5 100644 --- a/contracts/share-token/src/error.rs +++ b/contracts/share-token/src/error.rs @@ -6,9 +6,11 @@ use soroban_sdk::contracterror; #[derive(Copy, Clone, Debug, Eq, PartialEq)] #[repr(u32)] pub enum ShareTokenError { - /// The token must always have an admin: `pause`, `unpause` and role - /// management need it. Hand it over instead: propose and apply an `Admin` - /// change (`transfer_admin_role` during setup), then the nominee calls + /// The call needs the admin. `pause`, `unpause`, `set_compliance` and + /// `set_identity_verifier` refuse any other caller. `renounce_admin` is + /// always refused, because the token must always have an admin: hand it + /// over instead by proposing and applying an `Admin` change + /// (`transfer_admin_role` during setup), then the nominee calls /// `accept_admin_transfer`. AdminRequired = 7000, /// No pending change has this id. diff --git a/contracts/share-token/src/guards.rs b/contracts/share-token/src/guards.rs index 4f09e2cb..fcd26a7f 100644 --- a/contracts/share-token/src/guards.rs +++ b/contracts/share-token/src/guards.rs @@ -61,6 +61,13 @@ fn upgrade_pending(e: &Env) -> bool { }) } +/// Stands in for `#[only_admin]` where an OpenZeppelin trait fixes the caller. +pub(crate) fn caller_is_admin(e: &Env, caller: &Address) { + if access_control::get_admin(e).as_ref() != Some(caller) { + panic_with_error!(e, ShareTokenError::AdminRequired); + } +} + /// The admin signs too, and is a different account from the caller. pub(crate) fn admin_co_signs(e: &Env, caller: &Address) { let admin = access_control::enforce_admin_auth(e); diff --git a/contracts/share-token/src/test.rs b/contracts/share-token/src/test.rs index be526da7..df9ec251 100644 --- a/contracts/share-token/src/test.rs +++ b/contracts/share-token/src/test.rs @@ -1,7 +1,12 @@ +extern crate std; + use soroban_sdk::{ symbol_short, - testutils::{storage::Instance as _, Address as _, Ledger as _, MockAuth, MockAuthInvoke}, - Address, Env, IntoVal, String, Symbol, + testutils::{ + storage::Instance as _, Address as _, AuthorizedFunction, AuthorizedInvocation, + Ledger as _, MockAuth, MockAuthInvoke, + }, + Address, Env, IntoVal, String, Symbol, Val, Vec, }; use stellar_access::access_control::{self as access_control, AccessControlError}; use stellar_tokens::rwa::RWAError; @@ -61,6 +66,25 @@ struct Fixture<'a> { identity_verifier: Address, } +impl Fixture<'_> { + /// The last call asked each of `signers`, in order, to sign it once, and nobody else. + fn signed_once_by(&self, signers: &[&Address], fn_name: &str, args: Vec) { + let call = AuthorizedInvocation { + function: AuthorizedFunction::Contract(( + self.token.address.clone(), + Symbol::new(&self.e, fn_name), + args, + )), + sub_invocations: std::vec![], + }; + let expected: std::vec::Vec<_> = signers + .iter() + .map(|who| ((*who).clone(), call.clone())) + .collect(); + assert_eq!(self.e.auths(), expected); + } +} + fn setup_fixture(e: &Env) -> Fixture<'_> { let admin = Address::generate(e); let manager = Address::generate(e); @@ -533,6 +557,55 @@ fn a_legal_transfer_restores_the_instance_ttl() { assert_eq!(instance_ttl(&e, &f.token.address), INSTANCE_EXTEND_AMOUNT); } +#[test] +fn each_role_call_asks_its_holder_for_one_signature() { + let e = Env::default(); + let (f, identity) = setup_live(&e); + let holder = Address::generate(&e); + let other = Address::generate(&e); + identity.allow(&holder, &true); + identity.allow(&other, &true); + + f.token.mint(&holder, &1_000, &f.manager); + f.signed_once_by( + &[&f.manager], + "mint", + (holder.clone(), 1_000_i128, f.manager.clone()).into_val(&e), + ); + f.token.burn(&holder, &100, &f.manager); + f.signed_once_by( + &[&f.manager], + "burn", + (holder.clone(), 100_i128, f.manager.clone()).into_val(&e), + ); + f.token.forced_transfer(&holder, &other, &100, &f.manager); + f.signed_once_by( + &[&f.manager], + "forced_transfer", + (holder.clone(), other, 100_i128, f.manager.clone()).into_val(&e), + ); + + let compliance = &f.compliance_authority; + f.token.freeze_partial_tokens(&holder, &50, compliance); + f.signed_once_by( + &[compliance], + "freeze_partial_tokens", + (holder.clone(), 50_i128, compliance.clone()).into_val(&e), + ); + f.token.unfreeze_partial_tokens(&holder, &50, compliance); + f.signed_once_by( + &[compliance], + "unfreeze_partial_tokens", + (holder.clone(), 50_i128, compliance.clone()).into_val(&e), + ); + f.token.set_address_frozen(&holder, &true, compliance); + f.signed_once_by( + &[compliance], + "set_address_frozen", + (holder, true, compliance.clone()).into_val(&e), + ); +} + mod legal_transfer { use soroban_sdk::{ contract, contractimpl, contracttype, @@ -580,7 +653,7 @@ mod legal_transfer { } f.token.mint(&holder, &1_000, &f.manager); f.token.mint(&vault, &500, &f.manager); - f.token.bind_vault(&vault, &f.admin); + f.token.bind_vault(&vault); (f, holder, destination, vault) } @@ -878,6 +951,41 @@ mod legal_transfer { assert_eq!(f.token.balance(&destination), 1_000); } + #[test] + fn a_legal_transfer_or_a_recovery_asks_each_signer_once() { + let e = Env::default(); + let (f, holder, destination, _) = live(&e); + + f.token.legal_transfer( + &holder, + &destination, + &250, + &order(&e), + &f.compliance_authority, + ); + f.signed_once_by( + &[&f.compliance_authority, &f.admin], + "legal_transfer", + ( + holder.clone(), + destination.clone(), + 250_i128, + order(&e), + f.compliance_authority.clone(), + ) + .into_val(&e), + ); + + recovering_to(&e, &f, &destination); + f.token + .recover_balance(&holder, &destination, &f.compliance_authority); + f.signed_once_by( + &[&f.compliance_authority, &f.admin], + "recover_balance", + (holder, destination, f.compliance_authority.clone()).into_val(&e), + ); + } + #[test] fn recovery_never_takes_the_vault_balance() { let e = Env::default(); diff --git a/contracts/share-token/src/test_governance.rs b/contracts/share-token/src/test_governance.rs index b0510654..4e670937 100644 --- a/contracts/share-token/src/test_governance.rs +++ b/contracts/share-token/src/test_governance.rs @@ -1,7 +1,10 @@ +extern crate std; + use soroban_sdk::{ contract, contractimpl, symbol_short, testutils::{ - storage::Instance as _, Address as _, Events as _, Ledger as _, MockAuth, MockAuthInvoke, + storage::Instance as _, Address as _, AuthorizedFunction, AuthorizedInvocation, + Events as _, Ledger as _, MockAuth, MockAuthInvoke, }, Address, BytesN, Env, Event as _, IntoVal, String, Symbol, }; @@ -66,19 +69,19 @@ impl Fixture<'_> { } fn finish(&self) { - self.token.bind_vault(&self.vault.address, &self.admin); - self.token.finish_setup(&self.admin); + self.token.bind_vault(&self.vault.address); + self.token.finish_setup(); } fn propose(&self, change: TokenChange) -> u32 { - self.token.propose_change(&change, &self.admin) + self.token.propose_change(&change) } /// Lands an `Admin` change for `next` and returns the ledger of the offer. fn offer_admin(&self, next: &Address) -> u32 { let id = self.propose(TokenChange::Admin(next.clone())); self.advance(MIN_DELAY); - self.token.apply_change(&id, &self.admin); + self.token.apply_change(&id); self.e.ledger().sequence() } @@ -100,7 +103,7 @@ impl Fixture<'_> { } /// Signs one call to the token as `who` and nothing else, so the call - /// carries the caller's signature but not the admin's. + /// carries that account's signature but not the admin's. fn sign_only_as(&self, who: &Address, fn_name: &str, args: soroban_sdk::Vec) { self.e.mock_auths(&[MockAuth { address: who, @@ -112,6 +115,29 @@ impl Fixture<'_> { }, }]); } + + /// The last call asked `who` alone to sign, once, on exactly this call. + fn signed_once_by( + &self, + who: &Address, + fn_name: &str, + args: soroban_sdk::Vec, + ) { + assert_eq!( + self.e.auths(), + std::vec![( + who.clone(), + AuthorizedInvocation { + function: AuthorizedFunction::Contract(( + self.token.address.clone(), + Symbol::new(&self.e, fn_name), + args, + )), + sub_invocations: std::vec![], + }, + )] + ); + } } fn setup<'a>() -> Fixture<'a> { @@ -165,21 +191,15 @@ fn compliance(e: &Env) -> Symbol { fn setup_is_open_until_finished_and_never_reopens() { let f = setup(); assert!(f.token.setup_open()); - refused( - f.token.try_finish_setup(&f.admin), - ShareTokenError::VaultNotBound, - ); + refused(f.token.try_finish_setup(), ShareTokenError::VaultNotBound); f.finish(); assert!(!f.token.setup_open()); assert_eq!(f.token.vault(), Some(f.vault.address.clone())); + refused(f.token.try_finish_setup(), ShareTokenError::SetupClosed); refused( - f.token.try_finish_setup(&f.admin), - ShareTokenError::SetupClosed, - ); - refused( - f.token.try_bind_vault(&Address::generate(&f.e), &f.admin), + f.token.try_bind_vault(&Address::generate(&f.e)), ShareTokenError::SetupClosed, ); } @@ -187,10 +207,10 @@ fn setup_is_open_until_finished_and_never_reopens() { #[test] fn a_second_vault_cannot_be_bound() { let f = setup(); - f.token.bind_vault(&f.vault.address, &f.admin); + f.token.bind_vault(&f.vault.address); refused( - f.token.try_bind_vault(&Address::generate(&f.e), &f.admin), + f.token.try_bind_vault(&Address::generate(&f.e)), ShareTokenError::SetupClosed, ); assert_eq!(f.token.vault(), Some(f.vault.address.clone())); @@ -200,10 +220,7 @@ fn a_second_vault_cannot_be_bound() { fn an_address_that_reports_no_clock_cannot_be_bound() { let f = setup(); - assert!(f - .token - .try_bind_vault(&Address::generate(&f.e), &f.admin) - .is_err()); + assert!(f.token.try_bind_vault(&Address::generate(&f.e)).is_err()); assert_eq!(f.token.vault(), None); } @@ -215,19 +232,15 @@ fn only_the_admin_binds_and_finishes() { f.sign_only_as( &stranger, "bind_vault", - (f.vault.address.clone(), stranger.clone()).into_val(&f.e), + (f.vault.address.clone(),).into_val(&f.e), ); - assert!(f.token.try_bind_vault(&f.vault.address, &stranger).is_err()); + assert!(f.token.try_bind_vault(&f.vault.address).is_err()); assert_eq!(f.token.vault(), None); f.e.mock_all_auths(); - f.token.bind_vault(&f.vault.address, &f.admin); - f.sign_only_as( - &stranger, - "finish_setup", - (stranger.clone(),).into_val(&f.e), - ); - assert!(f.token.try_finish_setup(&stranger).is_err()); + f.token.bind_vault(&f.vault.address); + f.sign_only_as(&stranger, "finish_setup", soroban_sdk::Vec::new(&f.e)); + assert!(f.token.try_finish_setup().is_err()); assert!(f.token.setup_open()); } @@ -311,7 +324,7 @@ fn an_admin_hand_over_waits_but_cancelling_one_does_not() { let id = f.propose(TokenChange::Admin(next.clone())); f.advance(MIN_DELAY); - f.token.apply_change(&id, &f.admin); + f.token.apply_change(&id); f.token.transfer_admin_role(&next, &0); assert!(f.token.try_accept_admin_transfer().is_err()); @@ -322,17 +335,17 @@ fn an_admin_hand_over_waits_but_cancelling_one_does_not() { fn setup_cannot_finish_while_a_hand_over_is_pending() { let f = setup(); let next = Address::generate(&f.e); - f.token.bind_vault(&f.vault.address, &f.admin); + f.token.bind_vault(&f.vault.address); f.token .transfer_admin_role(&next, &(f.e.ledger().sequence() + 100)); refused( - f.token.try_finish_setup(&f.admin), + f.token.try_finish_setup(), ShareTokenError::AdminTransferPending, ); f.token.transfer_admin_role(&next, &0); - f.token.finish_setup(&f.admin); + f.token.finish_setup(); assert!(f.token.try_accept_admin_transfer().is_err()); assert_eq!(f.token.get_admin(), Some(f.admin.clone())); } @@ -340,12 +353,12 @@ fn setup_cannot_finish_while_a_hand_over_is_pending() { #[test] fn an_expired_hand_over_does_not_hold_setup_open() { let f = setup(); - f.token.bind_vault(&f.vault.address, &f.admin); + f.token.bind_vault(&f.vault.address); f.token .transfer_admin_role(&Address::generate(&f.e), &(f.e.ledger().sequence() + 10)); f.e.ledger().with_mut(|l| l.sequence_number += 11); - f.token.finish_setup(&f.admin); + f.token.finish_setup(); assert!(!f.token.setup_open()); assert_eq!( @@ -371,10 +384,8 @@ fn nothing_is_proposed_during_setup() { let f = setup(); refused( - f.token.try_propose_change( - &TokenChange::GrantRole(manager(), f.admin.clone()), - &f.admin, - ), + f.token + .try_propose_change(&TokenChange::GrantRole(manager(), f.admin.clone())), ShareTokenError::SetupOpen, ); assert_eq!(f.token.pending_changes().len(), 0); @@ -396,12 +407,12 @@ fn a_grant_lands_one_vault_delay_after_it_was_proposed() { f.advance(MIN_DELAY - 1); refused( - f.token.try_apply_change(&id, &f.admin), + f.token.try_apply_change(&id), ShareTokenError::ChangeNotReady, ); f.advance(1); - f.token.apply_change(&id, &f.admin); + f.token.apply_change(&id); assert!(f.token.has_role(&next, &manager()).is_some()); assert_eq!(f.token.pending_changes().len(), 0); } @@ -415,7 +426,7 @@ fn a_revoke_lands_through_the_timelock() { let id = f.propose(TokenChange::RevokeRole(manager(), holder.clone())); f.advance(MIN_DELAY); - f.token.apply_change(&id, &f.admin); + f.token.apply_change(&id); assert!(f.token.has_role(&holder, &manager()).is_none()); } @@ -433,10 +444,7 @@ fn a_vault_pause_moves_the_eta_and_holds_the_change() { f.clock(|c| c.paused_secs = 3 * DAY_DURATION); f.advance(MIN_DELAY); - refused( - f.token.try_apply_change(&id, &f.admin), - ShareTokenError::VaultPaused, - ); + refused(f.token.try_apply_change(&id), ShareTokenError::VaultPaused); f.clock(|c| c.paused = false); assert_eq!( @@ -444,7 +452,7 @@ fn a_vault_pause_moves_the_eta_and_holds_the_change() { eta + 3 * DAY_DURATION ); f.advance(3 * DAY_DURATION); - f.token.apply_change(&id, &f.admin); + f.token.apply_change(&id); } #[test] @@ -457,14 +465,14 @@ fn nothing_is_proposed_or_applied_while_the_vault_winds_down() { refused( f.token - .try_propose_change(&TokenChange::Compliance(Address::generate(&f.e)), &f.admin), + .try_propose_change(&TokenChange::Compliance(Address::generate(&f.e))), ShareTokenError::WindDownActive, ); refused( - f.token.try_apply_change(&id, &f.admin), + f.token.try_apply_change(&id), ShareTokenError::WindDownActive, ); - f.token.cancel_change(&id, &f.admin); + f.token.cancel_change(&id); } #[test] @@ -479,7 +487,7 @@ fn a_change_shorter_than_a_raised_notice_never_lands() { f.advance(30 * DAY_DURATION); refused( - f.token.try_apply_change(&id, &f.admin), + f.token.try_apply_change(&id), ShareTokenError::DelayBelowNotice, ); } @@ -491,10 +499,8 @@ fn one_upgrade_waits_at_a_time() { f.propose(TokenChange::Upgrade(BytesN::from_array(&f.e, &[7; 32]))); refused( - f.token.try_propose_change( - &TokenChange::Upgrade(BytesN::from_array(&f.e, &[9; 32])), - &f.admin, - ), + f.token + .try_propose_change(&TokenChange::Upgrade(BytesN::from_array(&f.e, &[9; 32]))), ShareTokenError::UpgradeAlreadyPending, ); } @@ -511,21 +517,13 @@ fn only_the_admin_proposes_cancels_and_applies() { f.sign_only_as( &stranger, "propose_change", - (change.clone(), stranger.clone()).into_val(&f.e), - ); - assert!(f.token.try_propose_change(&change, &stranger).is_err()); - f.sign_only_as( - &stranger, - "cancel_change", - (id, stranger.clone()).into_val(&f.e), - ); - assert!(f.token.try_cancel_change(&id, &stranger).is_err()); - f.sign_only_as( - &stranger, - "apply_change", - (id, stranger.clone()).into_val(&f.e), + (change.clone(),).into_val(&f.e), ); - assert!(f.token.try_apply_change(&id, &stranger).is_err()); + assert!(f.token.try_propose_change(&change).is_err()); + f.sign_only_as(&stranger, "cancel_change", (id,).into_val(&f.e)); + assert!(f.token.try_cancel_change(&id).is_err()); + f.sign_only_as(&stranger, "apply_change", (id,).into_val(&f.e)); + assert!(f.token.try_apply_change(&id).is_err()); assert_eq!(f.token.pending_changes().len(), 1); } @@ -537,26 +535,9 @@ fn an_applied_admin_change_starts_a_hand_over_the_next_admin_accepts() { let id = f.propose(TokenChange::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.token.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.token.apply_change(&id, &f.admin); + // One admin signature covers the call; the hand-over inside asks for none. + f.sign_only_as(&f.admin, "apply_change", (id,).into_val(&f.e)); + f.token.apply_change(&id); f.e.mock_all_auths(); assert_eq!(f.token.get_admin(), Some(f.admin.clone())); @@ -604,8 +585,8 @@ fn applied_rule_and_verifier_changes_take_effect() { let second = f.propose(TokenChange::IdentityVerifier(verifier.clone())); f.advance(MIN_DELAY); - f.token.apply_change(&first, &f.admin); - f.token.apply_change(&second, &f.admin); + f.token.apply_change(&first); + f.token.apply_change(&second); assert_eq!(f.token.compliance(), rules); assert_eq!(f.token.identity_verifier(), verifier); @@ -630,7 +611,7 @@ fn proposing_and_applying_publish_the_change() { ); f.advance(MIN_DELAY); - f.token.apply_change(&id, &f.admin); + f.token.apply_change(&id); let events = f.e.events().all().filter_by_contract(&f.token.address); assert_eq!( events.events().last(), @@ -686,15 +667,115 @@ fn every_admin_call_restores_the_instance_ttl() { .transfer_admin_role(&next, &(f.e.ledger().sequence() + ADMIN_ACCEPT_LEDGERS)) }); f.restores_the_instance(|| f.token.transfer_admin_role(&next, &0)); - f.restores_the_instance(|| f.token.bind_vault(&f.vault.address, &f.admin)); - f.restores_the_instance(|| f.token.finish_setup(&f.admin)); + f.restores_the_instance(|| f.token.bind_vault(&f.vault.address)); + f.restores_the_instance(|| f.token.finish_setup()); let rules = TokenChange::Compliance(Address::generate(&f.e)); let mut id = 0; f.restores_the_instance(|| id = f.propose(rules.clone())); - f.restores_the_instance(|| f.token.cancel_change(&id, &f.admin)); + f.restores_the_instance(|| f.token.cancel_change(&id)); let id = f.propose(rules); f.advance(MIN_DELAY); - f.restores_the_instance(|| f.token.apply_change(&id, &f.admin)); + f.restores_the_instance(|| f.token.apply_change(&id)); +} + +#[test] +fn each_admin_call_asks_the_admin_for_one_signature() { + let f = setup(); + let holder = Address::generate(&f.e); + let rules = Address::generate(&f.e); + let verifier = Address::generate(&f.e); + let live_until = f.e.ledger().sequence() + ADMIN_ACCEPT_LEDGERS; + + f.token.pause(&f.admin); + f.signed_once_by(&f.admin, "pause", (f.admin.clone(),).into_val(&f.e)); + f.token.unpause(&f.admin); + f.signed_once_by(&f.admin, "unpause", (f.admin.clone(),).into_val(&f.e)); + f.token.set_compliance(&rules, &f.admin); + f.signed_once_by( + &f.admin, + "set_compliance", + (rules.clone(), f.admin.clone()).into_val(&f.e), + ); + f.token.set_identity_verifier(&verifier, &f.admin); + f.signed_once_by( + &f.admin, + "set_identity_verifier", + (verifier, f.admin.clone()).into_val(&f.e), + ); + f.token.grant_role(&holder, &manager(), &f.admin); + f.signed_once_by( + &f.admin, + "grant_role", + (holder.clone(), manager(), f.admin.clone()).into_val(&f.e), + ); + f.token.revoke_role(&holder, &manager(), &f.admin); + f.signed_once_by( + &f.admin, + "revoke_role", + (holder.clone(), manager(), f.admin.clone()).into_val(&f.e), + ); + f.token.transfer_admin_role(&holder, &live_until); + f.signed_once_by( + &f.admin, + "transfer_admin_role", + (holder.clone(), live_until).into_val(&f.e), + ); + f.token.transfer_admin_role(&holder, &0); + f.signed_once_by( + &f.admin, + "transfer_admin_role", + (holder.clone(), 0_u32).into_val(&f.e), + ); + + f.token.bind_vault(&f.vault.address); + f.signed_once_by( + &f.admin, + "bind_vault", + (f.vault.address.clone(),).into_val(&f.e), + ); + f.token.finish_setup(); + f.signed_once_by(&f.admin, "finish_setup", soroban_sdk::Vec::new(&f.e)); + + let change = TokenChange::Compliance(rules); + let id = f.token.propose_change(&change); + f.signed_once_by(&f.admin, "propose_change", (change.clone(),).into_val(&f.e)); + f.token.cancel_change(&id); + f.signed_once_by(&f.admin, "cancel_change", (id,).into_val(&f.e)); + + let id = f.token.propose_change(&TokenChange::Admin(holder.clone())); + f.advance(MIN_DELAY); + f.token.apply_change(&id); + f.signed_once_by(&f.admin, "apply_change", (id,).into_val(&f.e)); + f.token.accept_admin_transfer(); + f.signed_once_by( + &holder, + "accept_admin_transfer", + soroban_sdk::Vec::new(&f.e), + ); +} + +#[test] +fn a_caller_other_than_the_admin_is_refused_even_when_it_signs() { + let f = setup(); + let stranger = Address::generate(&f.e); + + refused(f.token.try_pause(&stranger), ShareTokenError::AdminRequired); + f.token.pause(&f.admin); + refused( + f.token.try_unpause(&stranger), + ShareTokenError::AdminRequired, + ); + refused( + f.token + .try_set_compliance(&Address::generate(&f.e), &stranger), + ShareTokenError::AdminRequired, + ); + refused( + f.token + .try_set_identity_verifier(&Address::generate(&f.e), &stranger), + ShareTokenError::AdminRequired, + ); + assert!(f.token.paused()); } diff --git a/contracts/share-token/src/test_guards.rs b/contracts/share-token/src/test_guards.rs index 42c7bbbb..fa7a6208 100644 --- a/contracts/share-token/src/test_guards.rs +++ b/contracts/share-token/src/test_guards.rs @@ -42,8 +42,8 @@ struct Fixture<'a> { impl Fixture<'_> { fn finish_setup(&self) { - self.token.bind_vault(&self.vault, &self.admin); - self.token.finish_setup(&self.admin); + self.token.bind_vault(&self.vault); + self.token.finish_setup(); } } @@ -126,13 +126,26 @@ fn not_a_second_upgrade_refuses_an_upgrade_while_one_waits() { let grant = TokenChange::GrantRole(symbol_short!("manager"), Address::generate(&f.e)); holds(&f, || guards::not_a_second_upgrade(&f.e, &upgrade)); - f.token.propose_change(&upgrade, &f.admin); + f.token.propose_change(&upgrade); refuses(&f, ShareTokenError::UpgradeAlreadyPending, || { guards::not_a_second_upgrade(&f.e, &upgrade) }); holds(&f, || guards::not_a_second_upgrade(&f.e, &grant)); } +#[test] +fn caller_is_admin_refuses_anyone_but_the_admin() { + let f = setup(); + let stranger = Address::generate(&f.e); + holds(&f, || guards::caller_is_admin(&f.e, &f.admin)); + + for who in [&f.compliance_authority, &stranger] { + refuses(&f, ShareTokenError::AdminRequired, || { + guards::caller_is_admin(&f.e, who) + }); + } +} + #[test] fn admin_co_signs_refuses_one_account_signing_as_both() { let f = setup(); @@ -179,7 +192,7 @@ fn not_vault_address_refuses_the_bound_vault() { let holder = Address::generate(&f.e); holds(&f, || guards::not_vault_address(&f.e, &f.vault)); - f.token.bind_vault(&f.vault, &f.admin); + f.token.bind_vault(&f.vault); refuses(&f, ShareTokenError::VaultAddress, || { guards::not_vault_address(&f.e, &f.vault) }); @@ -190,7 +203,7 @@ fn not_vault_address_refuses_the_bound_vault() { fn not_freezing_vault_refuses_only_a_freeze_of_the_vault() { let f = setup(); let holder = Address::generate(&f.e); - f.token.bind_vault(&f.vault, &f.admin); + f.token.bind_vault(&f.vault); refuses(&f, ShareTokenError::VaultAddress, || { guards::not_freezing_vault(&f.e, &f.vault, true) diff --git a/scripts/harness/deploy.ts b/scripts/harness/deploy.ts index b4bb482a..a479f9e6 100644 --- a/scripts/harness/deploy.ts +++ b/scripts/harness/deploy.ts @@ -161,8 +161,8 @@ async function grantAndVerify( console.log("vault holds share_token manager role") // Grants are instant only during setup, so the token's setup closes after them. - await (await token.bind_vault({ vault, caller })).signAndSend() - await (await token.finish_setup({ caller })).signAndSend() + await (await token.bind_vault({ vault })).signAndSend() + await (await token.finish_setup()).signAndSend() if ((await token.setup_open()).result) throw new Error("the share token's setup did not finish") console.log("share_token bound to the vault, setup finished") diff --git a/scripts/harness/guards.test.ts b/scripts/harness/guards.test.ts index 1a0b0b18..2f35365c 100644 --- a/scripts/harness/guards.test.ts +++ b/scripts/harness/guards.test.ts @@ -130,6 +130,11 @@ describe("an account with no role", () => { await send(vault(c, governance).unpause({ caller: governance.publicKey() })) }) + it("cannot pause the share token", async () => { + const token = shareToken(c, outsider) + expectRefused(await token.pause({ caller: who }), token, "AdminRequired") + }) + it("cannot set the NAV pause", async () => { await expect( send(oracle(c, outsider).set_nav_paused({ paused: true })), @@ -206,7 +211,6 @@ describe("the compliance authority's reach", () => { tag: "IdentityVerifier", values: [c.deployment.contracts.compliance], }, - caller: compliance.publicKey(), }), ), ).rejects.toThrow(/signatures/) @@ -234,15 +238,10 @@ describe("the share token after setup", () => { const id = await send( token.propose_change({ change: { tag: "IdentityVerifier", values: [verifier()] }, - caller: caller(), }), ) - expectRefused( - await token.apply_change({ id, caller: caller() }), - token, - "ChangeNotReady", - ) - await send(token.cancel_change({ id, caller: caller() })) + expectRefused(await token.apply_change({ id }), token, "ChangeNotReady") + await send(token.cancel_change({ id })) expect((await token.pending_changes()).result).toEqual([]) }) }) From 41fc13ede60633858e9cdee488e6e031b0ad80fa Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:51:05 -0300 Subject: [PATCH 085/147] fix: ask the vault's admin to sign once The vault's own admin calls drop the caller argument that made the admin sign twice. unpause keeps the caller the Pausable trait fixes: it refuses any account but the admin with AdminRequired and asks it to sign once. --- contracts/async-vault/src/error.rs | 6 +- contracts/async-vault/src/guards.rs | 12 +- contracts/async-vault/src/lib.rs | 32 ++--- contracts/async-vault/src/test/compliance.rs | 2 +- contracts/async-vault/src/test/controls.rs | 37 +++-- contracts/async-vault/src/test/delisted.rs | 12 +- contracts/async-vault/src/test/deposit_cap.rs | 41 +++--- contracts/async-vault/src/test/governance.rs | 133 +++++++----------- contracts/async-vault/src/test/guards.rs | 27 +++- contracts/async-vault/src/test/health.rs | 2 +- contracts/async-vault/src/test/mod.rs | 29 +++- contracts/async-vault/src/test/notice.rs | 30 ++-- contracts/async-vault/src/test/oracle_swap.rs | 27 ++-- contracts/async-vault/src/test/setup.rs | 44 +++--- contracts/async-vault/src/test/signatures.rs | 110 +++++++++++++++ contracts/async-vault/src/test/treasury.rs | 14 +- contracts/async-vault/src/test/upgrade.rs | 131 ++++++++--------- contracts/async-vault/src/test/wind_down.rs | 81 +++++------ scripts/harness/deploy.ts | 4 +- scripts/harness/guards.test.ts | 25 +--- scripts/harness/wind-down.test.ts | 40 ++---- 21 files changed, 443 insertions(+), 396 deletions(-) create mode 100644 contracts/async-vault/src/test/signatures.rs diff --git a/contracts/async-vault/src/error.rs b/contracts/async-vault/src/error.rs index 5c92a6d4..8c246d06 100644 --- a/contracts/async-vault/src/error.rs +++ b/contracts/async-vault/src/error.rs @@ -52,8 +52,10 @@ pub enum VaultError { /// The epoch is priced, so the request can no longer be cancelled. The /// holder claims instead. AlreadyPriced = 6039, - /// The vault must always have an admin. Governance hands over in two steps - /// rather than stepping down into nobody. + /// The call needs the admin. `unpause` refuses any other caller. + /// `renounce_admin` is always refused, because the vault must always have + /// an admin: governance hands over in two steps rather than stepping down + /// into nobody. AdminRequired = 6040, /// The epoch is sealed and the feed can price it, so the price it will take /// is already knowable. Cancelling now would be declining a price after diff --git a/contracts/async-vault/src/guards.rs b/contracts/async-vault/src/guards.rs index 71029fda..784917d6 100644 --- a/contracts/async-vault/src/guards.rs +++ b/contracts/async-vault/src/guards.rs @@ -1,5 +1,8 @@ use soroban_sdk::{panic_with_error, Address, Env, Symbol}; -use stellar_access::{access_control::AccessControlStorageKey, role_transfer}; +use stellar_access::{ + access_control::{self, AccessControlStorageKey}, + role_transfer, +}; use stellar_tokens::{ fungible::FungibleTokenClient, rwa::{compliance::ComplianceClient as RulesClient, RWATokenClient}, @@ -94,6 +97,13 @@ pub(crate) fn no_hand_over_pending(e: &Env) { } } +/// Stands in for `#[only_admin]` where an OpenZeppelin trait fixes the caller. +pub(crate) fn caller_is_admin(e: &Env, caller: &Address) { + if access_control::get_admin(e).as_ref() != Some(caller) { + panic_with_error!(e, VaultError::AdminRequired); + } +} + pub(crate) fn wiring_complete(e: &Env) { if !governance::wiring(e).complete() { panic_with_error!(e, VaultError::SetupIncomplete); diff --git a/contracts/async-vault/src/lib.rs b/contracts/async-vault/src/lib.rs index 700a809e..11dc124a 100644 --- a/contracts/async-vault/src/lib.rs +++ b/contracts/async-vault/src/lib.rs @@ -178,8 +178,7 @@ impl AsyncVault { } #[only_admin] - pub fn set_deposit_cap(e: &Env, cap: Option, caller: Address) { - caller.require_auth(); + pub fn set_deposit_cap(e: &Env, cap: Option) { guards::non_negative_cap(e, cap); let old_cap = state::deposit_cap(e); state::set_deposit_cap(e, cap); @@ -193,8 +192,7 @@ impl AsyncVault { /// Instant during setup; afterwards the custodian changes through the /// timelock. #[only_admin] - pub fn set_custodian(e: &Env, custodian: Address, caller: Address) { - caller.require_auth(); + pub fn set_custodian(e: &Env, custodian: Address) { guards::direct_change_allowed(e); treasury::set_custodian(e, &custodian); } @@ -202,8 +200,7 @@ impl AsyncVault { /// Raising is instant. Lowering shortens the warning holders get before a /// wind-down starts, so after setup it waits for the timelock. #[only_admin] - pub fn set_wind_down_delay(e: &Env, secs: u64, caller: Address) { - caller.require_auth(); + pub fn set_wind_down_delay(e: &Env, secs: u64) { guards::wind_down_delay_in_bounds(e, secs); guards::not_lowered_after_setup(e, secs, wind_down::delay(e)); wind_down::set_delay(e, secs); @@ -218,14 +215,12 @@ impl AsyncVault { } #[only_admin] - pub fn propose_wind_down(e: &Env, caller: Address) { - caller.require_auth(); + pub fn propose_wind_down(e: &Env) { wind_down::propose(e); } #[only_admin] - pub fn cancel_wind_down_proposal(e: &Env, caller: Address) { - caller.require_auth(); + pub fn cancel_wind_down_proposal(e: &Env) { wind_down::cancel_proposal(e); } @@ -267,8 +262,7 @@ impl AsyncVault { /// Raising is instant. Lowering shortens the wait every closed epoch gives /// before it prices, so after setup it waits for the timelock. #[only_admin] - pub fn set_notice(e: &Env, secs: u64, caller: Address) { - caller.require_auth(); + pub fn set_notice(e: &Env, secs: u64) { guards::not_lowered_after_setup(e, secs, state::notice(e)); guards::notice_in_bounds(e, secs); governance::set_notice(e, secs); @@ -279,8 +273,7 @@ impl AsyncVault { /// manager, allowlisted, not frozen), no shares outstanding and no admin /// hand-over pending. Deposits open from here on, and it never reopens. #[only_admin] - pub fn finish_setup(e: &Env, caller: Address) { - caller.require_auth(); + pub fn finish_setup(e: &Env) { governance::finish_setup(e); } @@ -297,22 +290,19 @@ impl AsyncVault { /// Queues a change behind the timelock and returns its id. #[only_admin] - pub fn propose_change(e: &Env, change: VaultChange, caller: Address) -> u32 { - caller.require_auth(); + pub fn propose_change(e: &Env, change: VaultChange) -> u32 { governance::propose(e, change) } #[only_admin] - pub fn cancel_change(e: &Env, id: u32, caller: Address) { - caller.require_auth(); + pub fn cancel_change(e: &Env, id: u32) { governance::cancel(e, id); } /// Lands a change whose delay has run. Refused while paused or winding /// down. #[only_admin] - pub fn apply_change(e: &Env, id: u32, caller: Address) { - caller.require_auth(); + pub fn apply_change(e: &Env, id: u32) { governance::apply(e, id); } @@ -452,8 +442,8 @@ impl Pausable for AsyncVault { strata_timelock::pause::start(e); } - #[only_admin] fn unpause(e: &Env, caller: Address) { + guards::caller_is_admin(e, &caller); caller.require_auth(); pausable::unpause(e); strata_timelock::pause::end(e); diff --git a/contracts/async-vault/src/test/compliance.rs b/contracts/async-vault/src/test/compliance.rs index 6513e769..ae84f4a0 100644 --- a/contracts/async-vault/src/test/compliance.rs +++ b/contracts/async-vault/src/test/compliance.rs @@ -72,7 +72,7 @@ fn a_holder_delisted_after_pricing_is_paid_through_a_wind_down() { VaultError::AlreadyPriced, ); - f.vault.propose_wind_down(&f.admin); + f.vault.propose_wind_down(); f.advance(MIN_WIND_DOWN_DELAY); f.vault.activate_wind_down(); assert_eq!(f.vault.claim_deposit(&investor, &epoch), 0); diff --git a/contracts/async-vault/src/test/controls.rs b/contracts/async-vault/src/test/controls.rs index 6799488f..44059782 100644 --- a/contracts/async-vault/src/test/controls.rs +++ b/contracts/async-vault/src/test/controls.rs @@ -44,6 +44,17 @@ fn the_guardian_pauses_but_only_governance_unpauses() { assert!(!f.vault.paused()); } +#[test] +fn a_caller_other_than_the_admin_cannot_unpause_even_when_it_signs() { + let f = setup(); + f.vault.pause(&f.guardian); + + for who in [f.guardian.clone(), Address::generate(&f.e)] { + refused(f.vault.try_unpause(&who), VaultError::AdminRequired); + } + assert!(f.vault.paused()); +} + #[test] fn a_paused_vault_still_lets_investors_claim() { let f = setup(); @@ -108,17 +119,17 @@ fn governance_can_replace_the_guardian() { let f = setup(); let next = Address::generate(&f.e); - let grant = f.vault.propose_change( - &VaultChange::GrantRole(symbol_short!("guardian"), next.clone()), - &f.admin, - ); - let revoke = f.vault.propose_change( - &VaultChange::RevokeRole(symbol_short!("guardian"), f.guardian.clone()), - &f.admin, - ); + let grant = f.vault.propose_change(&VaultChange::GrantRole( + symbol_short!("guardian"), + next.clone(), + )); + let revoke = f.vault.propose_change(&VaultChange::RevokeRole( + symbol_short!("guardian"), + f.guardian.clone(), + )); f.advance(MIN_UPGRADE_DELAY); - f.vault.apply_change(&grant, &f.admin); - f.vault.apply_change(&revoke, &f.admin); + f.vault.apply_change(&grant); + f.vault.apply_change(&revoke); assert_eq!(f.vault.guardian(), Some(next.clone())); assert!(f.vault.try_pause(&f.guardian).is_err()); @@ -242,11 +253,9 @@ fn governance_hands_over_in_two_steps_instead() { let f = setup(); let next = Address::generate(&f.e); - let id = f - .vault - .propose_change(&VaultChange::Admin(next.clone()), &f.admin); + let id = f.vault.propose_change(&VaultChange::Admin(next.clone())); f.advance(MIN_UPGRADE_DELAY); - f.vault.apply_change(&id, &f.admin); + f.vault.apply_change(&id); f.vault.accept_admin_transfer(); assert_eq!(f.vault.governance(), Some(next.clone())); diff --git a/contracts/async-vault/src/test/delisted.rs b/contracts/async-vault/src/test/delisted.rs index aeae7cd3..c5a25efd 100644 --- a/contracts/async-vault/src/test/delisted.rs +++ b/contracts/async-vault/src/test/delisted.rs @@ -107,7 +107,7 @@ fn a_conversion_adds_to_a_redemption_already_open_in_the_epoch() { fn in_a_wind_down_the_shares_are_credited_before_the_first_round() { let f = setup(); let (investor, epoch) = delisted_with_a_priced_deposit(&f); - f.vault.propose_wind_down(&f.admin); + f.vault.propose_wind_down(); f.advance(MIN_WIND_DOWN_DELAY); f.vault.activate_wind_down(); @@ -140,7 +140,7 @@ fn in_a_wind_down_the_shares_are_credited_before_the_first_round() { fn in_a_wind_down_the_shares_are_credited_after_the_first_round() { let f = setup(); let (investor, epoch) = delisted_with_a_priced_deposit(&f); - f.vault.propose_wind_down(&f.admin); + f.vault.propose_wind_down(); f.advance(MIN_WIND_DOWN_DELAY); f.vault.activate_wind_down(); assert_eq!(f.vault.finalize_wind_down_round(), 400); @@ -157,7 +157,7 @@ fn a_credit_before_the_first_round_leaves_every_holder_their_share() { let f = setup(); let other = f.holder(200); let (investor, epoch) = delisted_with_a_priced_deposit(&f); - f.vault.propose_wind_down(&f.admin); + f.vault.propose_wind_down(); f.advance(MIN_WIND_DOWN_DELAY); f.vault.activate_wind_down(); f.vault.claim_deposit(&investor, &epoch); @@ -193,7 +193,7 @@ fn a_conversion_caught_by_a_wind_down_is_paid_from_the_rounds() { let f = setup(); let other = f.holder(200); let (investor, epoch) = delisted_with_a_priced_deposit(&f); - f.vault.propose_wind_down(&f.admin); + f.vault.propose_wind_down(); f.vault.claim_deposit(&investor, &epoch); let open = f.vault.current_epoch(); f.advance(MIN_WIND_DOWN_DELAY); @@ -223,7 +223,7 @@ fn a_redemption_surrendered_after_the_first_round_keeps_the_rounds_solvent() { let f = setup(); let other = f.holder(200); let (investor, epoch) = delisted_with_a_priced_deposit(&f); - f.vault.propose_wind_down(&f.admin); + f.vault.propose_wind_down(); f.vault.claim_deposit(&investor, &epoch); let open = f.vault.current_epoch(); f.advance(MIN_WIND_DOWN_DELAY); @@ -244,7 +244,7 @@ fn a_delisted_holders_unpriced_redemption_joins_the_wind_down_when_cancelled() { let other = f.holder(200); let epoch = f.vault.request_redeem(&investor, &200); f.identity.allow(&investor, &false); - f.vault.propose_wind_down(&f.admin); + f.vault.propose_wind_down(); f.advance(MIN_WIND_DOWN_DELAY); f.vault.activate_wind_down(); diff --git a/contracts/async-vault/src/test/deposit_cap.rs b/contracts/async-vault/src/test/deposit_cap.rs index 28ac415e..e0c321d1 100644 --- a/contracts/async-vault/src/test/deposit_cap.rs +++ b/contracts/async-vault/src/test/deposit_cap.rs @@ -1,3 +1,5 @@ +use soroban_sdk::IntoVal; + use super::*; #[test] @@ -15,15 +17,15 @@ fn governance_sets_updates_and_clears_cap() { let f = setup(); // Set cap - f.vault.set_deposit_cap(&Some(1_000), &f.admin); + f.vault.set_deposit_cap(&Some(1_000)); assert_eq!(f.vault.deposit_cap(), Some(1_000)); // Update cap - f.vault.set_deposit_cap(&Some(2_000), &f.admin); + f.vault.set_deposit_cap(&Some(2_000)); assert_eq!(f.vault.deposit_cap(), Some(2_000)); // Clear cap - f.vault.set_deposit_cap(&None, &f.admin); + f.vault.set_deposit_cap(&None); assert_eq!(f.vault.deposit_cap(), None); } @@ -31,7 +33,7 @@ fn governance_sets_updates_and_clears_cap() { fn negative_cap_rejected() { let f = setup(); refused( - f.vault.try_set_deposit_cap(&Some(-1), &f.admin), + f.vault.try_set_deposit_cap(&Some(-1)), VaultError::InvalidAmount, ); } @@ -41,26 +43,17 @@ fn unauthorized_caller_rejected() { let f = setup(); let rando = Address::generate(&f.e); - f.e.set_auths(&[]); - assert!(f.vault.try_set_deposit_cap(&Some(1_000), &rando).is_err()); - assert!(f - .vault - .try_set_deposit_cap(&Some(1_000), &f.manager) - .is_err()); - assert!(f - .vault - .try_set_deposit_cap(&Some(1_000), &f.guardian) - .is_err()); - assert!(f - .vault - .try_set_deposit_cap(&Some(1_000), &f.treasury) - .is_err()); + for who in [&rando, &f.manager, &f.guardian, &f.treasury] { + f.sign_only_as(who, "set_deposit_cap", (Some(1_000_i128),).into_val(&f.e)); + assert!(f.vault.try_set_deposit_cap(&Some(1_000)).is_err()); + } + assert_eq!(f.vault.deposit_cap(), None); } #[test] fn boundary_value_enforcement() { let f = setup(); - f.vault.set_deposit_cap(&Some(1_000), &f.admin); + f.vault.set_deposit_cap(&Some(1_000)); let investor1 = f.investor(2_000); let investor2 = f.investor(2_000); @@ -85,7 +78,7 @@ fn boundary_value_enforcement() { #[test] fn cancellation_frees_capacity() { let f = setup(); - f.vault.set_deposit_cap(&Some(1_000), &f.admin); + f.vault.set_deposit_cap(&Some(1_000)); let investor1 = f.investor(1_000); let investor2 = f.investor(1_000); @@ -110,7 +103,7 @@ fn cancellation_frees_capacity() { #[test] fn deployed_capital_and_multi_epoch_accounting() { let f = setup(); - f.vault.set_deposit_cap(&Some(1_000), &f.admin); + f.vault.set_deposit_cap(&Some(1_000)); let investor1 = f.investor(1_000); let investor2 = f.investor(1_000); @@ -132,7 +125,7 @@ fn deployed_capital_and_multi_epoch_accounting() { ); // Governance raises cap to 1_500 - f.vault.set_deposit_cap(&Some(1_500), &f.admin); + f.vault.set_deposit_cap(&Some(1_500)); // Now investor2 can deposit 500 let epoch2 = f.vault.request_deposit(&investor2, &500); @@ -142,7 +135,7 @@ fn deployed_capital_and_multi_epoch_accounting() { #[test] fn redemptions_free_capacity() { let f = setup(); - f.vault.set_deposit_cap(&Some(1_000), &f.admin); + f.vault.set_deposit_cap(&Some(1_000)); let investor1 = f.investor(1_000); let investor2 = f.investor(1_000); @@ -177,7 +170,7 @@ fn redemptions_free_capacity() { #[test] fn zero_cap_blocks_all_deposits() { let f = setup(); - f.vault.set_deposit_cap(&Some(0), &f.admin); + f.vault.set_deposit_cap(&Some(0)); let investor = f.investor(100); refused( diff --git a/contracts/async-vault/src/test/governance.rs b/contracts/async-vault/src/test/governance.rs index d3713d75..f90bd1d0 100644 --- a/contracts/async-vault/src/test/governance.rs +++ b/contracts/async-vault/src/test/governance.rs @@ -1,7 +1,4 @@ -use soroban_sdk::{ - testutils::{Events as _, MockAuth, MockAuthInvoke}, - Event as _, IntoVal, -}; +use soroban_sdk::{testutils::Events as _, Event as _, IntoVal}; use stellar_access::role_transfer::RoleTransferError; use super::*; @@ -9,9 +6,9 @@ use crate::{ChangeApplied, CustodianSet, ADMIN_ACCEPT_LEDGERS}; /// Proposes a change, waits one delay and applies it. fn land(f: &Fixture, change: VaultChange) { - let id = f.vault.propose_change(&change, &f.admin); + let id = f.vault.propose_change(&change); f.advance(f.vault.upgrade_delay()); - f.vault.apply_change(&id, &f.admin); + f.vault.apply_change(&id); } #[test] @@ -19,21 +16,15 @@ fn the_custodian_changes_through_the_timelock() { let f = setup(); let next = Address::generate(&f.e); - refused( - f.vault.try_set_custodian(&next, &f.admin), - VaultError::UseTimelock, - ); + refused(f.vault.try_set_custodian(&next), VaultError::UseTimelock); let id = f .vault - .propose_change(&VaultChange::Custodian(next.clone()), &f.admin); + .propose_change(&VaultChange::Custodian(next.clone())); f.advance(MIN_UPGRADE_DELAY - 1); - refused( - f.vault.try_apply_change(&id, &f.admin), - VaultError::ChangeNotReady, - ); + refused(f.vault.try_apply_change(&id), VaultError::ChangeNotReady); f.advance(1); - f.vault.apply_change(&id, &f.admin); + f.vault.apply_change(&id); assert_eq!(f.vault.custodian(), Some(next)); } @@ -43,10 +34,10 @@ fn applying_a_custodian_change_publishes_both_events() { let f = setup(); let next = Address::generate(&f.e); let change = VaultChange::Custodian(next.clone()); - let id = f.vault.propose_change(&change, &f.admin); + let id = f.vault.propose_change(&change); f.advance(MIN_UPGRADE_DELAY); - f.vault.apply_change(&id, &f.admin); + f.vault.apply_change(&id); assert_eq!( f.e.events().all().filter_by_contract(&f.vault.address), @@ -61,13 +52,10 @@ fn applying_a_custodian_change_publishes_both_events() { fn raising_the_notice_is_instant_and_lowering_waits() { let f = setup(); - f.vault.set_notice(&DAY_DURATION, &f.admin); + f.vault.set_notice(&DAY_DURATION); assert_eq!(f.vault.notice(), DAY_DURATION); - refused( - f.vault.try_set_notice(&3_600, &f.admin), - VaultError::UseTimelock, - ); + refused(f.vault.try_set_notice(&3_600), VaultError::UseTimelock); land(&f, VaultChange::Notice(3_600)); assert_eq!(f.vault.notice(), 3_600); @@ -79,7 +67,7 @@ fn a_notice_change_still_respects_its_bounds() { refused( f.vault - .try_propose_change(&VaultChange::Notice(MIN_UPGRADE_DELAY + 1), &f.admin), + .try_propose_change(&VaultChange::Notice(MIN_UPGRADE_DELAY + 1)), VaultError::NoticeAboveUpgradeDelay, ); } @@ -90,17 +78,17 @@ fn a_notice_change_checks_the_upgrade_delay_again_when_it_applies() { land(&f, VaultChange::UpgradeDelay(30 * DAY_DURATION)); let notice = f .vault - .propose_change(&VaultChange::Notice(20 * DAY_DURATION), &f.admin); + .propose_change(&VaultChange::Notice(20 * DAY_DURATION)); let delay = f .vault - .propose_change(&VaultChange::UpgradeDelay(MIN_UPGRADE_DELAY), &f.admin); + .propose_change(&VaultChange::UpgradeDelay(MIN_UPGRADE_DELAY)); f.advance(30 * DAY_DURATION); // The delay falls below the notice the first change would set. - f.vault.apply_change(&delay, &f.admin); + f.vault.apply_change(&delay); refused( - f.vault.try_apply_change(¬ice, &f.admin), + f.vault.try_apply_change(¬ice), VaultError::NoticeAboveUpgradeDelay, ); assert_eq!(f.vault.notice(), 0); @@ -110,11 +98,9 @@ fn a_notice_change_checks_the_upgrade_delay_again_when_it_applies() { fn raising_the_wind_down_delay_is_instant_and_lowering_waits() { let f = setup(); - f.vault - .set_wind_down_delay(&(2 * MIN_WIND_DOWN_DELAY), &f.admin); + f.vault.set_wind_down_delay(&(2 * MIN_WIND_DOWN_DELAY)); refused( - f.vault - .try_set_wind_down_delay(&MIN_WIND_DOWN_DELAY, &f.admin), + f.vault.try_set_wind_down_delay(&MIN_WIND_DOWN_DELAY), VaultError::UseTimelock, ); land(&f, VaultChange::WindDownDelay(MIN_WIND_DOWN_DELAY)); @@ -127,10 +113,8 @@ fn a_wind_down_delay_below_the_minimum_cannot_be_proposed() { let f = setup(); refused( - f.vault.try_propose_change( - &VaultChange::WindDownDelay(MIN_WIND_DOWN_DELAY - 1), - &f.admin, - ), + f.vault + .try_propose_change(&VaultChange::WindDownDelay(MIN_WIND_DOWN_DELAY - 1)), VaultError::WindDownDelayTooShort, ); } @@ -236,31 +220,12 @@ fn an_admin_hand_over_waits_but_cancelling_one_does_not() { fn an_applied_admin_change_starts_a_hand_over_the_next_admin_accepts() { let f = setup(); let next = Address::generate(&f.e); - let id = f - .vault - .propose_change(&VaultChange::Admin(next.clone()), &f.admin); + let id = f.vault.propose_change(&VaultChange::Admin(next.clone())); f.advance(MIN_UPGRADE_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.vault.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.vault.apply_change(&id, &f.admin); + // One admin signature covers the call; the hand-over inside asks for none. + f.sign_only_as(&f.admin, "apply_change", (id,).into_val(&f.e)); + f.vault.apply_change(&id); f.e.mock_all_auths(); assert_eq!(f.vault.governance(), Some(f.admin.clone())); @@ -302,21 +267,19 @@ fn the_hand_over_offer_lasts_seven_days_of_ledgers() { fn a_change_shorter_than_a_raised_notice_never_lands() { let f = setup(); let next = Address::generate(&f.e); - let custodian = f - .vault - .propose_change(&VaultChange::Custodian(next), &f.admin); + let custodian = f.vault.propose_change(&VaultChange::Custodian(next)); let delay = f .vault - .propose_change(&VaultChange::UpgradeDelay(30 * DAY_DURATION), &f.admin); + .propose_change(&VaultChange::UpgradeDelay(30 * DAY_DURATION)); f.advance(MIN_UPGRADE_DELAY); - f.vault.apply_change(&delay, &f.admin); + f.vault.apply_change(&delay); // The notice rises past the seven days the custodian change was given. - f.vault.set_notice(&(20 * DAY_DURATION), &f.admin); + f.vault.set_notice(&(20 * DAY_DURATION)); f.advance(30 * DAY_DURATION); refused( - f.vault.try_apply_change(&custodian, &f.admin), + f.vault.try_apply_change(&custodian), VaultError::UpgradeDelayBelowNotice, ); assert_eq!(f.vault.custodian(), Some(f.custodian.clone())); @@ -326,11 +289,11 @@ fn a_change_shorter_than_a_raised_notice_never_lands() { fn the_deposit_cap_stays_instant_both_ways() { let f = setup(); - f.vault.set_deposit_cap(&Some(1_000), &f.admin); + f.vault.set_deposit_cap(&Some(1_000)); assert_eq!(f.vault.deposit_cap(), Some(1_000)); - f.vault.set_deposit_cap(&Some(500), &f.admin); + f.vault.set_deposit_cap(&Some(500)); assert_eq!(f.vault.deposit_cap(), Some(500)); - f.vault.set_deposit_cap(&None, &f.admin); + f.vault.set_deposit_cap(&None); assert_eq!(f.vault.deposit_cap(), None); } @@ -338,18 +301,18 @@ fn the_deposit_cap_stays_instant_both_ways() { fn a_pending_role_change_lands_after_a_pause() { let f = setup(); let next = Address::generate(&f.e); - let id = f.vault.propose_change( - &VaultChange::GrantRole(symbol_short!("guardian"), next.clone()), - &f.admin, - ); + let id = f.vault.propose_change(&VaultChange::GrantRole( + symbol_short!("guardian"), + next.clone(), + )); f.vault.pause(&f.guardian); f.advance(MIN_UPGRADE_DELAY); - assert!(f.vault.try_apply_change(&id, &f.admin).is_err()); + assert!(f.vault.try_apply_change(&id).is_err()); f.vault.unpause(&f.admin); f.advance(MIN_UPGRADE_DELAY); - f.vault.apply_change(&id, &f.admin); + f.vault.apply_change(&id); assert!(f .vault .has_role(&next, &symbol_short!("guardian")) @@ -359,23 +322,23 @@ fn a_pending_role_change_lands_after_a_pause() { #[test] fn nothing_timelocked_moves_during_a_wind_down_a_manager_revoke_included() { let f = setup(); - let revoke = f.vault.propose_change( - &VaultChange::RevokeRole(symbol_short!("manager"), f.manager.clone()), - &f.admin, - ); - f.vault.propose_wind_down(&f.admin); + let revoke = f.vault.propose_change(&VaultChange::RevokeRole( + symbol_short!("manager"), + f.manager.clone(), + )); + f.vault.propose_wind_down(); f.advance(MIN_WIND_DOWN_DELAY); f.vault.activate_wind_down(); refused( - f.vault.try_apply_change(&revoke, &f.admin), + f.vault.try_apply_change(&revoke), VaultError::WindDownActive, ); refused( - f.vault.try_propose_change( - &VaultChange::RevokeRole(symbol_short!("manager"), f.manager.clone()), - &f.admin, - ), + f.vault.try_propose_change(&VaultChange::RevokeRole( + symbol_short!("manager"), + f.manager.clone(), + )), VaultError::WindDownActive, ); refused( diff --git a/contracts/async-vault/src/test/guards.rs b/contracts/async-vault/src/test/guards.rs index 9cfabafa..6cd429b4 100644 --- a/contracts/async-vault/src/test/guards.rs +++ b/contracts/async-vault/src/test/guards.rs @@ -63,7 +63,7 @@ fn not_winding_down_refuses_once_the_wind_down_is_active() { let f = setup(); holds(&f, || guards::not_winding_down(&f.e)); - f.vault.propose_wind_down(&f.admin); + f.vault.propose_wind_down(); holds(&f, || guards::not_winding_down(&f.e)); f.advance(MIN_WIND_DOWN_DELAY); @@ -80,7 +80,7 @@ fn winding_down_refuses_until_the_wind_down_is_active() { guards::winding_down(&f.e) }); - f.vault.propose_wind_down(&f.admin); + f.vault.propose_wind_down(); refuses(&f, VaultError::WindDownNotActive, || { guards::winding_down(&f.e) }); @@ -183,6 +183,19 @@ fn no_hand_over_pending_refuses_while_an_offer_is_open() { holds(&f, || guards::no_hand_over_pending(&f.e)); } +#[test] +fn caller_is_admin_refuses_anyone_but_the_admin() { + let f = setup(); + let stranger = Address::generate(&f.e); + holds(&f, || guards::caller_is_admin(&f.e, &f.admin)); + + for who in [&f.guardian, &f.manager, &stranger] { + refuses(&f, VaultError::AdminRequired, || { + guards::caller_is_admin(&f.e, who) + }); + } +} + #[test] fn wiring_complete_refuses_a_miswired_vault() { let wired = setup_unfinished(); @@ -324,7 +337,7 @@ fn not_a_second_code_change_refuses_a_code_change_while_one_waits() { holds(&f, || guards::not_a_second_code_change(&f.e, &upgrade)); f.vault - .propose_change(&VaultChange::UpgradeDelay(MIN_UPGRADE_DELAY), &f.admin); + .propose_change(&VaultChange::UpgradeDelay(MIN_UPGRADE_DELAY)); refuses(&f, VaultError::UpgradeProposalExists, || { guards::not_a_second_code_change(&f.e, &upgrade) }); @@ -341,7 +354,7 @@ fn no_wind_down_proposed_refuses_a_standing_proposal() { let f = setup(); holds(&f, || guards::no_wind_down_proposed(&f.e)); - f.vault.propose_wind_down(&f.admin); + f.vault.propose_wind_down(); refuses(&f, VaultError::WindDownAlreadyProposed, || { guards::no_wind_down_proposed(&f.e) }); @@ -354,7 +367,7 @@ fn wind_down_proposed_refuses_without_a_standing_proposal() { guards::wind_down_proposed(&f.e) }); - f.vault.propose_wind_down(&f.admin); + f.vault.propose_wind_down(); holds(&f, || guards::wind_down_proposed(&f.e)); f.advance(MIN_WIND_DOWN_DELAY); @@ -436,7 +449,7 @@ fn within_cap_refuses_a_deposit_past_the_cap() { let f = setup(); holds(&f, || guards::within_cap(&f.e, i128::MAX)); - f.vault.set_deposit_cap(&Some(1_000), &f.admin); + f.vault.set_deposit_cap(&Some(1_000)); let investor = f.investor(400); f.vault.request_deposit(&investor, &400); holds(&f, || guards::within_cap(&f.e, 600)); @@ -647,7 +660,7 @@ fn fulfilable_refuses_an_epoch_the_vault_cannot_price_yet() { guards::fulfilable(&f.e, &behind_the_report) }); - f.vault.set_notice(&3_600, &f.admin); + f.vault.set_notice(&3_600); let within_the_notice = epoch(&f, f.close_epoch()); refuses(&f, VaultError::NoticeNotElapsed, || { guards::fulfilable(&f.e, &within_the_notice) diff --git a/contracts/async-vault/src/test/health.rs b/contracts/async-vault/src/test/health.rs index 829c4f53..afcf2589 100644 --- a/contracts/async-vault/src/test/health.rs +++ b/contracts/async-vault/src/test/health.rs @@ -134,7 +134,7 @@ fn a_notice_longer_than_a_report_lives_is_reported() { f.attest(wad(1)); let freshness = f.oracle.config().freshness_duration; - f.vault.set_notice(&(freshness + 1), &f.admin); + f.vault.set_notice(&(freshness + 1)); assert!(!f.vault.health().freshness_covers_notice); } diff --git a/contracts/async-vault/src/test/mod.rs b/contracts/async-vault/src/test/mod.rs index 4024a4ff..cb451efa 100644 --- a/contracts/async-vault/src/test/mod.rs +++ b/contracts/async-vault/src/test/mod.rs @@ -21,6 +21,7 @@ mod redeem; mod settlement; mod setup; mod share_returns; +mod signatures; mod supply; mod timing; mod treasury; @@ -98,6 +99,30 @@ impl Fixture<'_> { }]); } + /// The last call asked `who` alone to sign, once, on exactly this call. + fn signed_once_by( + &self, + who: &Address, + fn_name: &str, + args: soroban_sdk::Vec, + ) { + use soroban_sdk::testutils::{AuthorizedFunction, AuthorizedInvocation}; + assert_eq!( + self.e.auths(), + std::vec![( + who.clone(), + AuthorizedInvocation { + function: AuthorizedFunction::Contract(( + self.vault.address.clone(), + soroban_sdk::Symbol::new(&self.e, fn_name), + args, + )), + sub_invocations: std::vec![], + }, + )] + ); + } + fn investor(&self, funded: i128) -> Address { let who = self.unverified_investor(funded); self.identity.allow(&who, &true); @@ -415,8 +440,8 @@ fn build<'a>( } let vault = AsyncVaultClient::new(&e, &contract_id); if finish { - vault.set_custodian(&custodian, &admin); - vault.finish_setup(&admin); + vault.set_custodian(&custodian); + vault.finish_setup(); } Fixture { diff --git a/contracts/async-vault/src/test/notice.rs b/contracts/async-vault/src/test/notice.rs index 4f33a0aa..893450e1 100644 --- a/contracts/async-vault/src/test/notice.rs +++ b/contracts/async-vault/src/test/notice.rs @@ -11,7 +11,7 @@ fn a_fresh_vault_has_no_notice() { fn governance_sets_the_notice() { let f = setup(); - f.vault.set_notice(&3_600, &f.admin); + f.vault.set_notice(&3_600); assert_eq!(f.vault.notice(), 3_600); } @@ -21,11 +21,11 @@ fn setting_the_notice_needs_governance_authorisation() { let f = setup(); f.e.set_auths(&[]); - assert!(f.vault.try_set_notice(&3_600, &f.admin).is_err()); + assert!(f.vault.try_set_notice(&3_600).is_err()); assert_eq!(f.vault.notice(), 0); f.e.mock_all_auths(); - f.vault.set_notice(&3_600, &f.admin); + f.vault.set_notice(&3_600); assert_eq!(f.vault.notice(), 3_600); } @@ -41,7 +41,7 @@ fn an_open_epoch_has_no_timestamps() { #[test] fn closing_records_the_moment_and_the_wait() { let f = setup(); - f.vault.set_notice(&3_600, &f.admin); + f.vault.set_notice(&3_600); let closed_at = f.e.ledger().timestamp(); let id = f.close_epoch(); @@ -54,11 +54,11 @@ fn closing_records_the_moment_and_the_wait() { #[test] fn a_later_notice_does_not_move_an_epoch_that_closed() { let f = setup(); - f.vault.set_notice(&3_600, &f.admin); + f.vault.set_notice(&3_600); let id = f.close_epoch(); let before = f.vault.get_epoch(&id).unwrap().priceable_at; - f.vault.set_notice(&DAY_DURATION, &f.admin); + f.vault.set_notice(&DAY_DURATION); assert_eq!(f.vault.get_epoch(&id).unwrap().priceable_at, before); } @@ -67,7 +67,7 @@ fn a_later_notice_does_not_move_an_epoch_that_closed() { fn an_epoch_cannot_be_priced_inside_its_notice() { let f = setup(); let holder = f.holder(100); - f.vault.set_notice(&3_600, &f.admin); + f.vault.set_notice(&3_600); f.vault.request_redeem(&holder, &100); let epoch = f.close_epoch(); f.attest(wad(2)); @@ -82,7 +82,7 @@ fn an_epoch_cannot_be_priced_inside_its_notice() { fn it_is_priced_once_the_notice_elapses() { let f = setup(); let holder = f.holder(100); - f.vault.set_notice(&3_600, &f.admin); + f.vault.set_notice(&3_600); f.vault.request_redeem(&holder, &100); let epoch = f.close_epoch(); @@ -110,7 +110,7 @@ fn a_notice_of_zero_prices_as_soon_as_the_valuation_lands() { fn the_notice_is_reported_before_the_valuation() { let f = setup(); let holder = f.holder(100); - f.vault.set_notice(&3_600, &f.admin); + f.vault.set_notice(&3_600); f.vault.request_redeem(&holder, &100); f.advance(60); let epoch = f.close_epoch(); @@ -127,7 +127,7 @@ fn the_notice_is_reported_before_the_valuation() { fn the_notice_does_not_reopen_the_cancellation_window() { let f = setup(); let investor = f.investor(1_000); - f.vault.set_notice(&3_600, &f.admin); + f.vault.set_notice(&3_600); f.vault.request_deposit(&investor, &1_000); let epoch = f.close_epoch(); f.attest(wad(2)); @@ -149,7 +149,7 @@ fn a_notice_beyond_the_maximum_is_refused() { let f = setup(); refused( - f.vault.try_set_notice(&(MAX_NOTICE_SECS + 1), &f.admin), + f.vault.try_set_notice(&(MAX_NOTICE_SECS + 1)), VaultError::NoticeTooLong, ); assert_eq!(f.vault.notice(), 0); @@ -160,7 +160,7 @@ fn a_notice_above_the_upgrade_delay_is_refused() { let f = setup(); refused( - f.vault.try_set_notice(&(MIN_UPGRADE_DELAY + 1), &f.admin), + f.vault.try_set_notice(&(MIN_UPGRADE_DELAY + 1)), VaultError::NoticeAboveUpgradeDelay, ); assert_eq!(f.vault.notice(), 0); @@ -173,11 +173,11 @@ fn a_notice_at_the_maximum_is_accepted() { // The delay must be raised first, since notice cannot outgrow the upgrade delay. let id = f .vault - .propose_change(&VaultChange::UpgradeDelay(MAX_NOTICE_SECS), &f.admin); + .propose_change(&VaultChange::UpgradeDelay(MAX_NOTICE_SECS)); f.advance(MIN_UPGRADE_DELAY); - f.vault.apply_change(&id, &f.admin); + f.vault.apply_change(&id); - f.vault.set_notice(&MAX_NOTICE_SECS, &f.admin); + f.vault.set_notice(&MAX_NOTICE_SECS); assert_eq!(f.vault.notice(), MAX_NOTICE_SECS); } diff --git a/contracts/async-vault/src/test/oracle_swap.rs b/contracts/async-vault/src/test/oracle_swap.rs index de8fc869..2748ea0a 100644 --- a/contracts/async-vault/src/test/oracle_swap.rs +++ b/contracts/async-vault/src/test/oracle_swap.rs @@ -71,16 +71,13 @@ fn the_oracle_changes_only_through_the_timelock() { let id = f .vault - .propose_change(&VaultChange::Oracle(next.address.clone()), &f.admin); + .propose_change(&VaultChange::Oracle(next.address.clone())); f.advance(MIN_UPGRADE_DELAY - 1); - refused( - f.vault.try_apply_change(&id, &f.admin), - VaultError::ChangeNotReady, - ); + refused(f.vault.try_apply_change(&id), VaultError::ChangeNotReady); assert_eq!(f.vault.oracle(), f.oracle.address); f.advance(1); - f.vault.apply_change(&id, &f.admin); + f.vault.apply_change(&id); assert_eq!(f.vault.oracle(), next.address); } @@ -90,9 +87,9 @@ fn the_next_epoch_prices_from_the_new_feed() { let next = second_feed(&f); let id = f .vault - .propose_change(&VaultChange::Oracle(next.address.clone()), &f.admin); + .propose_change(&VaultChange::Oracle(next.address.clone())); f.advance(MIN_UPGRADE_DELAY); - f.vault.apply_change(&id, &f.admin); + f.vault.apply_change(&id); let investor = f.investor(1_000); f.vault.request_deposit(&investor, &400); let epoch = f.close_epoch(); @@ -114,9 +111,9 @@ fn a_sealed_epoch_is_priced_by_the_feed_standing_at_fulfilment() { let id = f .vault - .propose_change(&VaultChange::Oracle(next.address.clone()), &f.admin); + .propose_change(&VaultChange::Oracle(next.address.clone())); f.advance(MIN_UPGRADE_DELAY); - f.vault.apply_change(&id, &f.admin); + f.vault.apply_change(&id); attest_on(&f, &next, wad(4)); f.vault.fulfill_epoch(&epoch); @@ -139,8 +136,7 @@ fn a_feed_outside_this_vault_is_never_proposed() { elsewhere.address, ] { refused( - f.vault - .try_propose_change(&VaultChange::Oracle(feed), &f.admin), + f.vault.try_propose_change(&VaultChange::Oracle(feed)), VaultError::FeedNotBound, ); } @@ -154,14 +150,11 @@ fn a_feed_that_stops_naming_this_vault_is_never_installed() { stub.report(&Some(f.vault.address.clone())); let id = f .vault - .propose_change(&VaultChange::Oracle(stub.address.clone()), &f.admin); + .propose_change(&VaultChange::Oracle(stub.address.clone())); stub.report(&Some(Address::generate(&f.e))); f.advance(MIN_UPGRADE_DELAY); - refused( - f.vault.try_apply_change(&id, &f.admin), - VaultError::FeedNotBound, - ); + refused(f.vault.try_apply_change(&id), VaultError::FeedNotBound); assert_eq!(f.vault.oracle(), f.oracle.address); } diff --git a/contracts/async-vault/src/test/setup.rs b/contracts/async-vault/src/test/setup.rs index 67433620..2221ab8d 100644 --- a/contracts/async-vault/src/test/setup.rs +++ b/contracts/async-vault/src/test/setup.rs @@ -1,4 +1,4 @@ -use soroban_sdk::{contract, contractimpl, testutils::Events as _, vec, Event as _, IntoVal, Vec}; +use soroban_sdk::{contract, contractimpl, testutils::Events as _, vec, Event as _, Vec}; use stellar_access::role_transfer::RoleTransferError; use super::*; @@ -20,7 +20,7 @@ fn a_deposit_before_setup_finishes_is_refused() { fn setup_finishes_once_the_wiring_is_complete() { let f = setup_unfinished(); - f.vault.finish_setup(&f.admin); + f.vault.finish_setup(); assert!(!f.vault.setup_open()); let investor = f.investor(1_000); @@ -31,7 +31,7 @@ fn setup_finishes_once_the_wiring_is_complete() { fn finishing_setup_publishes_it() { let f = setup_unfinished(); - f.vault.finish_setup(&f.admin); + f.vault.finish_setup(); assert_eq!( f.e.events().all().filter_by_contract(&f.vault.address), @@ -43,7 +43,7 @@ fn finishing_setup_publishes_it() { fn setup_never_finishes_twice() { let f = setup(); - refused(f.vault.try_finish_setup(&f.admin), VaultError::SetupClosed); + refused(f.vault.try_finish_setup(), VaultError::SetupClosed); } #[test] @@ -68,7 +68,7 @@ fn every_wiring_step_is_checked_before_setup_finishes() { let f = miswired(miswire); assert_eq!( - f.vault.try_finish_setup(&f.admin), + f.vault.try_finish_setup(), Err(Ok(VaultError::SetupIncomplete.into())), "{miswire:?}" ); @@ -173,7 +173,7 @@ fn a_token_that_cannot_answer_fails_its_checks_instead_of_trapping() { assert!(!health.vault_unfrozen); assert!(!health.compliance_bound); - refused(vault.try_finish_setup(&admin), VaultError::SetupIncomplete); + refused(vault.try_finish_setup(), VaultError::SetupIncomplete); } #[test] @@ -183,13 +183,10 @@ fn setup_cannot_finish_while_a_hand_over_is_pending() { f.vault .transfer_admin_role(&next, &(f.e.ledger().sequence() + 100)); - refused( - f.vault.try_finish_setup(&f.admin), - VaultError::AdminTransferPending, - ); + refused(f.vault.try_finish_setup(), VaultError::AdminTransferPending); f.vault.transfer_admin_role(&next, &0); - f.vault.finish_setup(&f.admin); + f.vault.finish_setup(); assert!(f.vault.try_accept_admin_transfer().is_err()); assert_eq!(f.vault.governance(), Some(f.admin.clone())); } @@ -201,7 +198,7 @@ fn an_expired_hand_over_does_not_hold_setup_open() { .transfer_admin_role(&Address::generate(&f.e), &(f.e.ledger().sequence() + 10)); f.e.ledger().with_mut(|l| l.sequence_number += 11); - f.vault.finish_setup(&f.admin); + f.vault.finish_setup(); assert!(!f.vault.setup_open()); assert_eq!( @@ -217,8 +214,8 @@ fn only_governance_finishes_setup() { let stranger = Address::generate(&f.e); for who in [&stranger, &f.manager] { - f.sign_only_as(who, "finish_setup", (who.clone(),).into_val(&f.e)); - assert!(f.vault.try_finish_setup(who).is_err()); + f.sign_only_as(who, "finish_setup", Vec::new(&f.e)); + assert!(f.vault.try_finish_setup().is_err()); } assert!(f.vault.setup_open()); } @@ -228,12 +225,11 @@ fn admin_changes_are_instant_during_setup() { let f = setup_unfinished(); let next = Address::generate(&f.e); - f.vault.set_custodian(&f.custodian, &f.admin); - f.vault.set_notice(&3_600, &f.admin); - f.vault.set_notice(&0, &f.admin); - f.vault - .set_wind_down_delay(&(2 * MIN_WIND_DOWN_DELAY), &f.admin); - f.vault.set_wind_down_delay(&MIN_WIND_DOWN_DELAY, &f.admin); + f.vault.set_custodian(&f.custodian); + f.vault.set_notice(&3_600); + f.vault.set_notice(&0); + f.vault.set_wind_down_delay(&(2 * MIN_WIND_DOWN_DELAY)); + f.vault.set_wind_down_delay(&MIN_WIND_DOWN_DELAY); f.vault .grant_role(&next, &symbol_short!("guardian"), &f.admin); f.vault @@ -255,14 +251,12 @@ fn nothing_is_proposed_during_setup() { refused( f.vault - .try_propose_change(&VaultChange::Custodian(f.custodian.clone()), &f.admin), + .try_propose_change(&VaultChange::Custodian(f.custodian.clone())), VaultError::SetupOpen, ); refused( - f.vault.try_propose_change( - &VaultChange::WindDownDelay(MIN_WIND_DOWN_DELAY - 1), - &f.admin, - ), + f.vault + .try_propose_change(&VaultChange::WindDownDelay(MIN_WIND_DOWN_DELAY - 1)), VaultError::SetupOpen, ); assert_eq!(f.vault.pending_changes().len(), 0); diff --git a/contracts/async-vault/src/test/signatures.rs b/contracts/async-vault/src/test/signatures.rs new file mode 100644 index 00000000..88835d0f --- /dev/null +++ b/contracts/async-vault/src/test/signatures.rs @@ -0,0 +1,110 @@ +//! What each privileged call asks to sign: one signature per account it needs. + +use soroban_sdk::{IntoVal, Vec}; + +use super::*; + +#[test] +fn each_setup_call_asks_the_admin_for_one_signature() { + let f = setup_unfinished(); + let holder = Address::generate(&f.e); + let treasury = symbol_short!("treasury"); + let delay = 2 * MIN_WIND_DOWN_DELAY; + let live_until = f.e.ledger().sequence() + 100; + + f.vault.set_custodian(&f.custodian); + f.signed_once_by( + &f.admin, + "set_custodian", + (f.custodian.clone(),).into_val(&f.e), + ); + f.vault.set_notice(&3_600); + f.signed_once_by(&f.admin, "set_notice", (3_600_u64,).into_val(&f.e)); + f.vault.set_wind_down_delay(&delay); + f.signed_once_by(&f.admin, "set_wind_down_delay", (delay,).into_val(&f.e)); + f.vault.set_deposit_cap(&Some(1_000)); + f.signed_once_by( + &f.admin, + "set_deposit_cap", + (Some(1_000_i128),).into_val(&f.e), + ); + f.vault.grant_role(&holder, &treasury, &f.admin); + f.signed_once_by( + &f.admin, + "grant_role", + (holder.clone(), treasury.clone(), f.admin.clone()).into_val(&f.e), + ); + f.vault.revoke_role(&holder, &treasury, &f.admin); + f.signed_once_by( + &f.admin, + "revoke_role", + (holder.clone(), treasury, f.admin.clone()).into_val(&f.e), + ); + f.vault.transfer_admin_role(&holder, &live_until); + f.signed_once_by( + &f.admin, + "transfer_admin_role", + (holder.clone(), live_until).into_val(&f.e), + ); + f.vault.transfer_admin_role(&holder, &0); + f.signed_once_by( + &f.admin, + "transfer_admin_role", + (holder, 0_u32).into_val(&f.e), + ); + f.vault.finish_setup(); + f.signed_once_by(&f.admin, "finish_setup", Vec::new(&f.e)); +} + +#[test] +fn each_admin_call_after_setup_asks_the_admin_for_one_signature() { + let f = setup(); + let next = Address::generate(&f.e); + + let change = VaultChange::Custodian(next.clone()); + let id = f.vault.propose_change(&change); + f.signed_once_by(&f.admin, "propose_change", (change,).into_val(&f.e)); + f.vault.cancel_change(&id); + f.signed_once_by(&f.admin, "cancel_change", (id,).into_val(&f.e)); + + let id = f.vault.propose_change(&VaultChange::Admin(next.clone())); + f.advance(MIN_UPGRADE_DELAY); + f.vault.apply_change(&id); + f.signed_once_by(&f.admin, "apply_change", (id,).into_val(&f.e)); + + f.vault.pause(&f.guardian); + f.vault.unpause(&f.admin); + f.signed_once_by(&f.admin, "unpause", (f.admin.clone(),).into_val(&f.e)); + + f.vault.propose_wind_down(); + f.signed_once_by(&f.admin, "propose_wind_down", Vec::new(&f.e)); + f.vault.cancel_wind_down_proposal(); + f.signed_once_by(&f.admin, "cancel_wind_down_proposal", Vec::new(&f.e)); + + f.vault.accept_admin_transfer(); + f.signed_once_by(&next, "accept_admin_transfer", Vec::new(&f.e)); +} + +#[test] +fn each_role_call_asks_its_holder_for_one_signature() { + let f = setup(); + let investor = f.investor(1_000); + f.vault.request_deposit(&investor, &400); + + let epoch = f.vault.close_epoch(&f.manager); + f.signed_once_by( + &f.manager, + "close_epoch", + (f.manager.clone(),).into_val(&f.e), + ); + f.fulfill_epoch_at(epoch, wad(2)); + let free = f.vault.free_reserve(); + f.vault.deploy_to_custodian(&f.treasury, &free); + f.signed_once_by( + &f.treasury, + "deploy_to_custodian", + (f.treasury.clone(), free).into_val(&f.e), + ); + f.vault.pause(&f.guardian); + f.signed_once_by(&f.guardian, "pause", (f.guardian.clone(),).into_val(&f.e)); +} diff --git a/contracts/async-vault/src/test/treasury.rs b/contracts/async-vault/src/test/treasury.rs index a3823b2d..c81af58a 100644 --- a/contracts/async-vault/src/test/treasury.rs +++ b/contracts/async-vault/src/test/treasury.rs @@ -1,3 +1,5 @@ +use soroban_sdk::IntoVal; + use super::*; #[test] @@ -115,11 +117,13 @@ fn setting_the_custodian_needs_the_admin_signature() { let f = setup_unfinished(); f.e.set_auths(&[]); - assert!(f.vault.try_set_custodian(&f.custodian, &f.admin).is_err()); - assert!(f - .vault - .try_set_custodian(&f.custodian, &f.treasury) - .is_err()); + assert!(f.vault.try_set_custodian(&f.custodian).is_err()); + f.sign_only_as( + &f.treasury, + "set_custodian", + (f.custodian.clone(),).into_val(&f.e), + ); + assert!(f.vault.try_set_custodian(&f.custodian).is_err()); assert_eq!(f.vault.custodian(), None); } diff --git a/contracts/async-vault/src/test/upgrade.rs b/contracts/async-vault/src/test/upgrade.rs index b9e4033b..5f3afeda 100644 --- a/contracts/async-vault/src/test/upgrade.rs +++ b/contracts/async-vault/src/test/upgrade.rs @@ -1,4 +1,4 @@ -use soroban_sdk::{testutils::Events as _, Event as _}; +use soroban_sdk::{testutils::Events as _, Event as _, IntoVal}; use stellar_contract_utils::pausable::PausableError; use super::*; @@ -9,14 +9,14 @@ fn wasm(f: &Fixture, byte: u8) -> BytesN<32> { } fn propose(f: &Fixture, change: VaultChange) -> u32 { - f.vault.propose_change(&change, &f.admin) + f.vault.propose_change(&change) } /// Raises the upgrade delay through the timelock, which costs one current delay. fn raise_delay(f: &Fixture, secs: u64) { let id = propose(f, VaultChange::UpgradeDelay(secs)); f.advance(f.vault.upgrade_delay()); - f.vault.apply_change(&id, &f.admin); + f.vault.apply_change(&id); } fn only_pending(f: &Fixture) -> VaultPendingChange { @@ -52,12 +52,12 @@ fn a_second_code_change_while_one_stands_is_refused() { refused( f.vault - .try_propose_change(&VaultChange::Upgrade(wasm(&f, 9)), &f.admin), + .try_propose_change(&VaultChange::Upgrade(wasm(&f, 9))), VaultError::UpgradeProposalExists, ); refused( f.vault - .try_propose_change(&VaultChange::UpgradeDelay(30 * DAY_DURATION), &f.admin), + .try_propose_change(&VaultChange::UpgradeDelay(30 * DAY_DURATION)), VaultError::UpgradeProposalExists, ); } @@ -68,7 +68,7 @@ fn a_cancelled_proposal_leaves_the_slot_free() { let hash = wasm(&f, 7); let id = propose(&f, VaultChange::Upgrade(hash.clone())); - f.vault.cancel_change(&id, &f.admin); + f.vault.cancel_change(&id); assert_eq!(f.vault.pending_changes().len(), 0); propose(&f, VaultChange::Upgrade(hash)); @@ -79,10 +79,7 @@ fn a_cancelled_proposal_leaves_the_slot_free() { fn cancelling_nothing_is_refused() { let f = setup(); - refused( - f.vault.try_cancel_change(&1, &f.admin), - VaultError::ChangeNotFound, - ); + refused(f.vault.try_cancel_change(&1), VaultError::ChangeNotFound); } #[test] @@ -91,7 +88,7 @@ fn a_delay_below_the_minimum_is_refused() { refused( f.vault - .try_propose_change(&VaultChange::UpgradeDelay(MIN_UPGRADE_DELAY - 1), &f.admin), + .try_propose_change(&VaultChange::UpgradeDelay(MIN_UPGRADE_DELAY - 1)), VaultError::UpgradeDelayTooShort, ); assert_eq!(f.vault.pending_changes().len(), 0); @@ -103,7 +100,7 @@ fn a_delay_above_the_maximum_is_refused() { refused( f.vault - .try_propose_change(&VaultChange::UpgradeDelay(MAX_UPGRADE_DELAY + 1), &f.admin), + .try_propose_change(&VaultChange::UpgradeDelay(MAX_UPGRADE_DELAY + 1)), VaultError::UpgradeDelayTooLong, ); assert_eq!(f.vault.pending_changes().len(), 0); @@ -114,12 +111,12 @@ fn a_delay_below_the_standing_notice_is_refused() { let f = setup(); raise_delay(&f, 30 * DAY_DURATION); - f.vault.set_notice(&(20 * DAY_DURATION), &f.admin); + f.vault.set_notice(&(20 * DAY_DURATION)); // Above the minimum, below the notice: an exit could not complete. refused( f.vault - .try_propose_change(&VaultChange::UpgradeDelay(10 * DAY_DURATION), &f.admin), + .try_propose_change(&VaultChange::UpgradeDelay(10 * DAY_DURATION)), VaultError::UpgradeDelayBelowNotice, ); @@ -133,20 +130,24 @@ fn only_governance_proposes_and_cancels() { let change = VaultChange::Upgrade(wasm(&f, 7)); let stranger = Address::generate(&f.e); - f.e.set_auths(&[]); - assert!(f.vault.try_propose_change(&change, &stranger).is_err()); - assert!(f.vault.try_propose_change(&change, &f.manager).is_err()); - assert!(f.vault.try_propose_change(&change, &f.guardian).is_err()); - assert!(f - .vault - .try_propose_change(&VaultChange::UpgradeDelay(30 * DAY_DURATION), &f.treasury) - .is_err()); + for who in [&stranger, &f.manager, &f.guardian] { + f.sign_only_as(who, "propose_change", (change.clone(),).into_val(&f.e)); + assert!(f.vault.try_propose_change(&change).is_err()); + } + let delay = VaultChange::UpgradeDelay(30 * DAY_DURATION); + f.sign_only_as( + &f.treasury, + "propose_change", + (delay.clone(),).into_val(&f.e), + ); + assert!(f.vault.try_propose_change(&delay).is_err()); f.e.mock_all_auths(); let id = propose(&f, change); - f.e.set_auths(&[]); - assert!(f.vault.try_cancel_change(&id, &stranger).is_err()); - assert!(f.vault.try_cancel_change(&id, &f.guardian).is_err()); + for who in [&stranger, &f.guardian] { + f.sign_only_as(who, "cancel_change", (id,).into_val(&f.e)); + assert!(f.vault.try_cancel_change(&id).is_err()); + } } #[test] @@ -161,19 +162,13 @@ fn applying_before_the_eta_is_refused() { let f = setup(); let id = propose(&f, VaultChange::UpgradeDelay(30 * DAY_DURATION)); - refused( - f.vault.try_apply_change(&id, &f.admin), - VaultError::ChangeNotReady, - ); + refused(f.vault.try_apply_change(&id), VaultError::ChangeNotReady); f.advance(MIN_UPGRADE_DELAY - 1); - refused( - f.vault.try_apply_change(&id, &f.admin), - VaultError::ChangeNotReady, - ); + refused(f.vault.try_apply_change(&id), VaultError::ChangeNotReady); f.advance(1); - f.vault.apply_change(&id, &f.admin); + f.vault.apply_change(&id); assert_eq!(f.vault.upgrade_delay(), 30 * DAY_DURATION); assert_eq!(f.vault.pending_changes().len(), 0); } @@ -199,13 +194,10 @@ fn shortening_the_delay_cannot_be_rushed() { // Back down to the minimum: still costs the full thirty days. let id = propose(&f, VaultChange::UpgradeDelay(MIN_UPGRADE_DELAY)); f.advance(30 * DAY_DURATION - 1); - refused( - f.vault.try_apply_change(&id, &f.admin), - VaultError::ChangeNotReady, - ); + refused(f.vault.try_apply_change(&id), VaultError::ChangeNotReady); f.advance(1); - f.vault.apply_change(&id, &f.admin); + f.vault.apply_change(&id); assert_eq!(f.vault.upgrade_delay(), MIN_UPGRADE_DELAY); } @@ -215,7 +207,7 @@ fn the_notice_cannot_outgrow_the_delay() { propose(&f, VaultChange::UpgradeDelay(10 * DAY_DURATION)); refused( - f.vault.try_set_notice(&(20 * DAY_DURATION), &f.admin), + f.vault.try_set_notice(&(20 * DAY_DURATION)), VaultError::NoticeAboveUpgradeDelay, ); assert_eq!(f.vault.notice(), 0); @@ -225,10 +217,7 @@ fn the_notice_cannot_outgrow_the_delay() { fn applying_nothing_is_refused() { let f = setup(); - refused( - f.vault.try_apply_change(&1, &f.admin), - VaultError::ChangeNotFound, - ); + refused(f.vault.try_apply_change(&1), VaultError::ChangeNotFound); } #[test] @@ -241,7 +230,7 @@ fn a_pause_freezes_the_upgrade_timelock_clock() { f.vault.pause(&f.guardian); f.advance(10 * DAY_DURATION); - assert!(f.vault.try_apply_change(&id, &f.admin).is_err()); + assert!(f.vault.try_apply_change(&id).is_err()); // Unpause: the eta has moved by the ten days spent paused. f.vault.unpause(&f.admin); @@ -249,13 +238,10 @@ fn a_pause_freezes_the_upgrade_timelock_clock() { // Six unpaused days out of seven: still refused. f.advance(3 * DAY_DURATION); - refused( - f.vault.try_apply_change(&id, &f.admin), - VaultError::ChangeNotReady, - ); + refused(f.vault.try_apply_change(&id), VaultError::ChangeNotReady); f.advance(DAY_DURATION); - f.vault.apply_change(&id, &f.admin); + f.vault.apply_change(&id); assert_eq!(f.vault.pending_changes().len(), 0); assert_eq!(f.vault.upgrade_delay(), 14 * DAY_DURATION); } @@ -269,13 +255,13 @@ fn applying_while_paused_is_refused() { // At the eta, with no paused time yet, only the pause stands in the way. f.vault.pause(&f.guardian); assert_eq!( - f.vault.try_apply_change(&id, &f.admin), + f.vault.try_apply_change(&id), Err(Ok(PausableError::EnforcedPause.into())) ); assert_eq!(f.vault.upgrade_delay(), MIN_UPGRADE_DELAY); f.vault.unpause(&f.admin); - f.vault.apply_change(&id, &f.admin); + f.vault.apply_change(&id); assert_eq!(f.vault.upgrade_delay(), 30 * DAY_DURATION); } @@ -286,10 +272,10 @@ fn only_governance_applies() { let id = propose(&f, VaultChange::UpgradeDelay(30 * DAY_DURATION)); f.advance(MIN_UPGRADE_DELAY); - f.e.set_auths(&[]); - assert!(f.vault.try_apply_change(&id, &stranger).is_err()); - assert!(f.vault.try_apply_change(&id, &f.manager).is_err()); - assert!(f.vault.try_apply_change(&id, &f.guardian).is_err()); + for who in [&stranger, &f.manager, &f.guardian] { + f.sign_only_as(who, "apply_change", (id,).into_val(&f.e)); + assert!(f.vault.try_apply_change(&id).is_err()); + } assert_eq!(f.vault.pending_changes().len(), 1); } @@ -324,13 +310,13 @@ fn the_vault_exposes_no_immediate_upgrade() { #[test] fn nothing_is_proposed_during_a_wind_down() { let f = setup(); - f.vault.propose_wind_down(&f.admin); + f.vault.propose_wind_down(); f.advance(MIN_WIND_DOWN_DELAY); f.vault.activate_wind_down(); refused( f.vault - .try_propose_change(&VaultChange::Upgrade(wasm(&f, 7)), &f.admin), + .try_propose_change(&VaultChange::Upgrade(wasm(&f, 7))), VaultError::WindDownActive, ); } @@ -339,16 +325,13 @@ fn nothing_is_proposed_during_a_wind_down() { fn a_change_standing_when_the_wind_down_starts_never_applies() { let f = setup(); let id = propose(&f, VaultChange::Upgrade(wasm(&f, 7))); - f.vault.propose_wind_down(&f.admin); + f.vault.propose_wind_down(); f.advance(MIN_WIND_DOWN_DELAY); f.vault.activate_wind_down(); f.advance(MAX_UPGRADE_DELAY); - refused( - f.vault.try_apply_change(&id, &f.admin), - VaultError::WindDownActive, - ); - f.vault.cancel_change(&id, &f.admin); + refused(f.vault.try_apply_change(&id), VaultError::WindDownActive); + f.vault.cancel_change(&id); assert_eq!(f.vault.pending_changes().len(), 0); } @@ -359,11 +342,11 @@ fn applying_a_delay_change_checks_the_notice_again() { let id = propose(&f, VaultChange::UpgradeDelay(MIN_UPGRADE_DELAY)); // The notice rises while the shorter delay waits. - f.vault.set_notice(&(20 * DAY_DURATION), &f.admin); + f.vault.set_notice(&(20 * DAY_DURATION)); f.advance(30 * DAY_DURATION); refused( - f.vault.try_apply_change(&id, &f.admin), + f.vault.try_apply_change(&id), VaultError::UpgradeDelayBelowNotice, ); assert_eq!(f.vault.upgrade_delay(), 30 * DAY_DURATION); @@ -387,7 +370,7 @@ fn proposing_and_applying_publish_the_change() { ); f.advance(MIN_UPGRADE_DELAY); - f.vault.apply_change(&id, &f.admin); + f.vault.apply_change(&id); assert_eq!( f.e.events().all().filter_by_contract(&f.vault.address), [ChangeApplied { id, change }.to_xdr(&f.e, &f.vault.address)] @@ -399,7 +382,7 @@ fn cancelling_publishes_the_change_id() { let f = setup(); let id = propose(&f, VaultChange::Upgrade(wasm(&f, 7))); - f.vault.cancel_change(&id, &f.admin); + f.vault.cancel_change(&id); assert_eq!( f.e.events().all().filter_by_contract(&f.vault.address), @@ -552,11 +535,9 @@ mod with_wasm { let hash = f.e.deployer().upload_contract_wasm(WASM); assert_eq!(f.vault.schema_version(), 1); - let id = f - .vault - .propose_change(&VaultChange::Upgrade(hash), &f.admin); + let id = f.vault.propose_change(&VaultChange::Upgrade(hash)); f.advance(MIN_UPGRADE_DELAY); - f.vault.apply_change(&id, &f.admin); + f.vault.apply_change(&id); let target = Client::new(&f.e, &f.vault.address); assert_eq!(target.schema_version(), 1); @@ -569,11 +550,9 @@ mod with_wasm { fn a_migration_refuses_a_second_run() { let f = setup(); let hash = f.e.deployer().upload_contract_wasm(WASM); - let id = f - .vault - .propose_change(&VaultChange::Upgrade(hash), &f.admin); + let id = f.vault.propose_change(&VaultChange::Upgrade(hash)); f.advance(MIN_UPGRADE_DELAY); - f.vault.apply_change(&id, &f.admin); + f.vault.apply_change(&id); let target = Client::new(&f.e, &f.vault.address); target.migrate(&f.admin); diff --git a/contracts/async-vault/src/test/wind_down.rs b/contracts/async-vault/src/test/wind_down.rs index d8a28c77..fee93d15 100644 --- a/contracts/async-vault/src/test/wind_down.rs +++ b/contracts/async-vault/src/test/wind_down.rs @@ -9,7 +9,7 @@ fn governance_sets_the_delay_within_the_maximum() { assert_eq!(f.vault.wind_down_delay(), MIN_WIND_DOWN_DELAY); - f.vault.set_wind_down_delay(&(14 * DAY_DURATION), &f.admin); + f.vault.set_wind_down_delay(&(14 * DAY_DURATION)); assert_eq!(f.vault.wind_down_delay(), 14 * DAY_DURATION); } @@ -19,7 +19,7 @@ fn the_delay_cannot_exceed_the_maximum() { assert!(f .vault - .try_set_wind_down_delay(&(MAX_WIND_DOWN_DELAY + 1), &f.admin) + .try_set_wind_down_delay(&(MAX_WIND_DOWN_DELAY + 1)) .is_err()); assert_eq!(f.vault.wind_down_delay(), MIN_WIND_DOWN_DELAY); } @@ -31,12 +31,8 @@ fn only_governance_sets_the_delay() { let longer = 2 * MIN_WIND_DOWN_DELAY; for who in [&stranger, &f.manager, &f.treasury, &f.guardian] { - f.sign_only_as( - who, - "set_wind_down_delay", - (longer, who.clone()).into_val(&f.e), - ); - assert!(f.vault.try_set_wind_down_delay(&longer, who).is_err()); + f.sign_only_as(who, "set_wind_down_delay", (longer,).into_val(&f.e)); + assert!(f.vault.try_set_wind_down_delay(&longer).is_err()); } assert_eq!(f.vault.wind_down_delay(), MIN_WIND_DOWN_DELAY); } @@ -54,9 +50,9 @@ const WEEK: u64 = 7 * DAY_DURATION; fn governance_proposes_and_anyone_activates_after_the_delay() { let f = setup(); let stranger = Address::generate(&f.e); - f.vault.set_wind_down_delay(&WEEK, &f.admin); + f.vault.set_wind_down_delay(&WEEK); - f.vault.propose_wind_down(&f.admin); + f.vault.propose_wind_down(); let proposed = f.vault.wind_down().unwrap(); assert_eq!(proposed.status, WindDownStatus::Proposed); assert_eq!(proposed.round, 0); @@ -73,8 +69,8 @@ fn governance_proposes_and_anyone_activates_after_the_delay() { #[test] fn activation_is_open_to_a_stranger() { let f = setup(); - f.vault.set_wind_down_delay(&WEEK, &f.admin); - f.vault.propose_wind_down(&f.admin); + f.vault.set_wind_down_delay(&WEEK); + f.vault.propose_wind_down(); f.advance(WEEK); f.e.set_auths(&[]); @@ -88,22 +84,22 @@ fn only_governance_proposes() { let f = setup(); let stranger = Address::generate(&f.e); - f.e.set_auths(&[]); - assert!(f.vault.try_propose_wind_down(&stranger).is_err()); - assert!(f.vault.try_propose_wind_down(&f.manager).is_err()); - assert!(f.vault.try_propose_wind_down(&f.guardian).is_err()); + for who in [&stranger, &f.manager, &f.guardian] { + f.sign_only_as(who, "propose_wind_down", soroban_sdk::Vec::new(&f.e)); + assert!(f.vault.try_propose_wind_down().is_err()); + } assert_eq!(f.vault.wind_down(), None); } #[test] fn a_second_proposal_cannot_push_the_date_back() { let f = setup(); - f.vault.set_wind_down_delay(&WEEK, &f.admin); - f.vault.propose_wind_down(&f.admin); + f.vault.set_wind_down_delay(&WEEK); + f.vault.propose_wind_down(); let first = f.vault.wind_down().unwrap().active_at; f.advance(WEEK / 2); - assert!(f.vault.try_propose_wind_down(&f.admin).is_err()); + assert!(f.vault.try_propose_wind_down().is_err()); assert_eq!(f.vault.wind_down().unwrap().active_at, first); } @@ -111,20 +107,24 @@ fn a_second_proposal_cannot_push_the_date_back() { #[test] fn governance_cancels_a_proposal_before_activation() { let f = setup(); - f.vault.set_wind_down_delay(&WEEK, &f.admin); - f.vault.propose_wind_down(&f.admin); + f.vault.set_wind_down_delay(&WEEK); + f.vault.propose_wind_down(); let stranger = Address::generate(&f.e); - f.e.set_auths(&[]); - assert!(f.vault.try_cancel_wind_down_proposal(&stranger).is_err()); - assert!(f.vault.try_cancel_wind_down_proposal(&f.manager).is_err()); - assert!(f.vault.try_cancel_wind_down_proposal(&f.guardian).is_err()); + for who in [&stranger, &f.manager, &f.guardian] { + f.sign_only_as( + who, + "cancel_wind_down_proposal", + soroban_sdk::Vec::new(&f.e), + ); + assert!(f.vault.try_cancel_wind_down_proposal().is_err()); + } f.e.mock_all_auths(); - f.vault.cancel_wind_down_proposal(&f.admin); + f.vault.cancel_wind_down_proposal(); assert_eq!(f.vault.wind_down(), None); - f.vault.propose_wind_down(&f.admin); + f.vault.propose_wind_down(); assert_eq!( f.vault.wind_down().unwrap().status, WindDownStatus::Proposed @@ -134,12 +134,12 @@ fn governance_cancels_a_proposal_before_activation() { #[test] fn an_active_wind_down_cannot_be_cancelled() { let f = setup(); - f.vault.set_wind_down_delay(&WEEK, &f.admin); - f.vault.propose_wind_down(&f.admin); + f.vault.set_wind_down_delay(&WEEK); + f.vault.propose_wind_down(); f.advance(WEEK); f.vault.activate_wind_down(); - assert!(f.vault.try_cancel_wind_down_proposal(&f.admin).is_err()); + assert!(f.vault.try_cancel_wind_down_proposal().is_err()); assert_eq!(f.vault.wind_down().unwrap().status, WindDownStatus::Active); } @@ -154,7 +154,7 @@ fn activating_without_a_proposal_is_refused() { fn an_unset_delay_still_waits_the_minimum() { let f = setup(); - f.vault.propose_wind_down(&f.admin); + f.vault.propose_wind_down(); f.advance(MIN_WIND_DOWN_DELAY - 1); refused( f.vault.try_activate_wind_down(), @@ -170,8 +170,7 @@ fn an_unset_delay_still_waits_the_minimum() { fn a_delay_below_the_minimum_is_refused() { for f in [setup_unfinished(), setup()] { refused( - f.vault - .try_set_wind_down_delay(&(MIN_WIND_DOWN_DELAY - 1), &f.admin), + f.vault.try_set_wind_down_delay(&(MIN_WIND_DOWN_DELAY - 1)), VaultError::WindDownDelayTooShort, ); } @@ -179,7 +178,7 @@ fn a_delay_below_the_minimum_is_refused() { /// Proposes, waits and activates. Returns nothing; the vault is active after. pub(super) fn wind_down_now(f: &Fixture) { - f.vault.propose_wind_down(&f.admin); + f.vault.propose_wind_down(); f.advance(MIN_WIND_DOWN_DELAY); f.vault.activate_wind_down(); } @@ -334,7 +333,7 @@ fn a_round_with_nothing_to_distribute_is_refused() { fn a_round_before_activation_is_refused() { let f = setup(); let _user = f.holder(500); - f.vault.propose_wind_down(&f.admin); + f.vault.propose_wind_down(); assert!(f.vault.try_finalize_wind_down_round().is_err()); } @@ -688,11 +687,7 @@ fn propose_wind_down_reports_active_when_active() { let _user = f.holder(500); wind_down_now(&f); - let err = f - .vault - .try_propose_wind_down(&f.admin) - .unwrap_err() - .unwrap(); + let err = f.vault.try_propose_wind_down().unwrap_err().unwrap(); assert_eq!(err, VaultError::WindDownActive.into()); } @@ -705,7 +700,7 @@ fn a_later_round_needs_the_units_an_earlier_round_left_in_owed() { let a = f.holder(1); let b = f.holder(1); f.empty_reserve(); - f.vault.propose_wind_down(&f.admin); + f.vault.propose_wind_down(); f.advance(MIN_WIND_DOWN_DELAY); f.vault.activate_wind_down(); @@ -729,7 +724,7 @@ fn after_every_holder_claims_owed_keeps_at_most_one_unit_per_holder() { let f = setup(); let holders = [f.holder(1), f.holder(2), f.holder(4)]; f.empty_reserve(); - f.vault.propose_wind_down(&f.admin); + f.vault.propose_wind_down(); f.advance(MIN_WIND_DOWN_DELAY); f.vault.activate_wind_down(); f.vault.fund(&f.custodian, &5); @@ -761,7 +756,7 @@ proptest! { let total_shares: i128 = holdings.iter().sum(); let backer = f.investor(total_shares + 10_000); f.empty_reserve(); - f.vault.propose_wind_down(&f.admin); + f.vault.propose_wind_down(); f.advance(MIN_WIND_DOWN_DELAY); f.vault.activate_wind_down(); diff --git a/scripts/harness/deploy.ts b/scripts/harness/deploy.ts index a479f9e6..6f3da744 100644 --- a/scripts/harness/deploy.ts +++ b/scripts/harness/deploy.ts @@ -230,7 +230,6 @@ async function finishVaultSetup( contracts: Record, ): Promise { const governance = accounts.governance - const caller = governance.publicKey() const { Client: AsyncVault } = (await import( clientEntry("async_vault") )) as typeof AsyncVaultModule @@ -240,10 +239,9 @@ async function finishVaultSetup( await ( await vault.set_custodian({ custodian: accounts.custodian.publicKey(), - caller, }) ).signAndSend() - await (await vault.finish_setup({ caller })).signAndSend() + await (await vault.finish_setup()).signAndSend() if ((await vault.setup_open()).result) throw new Error("the vault's setup did not finish") console.log("vault setup finished") diff --git a/scripts/harness/guards.test.ts b/scripts/harness/guards.test.ts index 2f35365c..3cc0639f 100644 --- a/scripts/harness/guards.test.ts +++ b/scripts/harness/guards.test.ts @@ -89,7 +89,6 @@ describe("an account with no role", () => { send( vault(c, outsider).propose_change({ change: { tag: "Custodian", values: [who] }, - caller: who, }), ), ).rejects.toThrow(/signatures/) @@ -124,9 +123,8 @@ describe("an account with no role", () => { it("cannot unpause the vault", async () => { await send(vault(c, guardian).pause({ caller: guardian.publicKey() })) - await expect( - send(vault(c, outsider).unpause({ caller: who })), - ).rejects.toThrow(/signatures/) + const v = vault(c, outsider) + expectRefused(await v.unpause({ caller: who }), v, "AdminRequired") await send(vault(c, governance).unpause({ caller: governance.publicKey() })) }) @@ -322,14 +320,11 @@ describe("the compliance contract after setup", () => { }) describe("the vault after setup", () => { - const caller = () => governance.publicKey() - it("governance cannot change the custodian at once", async () => { const v = vault(c, governance) expectRefused( await v.set_custodian({ custodian: outsider.publicKey(), - caller: caller(), }), v, "UseTimelock", @@ -342,15 +337,10 @@ describe("the vault after setup", () => { const id = await send( v.propose_change({ change: { tag: "Custodian", values: [outsider.publicKey()] }, - caller: caller(), }), ) - expectRefused( - await v.apply_change({ id, caller: caller() }), - v, - "ChangeNotReady", - ) - await send(v.cancel_change({ id, caller: caller() })) + expectRefused(await v.apply_change({ id }), v, "ChangeNotReady") + await send(v.cancel_change({ id })) expect((await v.pending_changes()).result).toEqual([]) }) }) @@ -738,7 +728,6 @@ describe("the notice period and valuation age rules", () => { await send( vault(c, governance).set_notice({ secs: noticeSecs, - caller: governance.publicKey(), }), ) }) @@ -813,11 +802,7 @@ describe("the notice period and valuation age rules", () => { it("governance cannot lower the notice at once", async () => { const v = vault(c, governance) - expectRefused( - await v.set_notice({ secs: 0n, caller: governance.publicKey() }), - v, - "UseTimelock", - ) + expectRefused(await v.set_notice({ secs: 0n }), v, "UseTimelock") expect((await reader.notice()).result).toBe(noticeSecs) }) }) diff --git a/scripts/harness/wind-down.test.ts b/scripts/harness/wind-down.test.ts index 3d47383d..0b354cd1 100644 --- a/scripts/harness/wind-down.test.ts +++ b/scripts/harness/wind-down.test.ts @@ -121,10 +121,9 @@ describe("a wind-down on a throwaway vault", () => { }) it("governance proposes the wind-down and anyone activates it", async () => { - const gov = c.keys.governance.publicKey() - expect( - await call("propose_wind_down", "governance", { caller: gov }, true), - ).toBe("accepted") + expect(await call("propose_wind_down", "governance", {}, true)).toBe( + "accepted", + ) expect(await call("activate_wind_down", "outsider", {}, true)).toBe( "accepted", ) @@ -218,45 +217,30 @@ describe("a wind-down on a throwaway vault", () => { // Simulated, never sent: each write still has to encode, reach the // contract and get the answer an active wind-down gives it. const writes: Array<[string, Signer, Record, string]> = [ - ["set_deposit_cap", "governance", { cap: 1n, caller: gov }, "accepted"], + ["set_deposit_cap", "governance", { cap: 1n }, "accepted"], [ "set_custodian", "governance", - { custodian: someone, caller: gov }, + { custodian: someone }, "refused: UseTimelock", ], - [ - "set_wind_down_delay", - "governance", - { secs: 1n, caller: gov }, - "accepted", - ], + ["set_wind_down_delay", "governance", { secs: 1n }, "accepted"], [ "cancel_wind_down_proposal", "governance", - { caller: gov }, + {}, "refused: WindDownActive", ], - ["set_notice", "governance", { secs: 1n, caller: gov }, "accepted"], - ["finish_setup", "governance", { caller: gov }, "refused: SetupClosed"], + ["set_notice", "governance", { secs: 1n }, "accepted"], + ["finish_setup", "governance", {}, "refused: SetupClosed"], [ "propose_change", "governance", - { change: { tag: "Custodian", values: [someone] }, caller: gov }, + { change: { tag: "Custodian", values: [someone] } }, "refused: WindDownActive", ], - [ - "cancel_change", - "governance", - { id: 1, caller: gov }, - "refused: ChangeNotFound", - ], - [ - "apply_change", - "governance", - { id: 1, caller: gov }, - "refused: ChangeNotFound", - ], + ["cancel_change", "governance", { id: 1 }, "refused: ChangeNotFound"], + ["apply_change", "governance", { id: 1 }, "refused: ChangeNotFound"], [ "deploy_to_custodian", "treasury", From 0e815cc8a9fa26eacd6b06d9bb7c87f483fe7a70 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:56:18 -0300 Subject: [PATCH 086/147] docs: one signature per admin call --- docs/AUTHORIZATION_MATRIX.md | 87 +++++++++++++++++++----------------- docs/TESTING.md | 10 +++-- docs/kit-spec.md | 16 +++---- 3 files changed, 61 insertions(+), 52 deletions(-) diff --git a/docs/AUTHORIZATION_MATRIX.md b/docs/AUTHORIZATION_MATRIX.md index 8b97446f..1542c51e 100644 --- a/docs/AUTHORIZATION_MATRIX.md +++ b/docs/AUTHORIZATION_MATRIX.md @@ -7,7 +7,11 @@ This document provides the complete authorization and role model specification f ## 1. Architectural Principles 1. **Direct Role Membership Checks**: Role membership is checked directly via `has_role(e, &caller, &role)` or `stellar_macros::only_role` / `only_admin`, never by enumerating members. -2. **Strict Caller Authentication**: No entrypoint takes a caller argument that it does not authenticate. Every function accepting a `caller: Address` executes `caller.require_auth()`. The testnet compliance stub is the exception: its admin calls check the stored admin's signature and ignore their `caller` or `operator` argument (§5.5). +2. **One signature per account**: A privileged call asks each account it needs for exactly one signature, on that call. No production entrypoint takes a caller argument it does not check. + - An admin entrypoint the kit defines takes no caller argument. `#[only_admin]` reads the stored admin and asks it to sign. + - Where an OpenZeppelin trait fixes a `caller` or `operator` argument (`pause`, `unpause`, `set_compliance`, `set_identity_verifier`), the entrypoint refuses any account other than the stored admin with `AdminRequired`, then asks that account to sign. + - A role entrypoint checks the role of the `caller` or `operator` it names, then asks that account to sign. `legal_transfer` and `recover_balance` also ask the admin, a different account, to sign the same call. + - The testnet stubs are the exception. The compliance contract's `bind_token`, `unbind_token`, `add_module_to` and `remove_module_from`, and the identity verifier's `set_claim_topics_and_issuers`, keep the `operator` argument their trait fixes but do not read it. `#[only_admin]` asks the stored admin to sign, so each still asks for one signature. Production replaces both stubs with external contracts (§5.4, §5.5). 3. **Exit guarantee, with one hold**: A covered redemption claim pays out cash regardless of pause, staleness or allowlist status. A delisted investor exits through the cash path and cannot re-enter share circulation. A frozen investor is held: the vault refuses their requests, cancellations and claims, and pays them only after the freeze lifts. 4. **Five Separate Authorities**: - **Governance (Admin)**: Root authority; upgrades, timelock parameters, custodian settings, unpause, oracle parameters, emergency NAV pause reset. As the share token's admin, it co-signs compliance's `legal_transfer` and `recover_balance`. @@ -36,10 +40,10 @@ In `scripts/harness/deploy.ts` and automated deployment scripts, the separation - The share token's constructor grants `compliance` to the compliance key and grants `manager` to no one; the identity verifier is constructed with the compliance key as admin. - 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. That no other account holds it is checked by the vault's `finish_setup` below. -- 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). -- It calls `compliance.bind_token({ token, operator: governance })`, then `compliance.bind_vault({ vault, caller: governance })` and `compliance.finish_setup({ caller: governance })`, and stops if `compliance.setup_open()` still reads true. From then on the compliance contract's module, token-binding and admin changes wait for the vault's timelock (§5.5). -- 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). -- Last, it calls `vault.set_custodian({ custodian, caller: governance })` and `vault.finish_setup({ caller: governance })`, and stops if `vault.setup_open()` still reads true. `finish_setup` refuses with `SetupIncomplete` unless the token and the oracle report this vault with their setup closed, the vault is the token's only `manager`, the token has no shares outstanding, the vault passes the identity check, its address is not frozen on the token and the token is bound to its compliance contract. A compliance contract that answers `vault()` and `setup_open()` must report this vault with its setup closed; one that does not answer them is an external module and is accepted. From then on the vault takes deposits and its admin changes wait for its timelock (§5.1). +- It then calls `token.bind_vault({ vault })` and `token.finish_setup()`, each signed by 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). +- It calls `compliance.bind_token({ token, operator: governance })`, then `compliance.bind_vault({ vault })` and `compliance.finish_setup()`, and stops if `compliance.setup_open()` still reads true. From then on the compliance contract's module, token-binding and admin changes wait for the vault's timelock (§5.5). +- It calls `oracle.bind_vault({ vault })` and `oracle.finish_setup()`, 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). +- Last, it calls `vault.set_custodian({ custodian })` and `vault.finish_setup()`, and stops if `vault.setup_open()` still reads true. `finish_setup` refuses with `SetupIncomplete` unless the token and the oracle report this vault with their setup closed, the vault is the token's only `manager`, the token has no shares outstanding, the vault passes the identity check, its address is not frozen on the token and the token is bound to its compliance contract. A compliance contract that answers `vault()` and `setup_open()` must report this vault with its setup closed; one that does not answer them is an external module and is accepted. From then on the vault takes deposits and its admin changes wait for its timelock (§5.1). --- @@ -79,28 +83,28 @@ The following entrypoints are open to any caller on purpose: The vault's `admin` is governance. Until `finish_setup`, admin changes are instant and the vault takes no deposit (`SetupOpen`). Setup finishes once the wiring is complete and no admin hand-over offered during setup is pending, and it never reopens. After setup, the following wait for the timelock through `propose_change`/`apply_change`: the custodian, grants, revoking `manager`, admin hand-over, lowering the notice or the wind-down delay, the oracle, and upgrades. Raising the notice or the wind-down delay, the deposit cap, `pause`/`unpause` and revoking `treasury` or `guardian` stay instant. During an active wind-down nothing timelocked is proposed or applied, a `manager` revoke included. -Tests: [`controls.rs`](../contracts/async-vault/src/test/controls.rs), [`epochs.rs`](../contracts/async-vault/src/test/epochs.rs), [`governance.rs`](../contracts/async-vault/src/test/governance.rs), [`notice.rs`](../contracts/async-vault/src/test/notice.rs), [`setup.rs`](../contracts/async-vault/src/test/setup.rs), [`treasury.rs`](../contracts/async-vault/src/test/treasury.rs), [`upgrade.rs`](../contracts/async-vault/src/test/upgrade.rs) and [`wind_down.rs`](../contracts/async-vault/src/test/wind_down.rs) in [`contracts/async-vault/src/test/`](../contracts/async-vault/src/test/). +Tests: [`controls.rs`](../contracts/async-vault/src/test/controls.rs), [`epochs.rs`](../contracts/async-vault/src/test/epochs.rs), [`governance.rs`](../contracts/async-vault/src/test/governance.rs), [`notice.rs`](../contracts/async-vault/src/test/notice.rs), [`setup.rs`](../contracts/async-vault/src/test/setup.rs), [`treasury.rs`](../contracts/async-vault/src/test/treasury.rs), [`upgrade.rs`](../contracts/async-vault/src/test/upgrade.rs) and [`wind_down.rs`](../contracts/async-vault/src/test/wind_down.rs) in [`contracts/async-vault/src/test/`](../contracts/async-vault/src/test/). [`signatures.rs`](../contracts/async-vault/src/test/signatures.rs) checks that each privileged call below asks one account for one signature, on that call only. | Entrypoint | Access Control / Caller Auth | State & Precondition Guards | Tested Refusal (Test Name) | |---|---|---|---| | `close_epoch` | `#[only_role(caller, "manager")]` | Epoch must be open | `test::epochs::a_non_manager_cannot_close` | | `deploy_to_custodian` | `#[only_role(caller, "treasury")]` | `assets <= free_reserve`, not winding down | `test::treasury::deploying_needs_the_treasury_role` | | `pause` | `#[only_role(caller, "guardian")]` | Contract not already paused | `test::controls::only_the_guardian_pauses` | -| `unpause` | `#[only_admin]`, `caller.require_auth()` | Contract must be paused | `test::controls::the_guardian_pauses_but_only_governance_unpauses` | -| `set_custodian` | `#[only_admin]`, `caller.require_auth()` | Instant during setup; `UseTimelock` after | `test::governance::the_custodian_changes_through_the_timelock`, `test::treasury::setting_the_custodian_needs_the_admin_signature` | -| `set_notice` | `#[only_admin]`, `caller.require_auth()` | `secs <= MAX_NOTICE_SECS`, `secs <= upgrade_delay`. Raising is instant; lowering is `UseTimelock` after setup | `test::governance::raising_the_notice_is_instant_and_lowering_waits`, `test::notice::setting_the_notice_needs_governance_authorisation` | -| `set_wind_down_delay` | `#[only_admin]`, `caller.require_auth()` | `MIN_WIND_DOWN_DELAY <= secs <= MAX_WIND_DOWN_DELAY`. Raising is instant; lowering is `UseTimelock` after setup | `test::governance::raising_the_wind_down_delay_is_instant_and_lowering_waits`, `test::governance::a_wind_down_delay_below_the_minimum_cannot_be_proposed`, `test::wind_down::only_governance_sets_the_delay` | -| `set_deposit_cap` | `#[only_admin]`, `caller.require_auth()` | Not negative. Instant both ways, also after setup | `test::governance::the_deposit_cap_stays_instant_both_ways` | -| `finish_setup` | `#[only_admin]`, `caller.require_auth()` | Once. The token and the oracle report this vault with their setup closed, the vault is the token's only `manager`, the token's `total_supply` is zero, the vault passes `verify_identity`, the token's `is_frozen` is false for the vault, and the token is in its compliance contract's `linked_tokens`; a compliance contract that answers `vault()` and `setup_open()` reports this vault with its setup closed (one that does not answer them is an external module and is accepted); no admin hand-over offered during setup is pending | `test::setup::every_wiring_step_is_checked_before_setup_finishes`, `test::setup::a_compliance_contract_from_outside_the_kit_is_accepted`, `test::setup::setup_cannot_finish_while_a_hand_over_is_pending`, `test::setup::setup_never_finishes_twice`, `test::setup::only_governance_finishes_setup` | +| `unpause` | `caller` must be the stored admin (`AdminRequired`), then `caller.require_auth()` | Contract must be paused | `test::controls::the_guardian_pauses_but_only_governance_unpauses`, `test::controls::a_caller_other_than_the_admin_cannot_unpause_even_when_it_signs` | +| `set_custodian` | `#[only_admin]` | Instant during setup; `UseTimelock` after | `test::governance::the_custodian_changes_through_the_timelock`, `test::treasury::setting_the_custodian_needs_the_admin_signature` | +| `set_notice` | `#[only_admin]` | `secs <= MAX_NOTICE_SECS`, `secs <= upgrade_delay`. Raising is instant; lowering is `UseTimelock` after setup | `test::governance::raising_the_notice_is_instant_and_lowering_waits`, `test::notice::setting_the_notice_needs_governance_authorisation` | +| `set_wind_down_delay` | `#[only_admin]` | `MIN_WIND_DOWN_DELAY <= secs <= MAX_WIND_DOWN_DELAY`. Raising is instant; lowering is `UseTimelock` after setup | `test::governance::raising_the_wind_down_delay_is_instant_and_lowering_waits`, `test::governance::a_wind_down_delay_below_the_minimum_cannot_be_proposed`, `test::wind_down::only_governance_sets_the_delay` | +| `set_deposit_cap` | `#[only_admin]` | Not negative. Instant both ways, also after setup | `test::governance::the_deposit_cap_stays_instant_both_ways` | +| `finish_setup` | `#[only_admin]` | Once. The token and the oracle report this vault with their setup closed, the vault is the token's only `manager`, the token's `total_supply` is zero, the vault passes `verify_identity`, the token's `is_frozen` is false for the vault, and the token is in its compliance contract's `linked_tokens`; a compliance contract that answers `vault()` and `setup_open()` reports this vault with its setup closed (one that does not answer them is an external module and is accepted); no admin hand-over offered during setup is pending | `test::setup::every_wiring_step_is_checked_before_setup_finishes`, `test::setup::a_compliance_contract_from_outside_the_kit_is_accepted`, `test::setup::setup_cannot_finish_while_a_hand_over_is_pending`, `test::setup::setup_never_finishes_twice`, `test::setup::only_governance_finishes_setup` | | `grant_role` / `transfer_admin_role` | Admin | Instant during setup, `UseTimelock` after. A zero `live_until_ledger` cancels instantly | `test::governance::grants_and_manager_revokes_wait_for_the_timelock`, `test::governance::an_admin_hand_over_waits_but_cancelling_one_does_not` | | `accept_admin_transfer` | The pending admin's auth | A hand-over is pending; an applied `Admin` change stays open for seven days of ledgers | `test::governance::an_applied_admin_change_starts_a_hand_over_the_next_admin_accepts`, `test::governance::the_hand_over_offer_lasts_seven_days_of_ledgers`, `test::governance::an_admin_hand_over_waits_but_cancelling_one_does_not`, `test::setup::setup_cannot_finish_while_a_hand_over_is_pending` | | `revoke_role` | Admin | `treasury`, `guardian`: instant. `manager`: instant during setup, `UseTimelock` after | `test::governance::grants_and_manager_revokes_wait_for_the_timelock` (instant case: `test::governance::revoking_treasury_or_guardian_stays_instant`; caller check: `test::governance::only_the_admin_revokes_a_role`) | | `set_role_admin` | — | Always refused (`UseTimelock`) | `test::governance::no_role_ever_gets_a_role_admin` | -| `propose_wind_down` | `#[only_admin]`, `caller.require_auth()` | No proposal standing, not active | `test::wind_down::only_governance_proposes` | -| `cancel_wind_down_proposal` | `#[only_admin]`, `caller.require_auth()` | Must be proposed, not active | `test::wind_down::governance_cancels_a_proposal_before_activation` | -| `propose_change` | `#[only_admin]`, `caller.require_auth()` | Not winding down, setup finished, one code change at a time, an `UpgradeDelay` in `[MIN, MAX]` and `>= notice`, a `Notice` within its bounds, a `WindDownDelay` in `[MIN_WIND_DOWN_DELAY, MAX_WIND_DOWN_DELAY]` | `test::upgrade::only_governance_proposes_and_cancels`, `test::upgrade::nothing_is_proposed_during_a_wind_down`, `test::setup::nothing_is_proposed_during_setup` | -| `cancel_change` | `#[only_admin]`, `caller.require_auth()` | The change must be pending | `test::upgrade::only_governance_proposes_and_cancels` | -| `apply_change` | `#[only_admin]`, `caller.require_auth()` | Eta passed, not paused, not winding down, delay `>= notice`; an `UpgradeDelay`, `Notice` or `WindDownDelay` change is checked against its bounds again | `test::upgrade::only_governance_applies`, `test::governance::a_change_shorter_than_a_raised_notice_never_lands`, `test::governance::nothing_timelocked_moves_during_a_wind_down_a_manager_revoke_included` | +| `propose_wind_down` | `#[only_admin]` | No proposal standing, not active | `test::wind_down::only_governance_proposes` | +| `cancel_wind_down_proposal` | `#[only_admin]` | Must be proposed, not active | `test::wind_down::governance_cancels_a_proposal_before_activation` | +| `propose_change` | `#[only_admin]` | Not winding down, setup finished, one code change at a time, an `UpgradeDelay` in `[MIN, MAX]` and `>= notice`, a `Notice` within its bounds, a `WindDownDelay` in `[MIN_WIND_DOWN_DELAY, MAX_WIND_DOWN_DELAY]` | `test::upgrade::only_governance_proposes_and_cancels`, `test::upgrade::nothing_is_proposed_during_a_wind_down`, `test::setup::nothing_is_proposed_during_setup` | +| `cancel_change` | `#[only_admin]` | The change must be pending | `test::upgrade::only_governance_proposes_and_cancels` | +| `apply_change` | `#[only_admin]` | Eta passed, not paused, not winding down, delay `>= notice`; an `UpgradeDelay`, `Notice` or `WindDownDelay` change is checked against its bounds again | `test::upgrade::only_governance_applies`, `test::governance::a_change_shorter_than_a_raised_notice_never_lands`, `test::governance::nothing_timelocked_moves_during_a_wind_down_a_manager_revoke_included` | | `propose_change` (`Oracle(address)`) | `#[only_admin]` | The feed names this vault and its setup is closed | `test::oracle_swap::a_feed_outside_this_vault_is_never_proposed` | | `apply_change` (`Oracle(address)`) | `#[only_admin]` | Delay run; the feed still names this vault with its setup closed, and answers `state()` | `test::oracle_swap::the_oracle_changes_only_through_the_timelock`, `test::oracle_swap::a_feed_that_stops_naming_this_vault_is_never_installed` | | `pending_changes` | Open view | None | None (view) | @@ -110,12 +114,12 @@ Tests: [`controls.rs`](../contracts/async-vault/src/test/controls.rs), [`epochs. The token's `admin` is governance; the guardian holds no role on the token. Until `finish_setup`, admin changes are instant, so the deploy can grant the vault the `manager` role. 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, revoking `manager`, admin hand-over, `set_compliance`, `set_identity_verifier` and upgrades. Revoking `compliance` and `pause`/`unpause` stay instant. The `manager` role is held by the vault and the `compliance` role by the compliance authority. The token's `pause` refuses SEP-41 transfers between holders only. The vault's own escrow moves (`forced_transfer`), mint and burn ignore it, so a token pause stops neither entry nor exit. -Tests: [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.rs) and [`contracts/share-token/src/test_governance.rs`](../contracts/share-token/src/test_governance.rs). +Tests: [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.rs) and [`contracts/share-token/src/test_governance.rs`](../contracts/share-token/src/test_governance.rs). What each privileged call asks to sign: `test_governance::each_admin_call_asks_the_admin_for_one_signature`, `test::each_role_call_asks_its_holder_for_one_signature` and `test::legal_transfer::a_legal_transfer_or_a_recovery_asks_each_signer_once`. | Entrypoint | Access Control / Caller Auth | State & Precondition Guards | Tested Refusal (Test Name) | |---|---|---|---| -| `pause` | `#[only_admin]`, `caller.require_auth()` | Not paused | `test::unauthorized_caller_cannot_pause_or_unpause` | -| `unpause` | `#[only_admin]`, `caller.require_auth()` | Paused | `test::unauthorized_caller_cannot_pause_or_unpause` | +| `pause` | `caller` must be the stored admin (`AdminRequired`), then `caller.require_auth()` | Not paused | `test::unauthorized_caller_cannot_pause_or_unpause`, `test_governance::a_caller_other_than_the_admin_is_refused_even_when_it_signs` | +| `unpause` | `caller` must be the stored admin (`AdminRequired`), then `caller.require_auth()` | Paused | `test::unauthorized_caller_cannot_pause_or_unpause`, `test_governance::a_caller_other_than_the_admin_is_refused_even_when_it_signs` | | `mint` | `#[only_role(operator, "manager")]` | Receiver allowlisted | `test::unauthorized_caller_cannot_mint`, `test::the_compliance_authority_cannot_mint_burn_or_swap_the_rules` | | `burn` | `#[only_role(operator, "manager")]` | Balance sufficient | `test::unauthorized_caller_cannot_burn` | | `forced_transfer` | `#[only_role(operator, "manager")]` | Balance sufficient | `test::unauthorized_caller_cannot_forced_transfer`, `test::legal_transfer::compliance_no_longer_forces_transfers` | @@ -124,14 +128,14 @@ Tests: [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.r | `freeze_partial_tokens` | `#[only_role(operator, "compliance")]` | Balance sufficient; never the bound vault's address (`VaultAddress`) | `test::unauthorized_caller_cannot_freeze_or_recover`, `test::the_manager_escrows_but_does_not_intervene`, `test::legal_transfer::the_vault_address_cannot_be_frozen` | | `unfreeze_partial_tokens`| `#[only_role(operator, "compliance")]` | Frozen amount sufficient | `test::unauthorized_caller_cannot_freeze_or_recover`, `test::the_manager_escrows_but_does_not_intervene` | | `recover_balance` | `#[only_role(operator, "compliance")]` and the admin's `require_auth()` in the same call, from two different accounts (`CoSignRequired`) | Never from or to the vault's address (`VaultAddress`); target allowlisted and named by the verifier's `recovery_target` (the testnet stub names none, so recovery always fails there) | `test::unauthorized_caller_cannot_freeze_or_recover`, `test::the_compliance_authority_reaches_recovery`, `test::legal_transfer::recovery_needs_the_admin_too`, `test::legal_transfer::recovery_never_takes_the_vault_balance`, `test::legal_transfer::recovery_never_lands_on_the_vault_address` | -| `set_compliance` | `#[only_admin]`, `operator.require_auth()` | Instant during setup; `UseTimelock` after | `test::unauthorized_caller_cannot_set_compliance_or_verifier`, `test::the_compliance_authority_cannot_mint_burn_or_swap_the_rules`, `test_governance::rule_and_verifier_swaps_wait_after_setup` | -| `set_identity_verifier` | `#[only_admin]`, `operator.require_auth()` | Instant during setup; `UseTimelock` after | `test::unauthorized_caller_cannot_set_compliance_or_verifier`, `test::the_compliance_authority_cannot_mint_burn_or_swap_the_rules`, `test_governance::rule_and_verifier_swaps_wait_after_setup` | +| `set_compliance` | `operator` must be the stored admin (`AdminRequired`), then `operator.require_auth()` | Instant during setup; `UseTimelock` after | `test::unauthorized_caller_cannot_set_compliance_or_verifier`, `test::the_compliance_authority_cannot_mint_burn_or_swap_the_rules`, `test_governance::rule_and_verifier_swaps_wait_after_setup`, `test_governance::a_caller_other_than_the_admin_is_refused_even_when_it_signs` | +| `set_identity_verifier` | `operator` must be the stored admin (`AdminRequired`), then `operator.require_auth()` | Instant during setup; `UseTimelock` after | `test::unauthorized_caller_cannot_set_compliance_or_verifier`, `test::the_compliance_authority_cannot_mint_burn_or_swap_the_rules`, `test_governance::rule_and_verifier_swaps_wait_after_setup`, `test_governance::a_caller_other_than_the_admin_is_refused_even_when_it_signs` | | `renounce_admin` | Refused always (`ShareTokenError::AdminRequired`) | None | `test::the_admin_cannot_renounce_itself_out_of_the_token`; `test::admin_handover_in_two_steps_still_works`; `test_governance::an_applied_admin_change_starts_a_hand_over_the_next_admin_accepts`. A token without an admin could never be unpaused or rotate its manager; 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::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 | `test_governance::nothing_is_proposed_during_setup`, `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 | `test_governance::a_vault_pause_moves_the_eta_and_holds_the_change`, `test_governance::a_change_shorter_than_a_raised_notice_never_lands` | +| `bind_vault` | `#[only_admin]` | During setup, once; the vault must answer `timelock_clock` | `test_governance::a_second_vault_cannot_be_bound`, `test_governance::an_address_that_reports_no_clock_cannot_be_bound` | +| `finish_setup` | `#[only_admin]` | 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]` | After setup; vault not winding down; one upgrade at a time | `test_governance::nothing_is_proposed_during_setup`, `test_governance::only_the_admin_proposes_cancels_and_applies` | +| `cancel_change` | `#[only_admin]` | The change must be pending | `test_governance::only_the_admin_proposes_cancels_and_applies` | +| `apply_change` | `#[only_admin]` | Delay run on the vault's clock, vault unpaused and not winding down, delay `>=` the vault's notice | `test_governance::a_vault_pause_moves_the_eta_and_holds_the_change`, `test_governance::a_change_shorter_than_a_raised_notice_never_lands` | | `pending_changes` | Open view | None | None (view) | | `grant_role` | Admin, `caller.require_auth()` | Instant during setup; `UseTimelock` after | `test_governance::grants_and_manager_revokes_wait_after_setup` | | `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`) | @@ -144,21 +148,21 @@ Tests: [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.r 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). +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). What each privileged call asks to sign: `test_governance::each_admin_call_asks_the_admin_for_one_signature` and `test_governance::each_role_call_asks_its_holder_for_one_signature`. | 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; 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. 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` | +| `set_nav_paused` | `#[only_admin]` | None; instant after setup too | `test::the_guardian_pauses_the_nav_but_unpausing_needs_governance` | +| `clear_latest` | `#[only_admin]` | 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]` | 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` | -| `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` | +| `bind_vault` | `#[only_admin]` | 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]` | 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]` | 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]` | The change must be pending | `test_governance::only_the_admin_proposes_cancels_and_applies` | +| `apply_change` | `#[only_admin]` | 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`) | @@ -171,24 +175,25 @@ Tests: [`contracts/nav-oracle/src/test.rs`](../contracts/nav-oracle/src/test.rs) A testnet-only stub. Its admin is the compliance key and cannot be transferred: the stub exposes no access-control interface, so replacing the compliance key on it means redeploying. So the compliance key loses its power at once only on the share token, where its `compliance` role can be revoked instantly. On this stub it can still allowlist and delist addresses until the token's verifier is swapped for a new deployment; after setup that swap waits for the vault's timelock, at least seven days. -Tests: [`contracts/identity-verifier/src/test.rs`](../contracts/identity-verifier/src/test.rs). +Tests: [`contracts/identity-verifier/src/test.rs`](../contracts/identity-verifier/src/test.rs). What each admin call asks to sign: `test::each_admin_call_asks_the_admin_for_one_signature`. | Entrypoint | Access Control / Caller Auth | State & Precondition Guards | Tested Refusal (Test Name) | |---|---|---|---| -| `allow` | `#[only_admin]` (the compliance key), `caller.require_auth()` | None | `test::only_admin_can_allow` | +| `allow` | `#[only_admin]` (the compliance key) | None | `test::only_admin_can_allow` | +| `set_claim_topics_and_issuers` | `#[only_admin]` (the compliance key); keeps the `operator` the trait fixes and does not read it | None; it changes nothing | None (signature: `test::each_admin_call_asks_the_admin_for_one_signature`) | | `verify_identity` | Open check (Internal hook) | `is_allowed(account) == true` | `test::verify_identity_checks_allowlist` | ### 5.5 Compliance (`contracts/compliance`) -A testnet-only stub. Its admin is governance. Module changes, token bindings and admin hand-overs are instant during setup and wait for the vault's timelock after `finish_setup`. Each admin call checks the stored admin's signature; its `caller` or `operator` argument is not checked. +A testnet-only stub. Its admin is governance. Module changes, token bindings and admin hand-overs are instant during setup and wait for the vault's timelock after `finish_setup`. Each admin call asks the stored admin to sign. Its own entrypoints take no caller argument; its OpenZeppelin trait methods keep the `operator` argument their trait fixes and do not read it (principle 2). -Tests: [`contracts/compliance/src/test/changes.rs`](../contracts/compliance/src/test/changes.rs). +Tests: [`contracts/compliance/src/test/changes.rs`](../contracts/compliance/src/test/changes.rs). What each privileged call asks to sign: `test::changes::each_admin_call_asks_the_admin_for_one_signature`, `test::changes::a_hand_over_asks_each_side_for_one_signature` and `test::changes::each_hook_asks_the_bound_token_for_one_signature`. | Entrypoint | Access Control / Caller Auth | State & Precondition Guards | Tested Refusal (Test Name) | |---|---|---|---| | `bind_vault` | `#[only_admin]` | During setup, once; the vault must answer `timelock_clock` | `test::changes::a_second_vault_or_a_late_vault_is_refused`, `test::changes::an_address_that_reports_no_clock_cannot_be_bound`, `test::changes::only_the_admin_binds_and_finishes` | | `finish_setup` | `#[only_admin]` | Needs a bound vault and no pending admin hand-over; once | `test::changes::setup_cannot_finish_without_a_vault`, `test::changes::setup_cannot_finish_while_a_hand_over_is_pending`, `test::changes::a_second_vault_or_a_late_vault_is_refused` | -| `bind_token`, `unbind_token`, `add_module_to`, `remove_module_from` | `#[only_admin]` | Instant during setup; `UseTimelock` after | `test::changes::after_setup_rule_changes_wait_for_the_timelock` | +| `bind_token`, `unbind_token`, `add_module_to`, `remove_module_from` | `#[only_admin]`; `operator` is not read | Instant during setup; `UseTimelock` after | `test::changes::after_setup_rule_changes_wait_for_the_timelock` | | `propose_change` | `#[only_admin]` | After setup; vault not winding down | `test::changes::proposals_wait_until_setup_is_finished`, `test::changes::only_the_admin_proposes_cancels_and_applies` | | `cancel_change` | `#[only_admin]` | The change must be pending | `test::changes::a_cancelled_change_never_lands` | | `apply_change` | `#[only_admin]` | Delay run on the vault's clock, vault unpaused and not winding down, delay `>=` the vault's notice | `test::changes::a_proposed_binding_lands_after_the_delay`, `test::changes::nothing_lands_while_the_vault_is_paused_or_winding_down`, `test::changes::a_vault_pause_pushes_the_change_back`, `test::changes::a_change_shorter_than_a_raised_notice_never_lands` | diff --git a/docs/TESTING.md b/docs/TESTING.md index dd800139..00669be6 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -66,12 +66,16 @@ extend their own on every call that does work. ## Authorization -Most tests sign every call with `mock_all_auths`. A refusal test then clears -all signatures with `set_auths(&[])` and checks the call fails and changes -nothing. +Most tests sign every call with `mock_all_auths`. A refusal test then signs +the call as another account only (`mock_auths`), or clears all signatures with +`set_auths(&[])`, and checks the call fails and changes nothing. A signature +test reads `e.auths()` right after a call and expects one entry per account +the call needs, on that call only. | Behaviour | Tests | Key test names | | --- | --- | --- | +| Every privileged call asks each account it needs for one signature: an admin call asks the admin once, a role call asks the role holder once, and `legal_transfer` and `recover_balance` ask compliance and the admin once each | [`contracts/async-vault/src/test/signatures.rs`](../contracts/async-vault/src/test/signatures.rs), [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.rs), [`contracts/share-token/src/test_governance.rs`](../contracts/share-token/src/test_governance.rs), [`contracts/nav-oracle/src/test_governance.rs`](../contracts/nav-oracle/src/test_governance.rs), [`contracts/compliance/src/test/changes.rs`](../contracts/compliance/src/test/changes.rs), [`contracts/identity-verifier/src/test.rs`](../contracts/identity-verifier/src/test.rs) | `each_setup_call_asks_the_admin_for_one_signature`, `each_admin_call_after_setup_asks_the_admin_for_one_signature`, `each_admin_call_asks_the_admin_for_one_signature`, `each_role_call_asks_its_holder_for_one_signature`, `a_legal_transfer_or_a_recovery_asks_each_signer_once`, `a_hand_over_asks_each_side_for_one_signature`, `each_hook_asks_the_bound_token_for_one_signature` | +| An account other than the admin cannot pause, unpause or swap the token's rules or verifier, or unpause the vault, even when it signs | [`contracts/async-vault/src/test/controls.rs`](../contracts/async-vault/src/test/controls.rs), [`contracts/share-token/src/test_governance.rs`](../contracts/share-token/src/test_governance.rs) | `a_caller_other_than_the_admin_cannot_unpause_even_when_it_signs`, `a_caller_other_than_the_admin_is_refused_even_when_it_signs` | | A deposit is one investor signature that covers the vault call and the asset transfer inside it | [`contracts/async-vault/src/test/deposit.rs`](../contracts/async-vault/src/test/deposit.rs) | `one_investor_signature_covers_the_request_and_the_transfer` | | Investor calls refuse without the investor's signature | [`contracts/async-vault/src/test/deposit.rs`](../contracts/async-vault/src/test/deposit.rs), [`contracts/async-vault/src/test/redeem.rs`](../contracts/async-vault/src/test/redeem.rs) | `request_deposit_needs_the_investor_authorisation`, `claiming_needs_the_investor_authorisation`, `request_redeem_needs_the_holder_authorisation` | | Role calls refuse without the role holder's signature | [`contracts/async-vault/src/test/epochs.rs`](../contracts/async-vault/src/test/epochs.rs), [`contracts/async-vault/src/test/notice.rs`](../contracts/async-vault/src/test/notice.rs), [`contracts/async-vault/src/test/treasury.rs`](../contracts/async-vault/src/test/treasury.rs), [`contracts/async-vault/src/test/deposit_cap.rs`](../contracts/async-vault/src/test/deposit_cap.rs) | `closing_needs_the_manager_authorisation`, `setting_the_notice_needs_governance_authorisation`, `setting_the_custodian_needs_the_admin_signature`, `unauthorized_caller_rejected` | diff --git a/docs/kit-spec.md b/docs/kit-spec.md index c0b7b30c..3a018a44 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -186,7 +186,7 @@ The `async_vault` constructor strictly verifies pairwise separation across autho ## 3. Role & Authority Matrix -Strata distributes control across five native Stellar multisig authorities and permissionless public entrypoints. Every privileged function is mapped to exactly one authority, except the share token's `legal_transfer` and `recover_balance`, which need compliance and governance in the same call. +Strata distributes control across five native Stellar multisig authorities and permissionless public entrypoints. Every privileged function is mapped to exactly one authority, except the share token's `legal_transfer` and `recover_balance`, which need compliance and governance in the same call. Each call asks each account it needs for one signature. An `#[only_admin]` entrypoint takes no caller argument: the stored admin signs it. ### Authority Capabilities & Anti-Capabilities @@ -215,7 +215,7 @@ Strata distributes control across five native Stellar multisig authorities and p | **`AsyncVault`** | `close_epoch` | Manager | `#[only_role(caller, "manager")]` | No | | **`AsyncVault`** | `fulfill_epoch` | Permissionless | None | Yes (halted by pause) | | **`AsyncVault`** | `pause` | Guardian | `#[only_role(caller, "guardian")]` | N/A | -| **`AsyncVault`** | `unpause` | Governance | `#[only_admin]` | N/A | +| **`AsyncVault`** | `unpause` | Governance | `caller` must be the admin (`AdminRequired`), then `caller.require_auth()` | N/A | | **`AsyncVault`** | `set_notice` | Governance | `#[only_admin]`; raising is instant, lowering is instant during setup, then `propose_change` | No | | **`AsyncVault`** | `set_custodian` | Governance | `#[only_admin]`; instant during setup, then `propose_change` | No | | **`AsyncVault`** | `set_deposit_cap` | Governance | `#[only_admin]`; instant both ways | No | @@ -236,7 +236,7 @@ Strata distributes control across five native Stellar multisig authorities and p | **`AsyncVault`** | `grant_role`, `transfer_admin_role` | Governance | Instant during setup, then `propose_change` (a zero `live_until_ledger` cancels at once) | No | | **`AsyncVault`** | `revoke_role` | Governance | `treasury`, `guardian`: instant. `manager`: instant during setup, then `propose_change` | No | | **`AsyncVault`** | `set_role_admin` | None | Always refused (`UseTimelock`) | N/A | -| **`AsyncVault`** | `accept_admin_transfer` | Proposed Admin | `caller.require_auth()` | No | +| **`AsyncVault`** | `accept_admin_transfer` | Proposed Admin | The pending admin's `require_auth()` | No | | **`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 | @@ -251,15 +251,15 @@ Strata distributes control across five native Stellar multisig authorities and p | **`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`** | `pause` | Admin (Governance) | `caller` must be the admin (`AdminRequired`), then `caller.require_auth()` | N/A | +| **`ShareToken`** | `unpause` | Admin (Governance) | `caller` must be the admin (`AdminRequired`), then `caller.require_auth()` | N/A | | **`ShareToken`** | `mint` | Manager (Vault) | `#[only_role(operator, "manager")]` | Bypasses token pause | | **`ShareToken`** | `burn` | Manager (Vault) | `#[only_role(operator, "manager")]` | Bypasses token pause | | **`ShareToken`** | `forced_transfer` | Manager (Vault) | `#[only_role(operator, "manager")]` | Bypasses token pause | | **`ShareToken`** | `legal_transfer` | Compliance, co-signed by Governance (token admin) | `#[only_role(caller, "compliance")]` and the admin's `require_auth()` in the same call, from two different accounts; never from or to the vault's address; the destination passes `verify_identity`; a reference of 1–128 bytes | Bypasses token pause | | **`ShareToken`** | `set_address_frozen`, `freeze_partial_tokens`, `unfreeze_partial_tokens` | Compliance | `#[only_role(operator, "compliance")]`; never freezes the vault's address once the token knows its vault | No | | **`ShareToken`** | `recover_balance` | Compliance, co-signed by Governance (token admin) | `#[only_role(operator, "compliance")]` and the admin's `require_auth()` in the same call, from two different accounts; never from or to the vault's address | No | -| **`ShareToken`** | `set_compliance`, `set_identity_verifier` | Governance | `#[only_admin]`; instant during setup, then `propose_change` | No | +| **`ShareToken`** | `set_compliance`, `set_identity_verifier` | Governance | `operator` must be the admin (`AdminRequired`), then `operator.require_auth()`; instant during setup, then `propose_change` | No | | **`ShareToken`** | `bind_vault` | Governance (token admin) | `#[only_admin]`; during setup, once | No | | **`ShareToken`** | `finish_setup` | Governance | `#[only_admin]`; needs a bound vault and no pending admin hand-over | No | | **`ShareToken`** | `propose_change` | Governance | `#[only_admin]`; after setup | No | @@ -270,8 +270,8 @@ Strata distributes control across five native Stellar multisig authorities and p | **`ShareToken`** | `revoke_role` | Governance | `compliance`: instant. `manager`: instant during setup, then `propose_change` | No | | **`ShareToken`** | `set_role_admin` | None | Always refused (`UseTimelock`) | N/A | | **`IdentityVerifier`** | `allow` | Admin (Compliance) | `#[only_admin]` | No | -| **`IdentityVerifier`** | `set_claim_topics_and_issuers`| Admin (Compliance) | `#[only_admin]` | No | -| **`Compliance`** | `bind_token`, `unbind_token`, `add_module_to`, `remove_module_from` | Governance | `#[only_admin]`; instant during setup, then `propose_change` | No | +| **`IdentityVerifier`** | `set_claim_topics_and_issuers`| Admin (Compliance) | `#[only_admin]`; `operator` is not read | No | +| **`Compliance`** | `bind_token`, `unbind_token`, `add_module_to`, `remove_module_from` | Governance | `#[only_admin]`, `operator` is not read; instant during setup, then `propose_change` | No | | **`Compliance`** | `bind_vault` | Governance (compliance admin) | `#[only_admin]`; during setup, once | No | | **`Compliance`** | `finish_setup` | Governance | `#[only_admin]`; needs a bound vault and no pending admin hand-over | No | | **`Compliance`** | `propose_change` | Governance | `#[only_admin]`; after setup | No | From d1255ccd6f75231448c3c0eb6ab4d3aa2dbf7642 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:54:33 -0300 Subject: [PATCH 087/147] test: pin what renounce_role asks to sign --- contracts/async-vault/src/test/signatures.rs | 8 ++++++++ contracts/nav-oracle/src/test_governance.rs | 6 ++++++ contracts/share-token/src/test.rs | 9 ++++++++- docs/AUTHORIZATION_MATRIX.md | 3 +++ 4 files changed, 25 insertions(+), 1 deletion(-) diff --git a/contracts/async-vault/src/test/signatures.rs b/contracts/async-vault/src/test/signatures.rs index 88835d0f..48e6c8e8 100644 --- a/contracts/async-vault/src/test/signatures.rs +++ b/contracts/async-vault/src/test/signatures.rs @@ -107,4 +107,12 @@ fn each_role_call_asks_its_holder_for_one_signature() { ); f.vault.pause(&f.guardian); f.signed_once_by(&f.guardian, "pause", (f.guardian.clone(),).into_val(&f.e)); + + let guardian = symbol_short!("guardian"); + f.vault.renounce_role(&guardian, &f.guardian); + f.signed_once_by( + &f.guardian, + "renounce_role", + (guardian, f.guardian.clone()).into_val(&f.e), + ); } diff --git a/contracts/nav-oracle/src/test_governance.rs b/contracts/nav-oracle/src/test_governance.rs index a9c58379..6a64dec0 100644 --- a/contracts/nav-oracle/src/test_governance.rs +++ b/contracts/nav-oracle/src/test_governance.rs @@ -1013,6 +1013,12 @@ fn each_role_call_asks_its_holder_for_one_signature() { "pause_nav", (f.guardian.clone(),).into_val(&f.e), ); + f.oracle.renounce_role(&ATTESTER_ROLE, &f.attester); + f.signed_once_by( + &f.attester, + "renounce_role", + (ATTESTER_ROLE, f.attester.clone()).into_val(&f.e), + ); f.oracle .transfer_admin_role(&next, &(f.e.ledger().sequence() + ADMIN_ACCEPT_LEDGERS)); diff --git a/contracts/share-token/src/test.rs b/contracts/share-token/src/test.rs index df9ec251..52137224 100644 --- a/contracts/share-token/src/test.rs +++ b/contracts/share-token/src/test.rs @@ -54,7 +54,6 @@ fn manager_mints_to_allowlisted_account() { } struct Fixture<'a> { - #[allow(dead_code)] e: Env, token: ShareTokenClient<'a>, admin: Address, @@ -604,6 +603,14 @@ fn each_role_call_asks_its_holder_for_one_signature() { "set_address_frozen", (holder, true, compliance.clone()).into_val(&e), ); + + let role = Symbol::new(&e, "compliance"); + f.token.renounce_role(&role, compliance); + f.signed_once_by( + &[compliance], + "renounce_role", + (role, compliance.clone()).into_val(&e), + ); } mod legal_transfer { diff --git a/docs/AUTHORIZATION_MATRIX.md b/docs/AUTHORIZATION_MATRIX.md index 1542c51e..877f5bfa 100644 --- a/docs/AUTHORIZATION_MATRIX.md +++ b/docs/AUTHORIZATION_MATRIX.md @@ -99,6 +99,7 @@ Tests: [`controls.rs`](../contracts/async-vault/src/test/controls.rs), [`epochs. | `grant_role` / `transfer_admin_role` | Admin | Instant during setup, `UseTimelock` after. A zero `live_until_ledger` cancels instantly | `test::governance::grants_and_manager_revokes_wait_for_the_timelock`, `test::governance::an_admin_hand_over_waits_but_cancelling_one_does_not` | | `accept_admin_transfer` | The pending admin's auth | A hand-over is pending; an applied `Admin` change stays open for seven days of ledgers | `test::governance::an_applied_admin_change_starts_a_hand_over_the_next_admin_accepts`, `test::governance::the_hand_over_offer_lasts_seven_days_of_ledgers`, `test::governance::an_admin_hand_over_waits_but_cancelling_one_does_not`, `test::setup::setup_cannot_finish_while_a_hand_over_is_pending` | | `revoke_role` | Admin | `treasury`, `guardian`: instant. `manager`: instant during setup, `UseTimelock` after | `test::governance::grants_and_manager_revokes_wait_for_the_timelock` (instant case: `test::governance::revoking_treasury_or_guardian_stays_instant`; caller check: `test::governance::only_the_admin_revokes_a_role`) | +| `renounce_role` | The role holder, `caller.require_auth()` (OpenZeppelin's default) | The caller holds the role (`RoleNotHeld`); instant for every role, `manager` included | `test::signatures::each_role_call_asks_its_holder_for_one_signature` | | `set_role_admin` | — | Always refused (`UseTimelock`) | `test::governance::no_role_ever_gets_a_role_admin` | | `propose_wind_down` | `#[only_admin]` | No proposal standing, not active | `test::wind_down::only_governance_proposes` | | `cancel_wind_down_proposal` | `#[only_admin]` | Must be proposed, not active | `test::wind_down::governance_cancels_a_proposal_before_activation` | @@ -139,6 +140,7 @@ Tests: [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.r | `pending_changes` | Open view | None | None (view) | | `grant_role` | Admin, `caller.require_auth()` | Instant during setup; `UseTimelock` after | `test_governance::grants_and_manager_revokes_wait_after_setup` | | `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`) | +| `renounce_role` | The role holder, `caller.require_auth()` (OpenZeppelin's default) | The caller holds the role (`RoleNotHeld`); instant | `test::each_role_call_asks_its_holder_for_one_signature` | | `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` | | `accept_admin_transfer` | The pending admin's auth | A hand-over is pending; an applied `Admin` change stays open for seven days of ledgers | `test_governance::an_applied_admin_change_starts_a_hand_over_the_next_admin_accepts`, `test_governance::the_hand_over_offer_lasts_seven_days_of_ledgers`, `test_governance::an_admin_hand_over_waits_but_cancelling_one_does_not`, `test_governance::setup_cannot_finish_while_a_hand_over_is_pending` | | `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. | @@ -166,6 +168,7 @@ Tests: [`contracts/nav-oracle/src/test.rs`](../contracts/nav-oracle/src/test.rs) | `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`) | +| `renounce_role` | The role holder, `caller.require_auth()` (OpenZeppelin's default) | The caller holds the role (`RoleNotHeld`); instant | `test_governance::each_role_call_asks_its_holder_for_one_signature` | | `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` | | `accept_admin_transfer` | The pending admin's auth | A hand-over is pending; an applied `Admin` change stays open for seven days of ledgers | `test_governance::an_applied_admin_change_starts_a_hand_over_the_next_admin_accepts`, `test_governance::the_hand_over_offer_lasts_seven_days_of_ledgers`, `test_governance::an_admin_hand_over_waits_but_cancelling_one_does_not`, `test_governance::setup_cannot_finish_while_a_hand_over_is_pending` | | `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. | From 961bec833a4acaade4634b101640d40357921999 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:54:34 -0300 Subject: [PATCH 088/147] docs: fix kit spec admin and runbook lines --- docs/kit-spec.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/kit-spec.md b/docs/kit-spec.md index 3a018a44..b15c9e13 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -186,7 +186,7 @@ The `async_vault` constructor strictly verifies pairwise separation across autho ## 3. Role & Authority Matrix -Strata distributes control across five native Stellar multisig authorities and permissionless public entrypoints. Every privileged function is mapped to exactly one authority, except the share token's `legal_transfer` and `recover_balance`, which need compliance and governance in the same call. Each call asks each account it needs for one signature. An `#[only_admin]` entrypoint takes no caller argument: the stored admin signs it. +Strata distributes control across five native Stellar multisig authorities and permissionless public entrypoints. Every privileged function is mapped to exactly one authority, except the share token's `legal_transfer` and `recover_balance`, which need compliance and governance in the same call. Each call asks each account it needs for one signature. An admin entrypoint the kit defines takes no caller argument: the stored admin signs it. ### Authority Capabilities & Anti-Capabilities @@ -290,10 +290,10 @@ A clean deployment executes in three sequenced phases: 1. **Phase 1: Contract Deployment** - Deploy `compliance`, `identity_verifier`, `share_token`, `nav_oracle`, and `async_vault` with constructor arguments matching `environments.toml`. 2. **Phase 2: Post-Deployment Role Wiring** - - Call `ShareToken::grant_role("manager", vault_address)` to authorize lazy share minting and burning. + - Call `ShareToken::grant_role(vault_address, "manager", governance)` to authorize lazy share minting and burning. - 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 a delisted holder's priced deposit can be minted into the vault's escrow; `finish_setup` requires it. - - Call `Compliance::bind_token(share_token_address)` to link SEP-57 rule verification, then `Compliance::bind_vault(vault_address)` and `Compliance::finish_setup()`. Its module and binding changes are instant until then and wait for the vault's timelock afterwards. + - Call `Compliance::bind_token(share_token_address, governance)` to link SEP-57 rule verification, then `Compliance::bind_vault(vault_address)` and `Compliance::finish_setup()`. Its module and binding changes are instant until then and wait for the vault's timelock afterwards. - 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. - On the vault, call `set_custodian(custodian)`, `set_notice(secs)` and, if initial capital intake is bounded, `set_deposit_cap(cap)`. All three are instant during setup, so deposits never open without the cap. - Then call `finish_setup()` on the vault. It refuses with `SetupIncomplete` until every wiring step above is in place, the token has no shares outstanding and the vault's address is not frozen on it, and with `AdminTransferPending` while an admin hand-over offered during setup is pending. The vault takes no deposit before it. From 785c902e4db9e0957827dd57375e1e28c44905bd Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:05:12 -0300 Subject: [PATCH 089/147] fix: refuse the vault as its own custodian set_custodian and a Custodian change accepted the vault's own address. Capital deployed there never leaves, and the books count it as both held and deployed. Every path that sets the custodian now refuses it with InvalidCustodian: the direct setter during setup and after, and a timelocked change when it is proposed and again when it applies. --- contracts/async-vault/src/error.rs | 3 +++ contracts/async-vault/src/guards.rs | 8 +++++++- contracts/async-vault/src/lib.rs | 3 ++- contracts/async-vault/src/test/governance.rs | 20 ++++++++++++++++++++ contracts/async-vault/src/test/guards.rs | 13 +++++++++++++ contracts/async-vault/src/test/treasury.rs | 12 ++++++++++++ scripts/harness/guards.test.ts | 18 ++++++++++++++++++ 7 files changed, 75 insertions(+), 2 deletions(-) diff --git a/contracts/async-vault/src/error.rs b/contracts/async-vault/src/error.rs index 8c246d06..0e1ca536 100644 --- a/contracts/async-vault/src/error.rs +++ b/contracts/async-vault/src/error.rs @@ -152,4 +152,7 @@ pub enum VaultError { /// once it is accepted, cancelled or expired, so no nominee from setup can /// accept after changes start to wait. AdminTransferPending = 6074, + /// The custodian would be the vault itself. Capital deployed to it would + /// never leave, and the books would count it as both held and deployed. + InvalidCustodian = 6075, } diff --git a/contracts/async-vault/src/guards.rs b/contracts/async-vault/src/guards.rs index 784917d6..e072de91 100644 --- a/contracts/async-vault/src/guards.rs +++ b/contracts/async-vault/src/guards.rs @@ -148,6 +148,12 @@ pub(crate) fn upgrade_delay_in_bounds(e: &Env, secs: u64) { } } +pub(crate) fn custodian_not_the_vault(e: &Env, custodian: &Address) { + if *custodian == e.current_contract_address() { + panic_with_error!(e, VaultError::InvalidCustodian); + } +} + pub(crate) fn feed_bound_here(e: &Env, feed: &Address) { if !governance::bound_here(e, feed) { panic_with_error!(e, VaultError::FeedNotBound); @@ -162,8 +168,8 @@ pub(crate) fn change_in_bounds(e: &Env, change: &VaultChange) { VaultChange::Notice(secs) => notice_in_bounds(e, *secs), VaultChange::WindDownDelay(secs) => wind_down_delay_in_bounds(e, *secs), VaultChange::Oracle(feed) => feed_bound_here(e, feed), + VaultChange::Custodian(custodian) => custodian_not_the_vault(e, custodian), VaultChange::Upgrade(_) - | VaultChange::Custodian(_) | VaultChange::GrantRole(..) | VaultChange::RevokeRole(..) | VaultChange::Admin(_) => {} diff --git a/contracts/async-vault/src/lib.rs b/contracts/async-vault/src/lib.rs index 11dc124a..2e269555 100644 --- a/contracts/async-vault/src/lib.rs +++ b/contracts/async-vault/src/lib.rs @@ -190,9 +190,10 @@ impl AsyncVault { } /// Instant during setup; afterwards the custodian changes through the - /// timelock. + /// timelock. Never the vault itself (`InvalidCustodian`). #[only_admin] pub fn set_custodian(e: &Env, custodian: Address) { + guards::custodian_not_the_vault(e, &custodian); guards::direct_change_allowed(e); treasury::set_custodian(e, &custodian); } diff --git a/contracts/async-vault/src/test/governance.rs b/contracts/async-vault/src/test/governance.rs index f90bd1d0..9b23d0dd 100644 --- a/contracts/async-vault/src/test/governance.rs +++ b/contracts/async-vault/src/test/governance.rs @@ -48,6 +48,26 @@ fn applying_a_custodian_change_publishes_both_events() { ); } +#[test] +fn a_custodian_change_to_the_vault_is_refused_when_proposed_and_when_applied() { + let f = setup(); + let change = VaultChange::Custodian(f.vault.address.clone()); + + refused( + f.vault.try_propose_change(&change), + VaultError::InvalidCustodian, + ); + + // Skips the proposal's check, as a change queued by an earlier version would. + let id = f.e.as_contract(&f.vault.address, || { + strata_timelock::queue::propose::(&f.e, &change).unwrap() + }); + f.advance(MIN_UPGRADE_DELAY); + + refused(f.vault.try_apply_change(&id), VaultError::InvalidCustodian); + assert_eq!(f.vault.custodian(), Some(f.custodian.clone())); +} + #[test] fn raising_the_notice_is_instant_and_lowering_waits() { let f = setup(); diff --git a/contracts/async-vault/src/test/guards.rs b/contracts/async-vault/src/test/guards.rs index 6cd429b4..40220177 100644 --- a/contracts/async-vault/src/test/guards.rs +++ b/contracts/async-vault/src/test/guards.rs @@ -271,6 +271,15 @@ fn upgrade_delay_in_bounds_refuses_a_delay_outside_the_bounds_or_below_the_notic }); } +#[test] +fn custodian_not_the_vault_refuses_the_vault_itself() { + let f = setup(); + holds(&f, || guards::custodian_not_the_vault(&f.e, &f.custodian)); + refuses(&f, VaultError::InvalidCustodian, || { + guards::custodian_not_the_vault(&f.e, &f.vault.address) + }); +} + #[test] fn feed_bound_here_refuses_a_feed_not_bound_to_this_vault() { let f = setup(); @@ -325,6 +334,10 @@ fn change_in_bounds_checks_the_bounds_of_each_change() { VaultChange::Oracle(stranger.clone()), VaultError::FeedNotBound, ), + ( + VaultChange::Custodian(f.vault.address.clone()), + VaultError::InvalidCustodian, + ), ] { refuses(&f, error, || guards::change_in_bounds(&f.e, &change)); } diff --git a/contracts/async-vault/src/test/treasury.rs b/contracts/async-vault/src/test/treasury.rs index c81af58a..dea1410f 100644 --- a/contracts/async-vault/src/test/treasury.rs +++ b/contracts/async-vault/src/test/treasury.rs @@ -127,6 +127,18 @@ fn setting_the_custodian_needs_the_admin_signature() { assert_eq!(f.vault.custodian(), None); } +#[test] +fn setting_the_vault_as_its_own_custodian_is_refused() { + for f in [setup_unfinished(), setup()] { + let before = f.vault.custodian(); + refused( + f.vault.try_set_custodian(&f.vault.address), + VaultError::InvalidCustodian, + ); + assert_eq!(f.vault.custodian(), before); + } +} + #[test] fn deploying_a_non_positive_amount_is_rejected() { let f = setup(); diff --git a/scripts/harness/guards.test.ts b/scripts/harness/guards.test.ts index 3cc0639f..f5d283da 100644 --- a/scripts/harness/guards.test.ts +++ b/scripts/harness/guards.test.ts @@ -332,6 +332,24 @@ describe("the vault after setup", () => { expect((await reader.custodian()).result).toBe(custodian.publicKey()) }) + it("governance cannot make the vault its own custodian", async () => { + const v = vault(c, governance) + const vaultId = c.deployment.contracts.async_vault + expectRefused( + await v.set_custodian({ custodian: vaultId }), + v, + "InvalidCustodian", + ) + expectRefused( + await v.propose_change({ + change: { tag: "Custodian", values: [vaultId] }, + }), + v, + "InvalidCustodian", + ) + expect((await reader.custodian()).result).toBe(custodian.publicKey()) + }) + it("queues a custodian change behind its delay", async () => { const v = vault(c, governance) const id = await send( From 2bb65cc8e48d1667b2380870967db00582ffc4a9 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:07:49 -0300 Subject: [PATCH 090/147] feat: check the whole wiring before setup closes finish_setup checked the bindings, the token's manager role, the share supply, the allowlist and the freeze, but not the rest of the wiring. It now also needs the asset to have the share token's decimals, four different addresses for the asset, the token, the oracle and the vault, an oracle that answers state and freshness_duration, and a custodian that is not the vault. Each check calls out with try_ and counts an error as false, and health() reports each one as a new field. The harness deploy stops if any of them is false. --- contracts/async-vault/src/error.rs | 3 +- contracts/async-vault/src/governance.rs | 36 +++++++++++++++--- contracts/async-vault/src/health.rs | 12 ++++++ contracts/async-vault/src/lib.rs | 3 +- contracts/async-vault/src/test/health.rs | 44 +++++++++++++++++++++- contracts/async-vault/src/test/mod.rs | 44 +++++++++++++++++++--- contracts/async-vault/src/test/setup.rs | 6 +++ contracts/async-vault/src/test/treasury.rs | 4 +- scripts/harness/deploy.ts | 7 +++- 9 files changed, 143 insertions(+), 16 deletions(-) diff --git a/contracts/async-vault/src/error.rs b/contracts/async-vault/src/error.rs index 0e1ca536..5394f89e 100644 --- a/contracts/async-vault/src/error.rs +++ b/contracts/async-vault/src/error.rs @@ -126,7 +126,8 @@ pub enum VaultError { /// Setup has finished, and it never reopens. SetupClosed = 6066, /// A wiring check `health()` reports is false (token, oracle and compliance - /// bound, sole token manager, allowlisted, not frozen) or the token has + /// bound, sole token manager, allowlisted, not frozen, matching decimals, + /// distinct addresses, a feed that answers, a custodian) or the token has /// shares outstanding, so setup cannot finish. SetupIncomplete = 6067, /// The holder fails the share token's identity check, so the vault takes no diff --git a/contracts/async-vault/src/governance.rs b/contracts/async-vault/src/governance.rs index 45c44728..f5b2a0ca 100644 --- a/contracts/async-vault/src/governance.rs +++ b/contracts/async-vault/src/governance.rs @@ -1,5 +1,6 @@ use soroban_sdk::{ - contracttype, panic_with_error, symbol_short, Address, BytesN, Env, Symbol, Vec, + contracttype, panic_with_error, symbol_short, token::TokenClient, Address, BytesN, Env, Symbol, + Vec, }; use stellar_access::{ access_control::{self, AccessControlClient, AccessControlStorageKey}, @@ -71,6 +72,11 @@ pub(crate) struct Wiring { pub allowlisted: bool, pub vault_unfrozen: bool, pub compliance_bound: bool, + pub decimals_match: bool, + pub addresses_distinct: bool, + pub custodian_set: bool, + /// A new feed has no report yet, so this asks only that the feed answers. + pub feed_answers: bool, } impl Wiring { @@ -82,6 +88,10 @@ impl Wiring { && self.allowlisted && self.vault_unfrozen && self.compliance_bound + && self.decimals_match + && self.addresses_distinct + && self.custodian_set + && self.feed_answers } } @@ -135,12 +145,22 @@ fn compliance_bound(e: &Env, compliance: &Address, token: &Address) -> bool { linked && on_this_clock } +fn all_different(addresses: &[&Address]) -> bool { + addresses + .iter() + .enumerate() + .all(|(i, a)| addresses[i + 1..].iter().all(|b| a != b)) +} + pub(crate) fn wiring(e: &Env) -> Wiring { let me = e.current_contract_address(); + let asset = state::get_addr(e, &DataKey::Asset); let token = state::get_addr(e, &DataKey::ShareToken); let oracle = state::get_addr(e, &DataKey::Oracle); let roles = AccessControlClient::new(e, &token); let rwa = RWATokenClient::new(e, &token); + let shares = FungibleTokenClient::new(e, &token); + let feed = OracleFeedClient::new(e, &oracle); Wiring { token_bound: bound_here(e, &token), oracle_bound: bound_here(e, &oracle), @@ -151,10 +171,7 @@ pub(crate) fn wiring(e: &Env) -> Wiring { roles.try_get_role_member(&TOKEN_MANAGER_ROLE, &0), Ok(Ok(holder)) if holder == me ), - no_shares_outstanding: matches!( - FungibleTokenClient::new(e, &token).try_total_supply(), - Ok(Ok(0)) - ), + no_shares_outstanding: matches!(shares.try_total_supply(), Ok(Ok(0))), allowlisted: match rwa.try_identity_verifier() { Ok(Ok(verifier)) => matches!( IdentityVerifierClient::new(e, &verifier).try_verify_identity(&me), @@ -167,6 +184,15 @@ pub(crate) fn wiring(e: &Env) -> Wiring { Ok(Ok(compliance)) => compliance_bound(e, &compliance, &token), _ => false, }, + decimals_match: matches!( + (TokenClient::new(e, &asset).try_decimals(), shares.try_decimals()), + (Ok(Ok(of_asset)), Ok(Ok(of_shares))) if of_asset == of_shares + ), + addresses_distinct: all_different(&[&asset, &token, &oracle, &me]), + custodian_set: state::get_addr_opt(e, &DataKey::Custodian) + .is_some_and(|custodian| custodian != me), + feed_answers: matches!(feed.try_state(), Ok(Ok(_))) + && matches!(feed.try_freshness_duration(), Ok(Ok(_))), } } diff --git a/contracts/async-vault/src/health.rs b/contracts/async-vault/src/health.rs index 8f7884c7..cef963dc 100644 --- a/contracts/async-vault/src/health.rs +++ b/contracts/async-vault/src/health.rs @@ -20,6 +20,14 @@ pub struct Health { pub allowlisted: bool, pub vault_unfrozen: bool, pub compliance_bound: bool, + pub decimals_match: bool, + /// The asset, the share token, the oracle and the vault are four + /// different addresses. + pub addresses_distinct: bool, + /// A custodian is set, and it is not the vault. + pub custodian_set: bool, + /// The oracle answers `state` and `freshness_duration`, report or not. + pub feed_answers: bool, pub feed_valid: bool, /// A report outlives the notice, so an epoch's cancellation window does /// not reopen on its own while the notice runs. @@ -38,6 +46,10 @@ pub(crate) fn health(e: &Env) -> Health { allowlisted: wiring.allowlisted, vault_unfrozen: wiring.vault_unfrozen, compliance_bound: wiring.compliance_bound, + decimals_match: wiring.decimals_match, + addresses_distinct: wiring.addresses_distinct, + custodian_set: wiring.custodian_set, + feed_answers: wiring.feed_answers, feed_valid: matches!(feed.try_state(), Ok(Ok(OracleState::Valid))), freshness_covers_notice: matches!( feed.try_freshness_duration(), diff --git a/contracts/async-vault/src/lib.rs b/contracts/async-vault/src/lib.rs index 2e269555..1dcb990a 100644 --- a/contracts/async-vault/src/lib.rs +++ b/contracts/async-vault/src/lib.rs @@ -271,7 +271,8 @@ impl AsyncVault { /// Ends the window in which admin changes are instant. Needs every wiring /// check `health()` reports (token, oracle and compliance bound, sole token - /// manager, allowlisted, not frozen), no shares outstanding and no admin + /// manager, allowlisted, not frozen, matching decimals, distinct addresses, + /// a feed that answers, a custodian), no shares outstanding and no admin /// hand-over pending. Deposits open from here on, and it never reopens. #[only_admin] pub fn finish_setup(e: &Env) { diff --git a/contracts/async-vault/src/test/health.rs b/contracts/async-vault/src/test/health.rs index afcf2589..4ee84791 100644 --- a/contracts/async-vault/src/test/health.rs +++ b/contracts/async-vault/src/test/health.rs @@ -10,6 +10,10 @@ fn all_healthy() -> Health { allowlisted: true, vault_unfrozen: true, compliance_bound: true, + decimals_match: true, + addresses_distinct: true, + custodian_set: true, + feed_answers: true, feed_valid: true, freshness_covers_notice: true, } @@ -103,6 +107,43 @@ fn each_missing_wiring_step_shows_in_its_own_flag() { ..all_healthy() }, ), + ( + Miswire::AssetDecimalsDiffer, + Health { + decimals_match: false, + ..all_healthy() + }, + ), + ( + Miswire::AssetIsShareToken, + Health { + addresses_distinct: false, + ..all_healthy() + }, + ), + ( + Miswire::OracleNotAFeed, + Health { + feed_answers: false, + feed_valid: false, + freshness_covers_notice: false, + ..all_healthy() + }, + ), + ( + Miswire::NoCustodian, + Health { + custodian_set: false, + ..all_healthy() + }, + ), + ( + Miswire::CustodianIsVault, + Health { + custodian_set: false, + ..all_healthy() + }, + ), ]; for (miswire, expected) in cases { let f = miswired(miswire); @@ -113,7 +154,8 @@ fn each_missing_wiring_step_shows_in_its_own_flag() { Health { setup_open: true, ..expected - } + }, + "{miswire:?}" ); } } diff --git a/contracts/async-vault/src/test/mod.rs b/contracts/async-vault/src/test/mod.rs index cb451efa..87b8cfe7 100644 --- a/contracts/async-vault/src/test/mod.rs +++ b/contracts/async-vault/src/test/mod.rs @@ -31,6 +31,7 @@ mod wind_down; extern crate std; +use soroban_sdk::{contract, contractimpl}; pub(crate) use soroban_sdk::{ symbol_short, testutils::{Address as _, Ledger as _}, @@ -275,6 +276,23 @@ enum Miswire { ComplianceUnbound, ComplianceBoundElsewhere, ComplianceSetupOpen, + AssetDecimalsDiffer, + AssetIsShareToken, + /// The compliance contract passed as the oracle: bound here, but no feed. + OracleNotAFeed, + NoCustodian, + CustodianIsVault, +} + +/// An asset with six decimals, where the share token has seven. +#[contract] +struct SixDecimalAsset; + +#[contractimpl] +impl SixDecimalAsset { + pub fn decimals(_e: Env) -> u32 { + 6 + } } /// A vault left in setup, wired except for `miswire`. @@ -306,8 +324,8 @@ fn setup_with<'a>( /// The share token's constructor arguments, in order. type TokenArgs = (String, String, Address, Address, Address, Address); -/// Wires the set the way the deploy does, apart from `miswire`, then sets the -/// custodian and finishes the vault's setup when `finish` is set. +/// Wires the set the way the deploy does, custodian included, apart from +/// `miswire`, then finishes the vault's setup when `finish` is set. fn build<'a>( feed: OracleConfig, register_token: impl FnOnce(&Env, TokenArgs) -> Address, @@ -360,12 +378,21 @@ fn build<'a>( ), ); + let vault_asset = match miswire { + Miswire::AssetDecimalsDiffer => e.register(SixDecimalAsset, ()), + Miswire::AssetIsShareToken => share.address.clone(), + _ => asset.clone(), + }; + let vault_oracle = match miswire { + Miswire::OracleNotAFeed => compliance.address.clone(), + _ => oracle.address.clone(), + }; let contract_id = e.register( AsyncVault, ( - &asset, + &vault_asset, &share.address, - &oracle.address, + &vault_oracle, VaultRoles { governance: admin.clone(), manager: manager.clone(), @@ -439,8 +466,15 @@ fn build<'a>( } } let vault = AsyncVaultClient::new(&e, &contract_id); + match miswire { + Miswire::NoCustodian => {} + // No entrypoint takes the vault as its own custodian, so the test stores it. + Miswire::CustodianIsVault => e.as_contract(&contract_id, || { + crate::state::set_addr(&e, &crate::keys::DataKey::Custodian, &contract_id) + }), + _ => vault.set_custodian(&custodian), + } if finish { - vault.set_custodian(&custodian); vault.finish_setup(); } diff --git a/contracts/async-vault/src/test/setup.rs b/contracts/async-vault/src/test/setup.rs index 2221ab8d..bd798e68 100644 --- a/contracts/async-vault/src/test/setup.rs +++ b/contracts/async-vault/src/test/setup.rs @@ -64,6 +64,11 @@ fn every_wiring_step_is_checked_before_setup_finishes() { Miswire::ComplianceUnbound, Miswire::ComplianceBoundElsewhere, Miswire::ComplianceSetupOpen, + Miswire::AssetDecimalsDiffer, + Miswire::AssetIsShareToken, + Miswire::OracleNotAFeed, + Miswire::NoCustodian, + Miswire::CustodianIsVault, ] { let f = miswired(miswire); @@ -172,6 +177,7 @@ fn a_token_that_cannot_answer_fails_its_checks_instead_of_trapping() { assert!(!health.allowlisted); assert!(!health.vault_unfrozen); assert!(!health.compliance_bound); + assert!(!health.decimals_match); refused(vault.try_finish_setup(), VaultError::SetupIncomplete); } diff --git a/contracts/async-vault/src/test/treasury.rs b/contracts/async-vault/src/test/treasury.rs index dea1410f..d51883a3 100644 --- a/contracts/async-vault/src/test/treasury.rs +++ b/contracts/async-vault/src/test/treasury.rs @@ -101,7 +101,7 @@ fn deploying_needs_the_treasury_role() { #[test] fn deploying_without_a_custodian_is_rejected() { - let f = setup_unfinished(); + let f = miswired(Miswire::NoCustodian); let backer = f.investor(1_000); f.vault.fund(&backer, &1_000); @@ -114,7 +114,7 @@ fn deploying_without_a_custodian_is_rejected() { #[test] fn setting_the_custodian_needs_the_admin_signature() { - let f = setup_unfinished(); + let f = miswired(Miswire::NoCustodian); f.e.set_auths(&[]); assert!(f.vault.try_set_custodian(&f.custodian).is_err()); diff --git a/scripts/harness/deploy.ts b/scripts/harness/deploy.ts index 6f3da744..fd5d72d8 100644 --- a/scripts/harness/deploy.ts +++ b/scripts/harness/deploy.ts @@ -223,7 +223,8 @@ async function grantAndVerify( } // The custodian is set while setup is open; afterwards it changes only through -// the timelock. finish_setup refuses unless every wiring step above landed. +// the timelock. finish_setup refuses until the custodian and every wiring step +// above are in place. async function finishVaultSetup( net: Network, accounts: Accounts, @@ -255,6 +256,10 @@ async function finishVaultSetup( "allowlisted", "vault_unfrozen", "compliance_bound", + "decimals_match", + "addresses_distinct", + "custodian_set", + "feed_answers", ] as const const broken = wiring.filter((flag) => !health[flag]) if (health.setup_open || broken.length > 0) From 83470d6d51bf19503ba52e7776a051106bde03a7 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:08:13 -0300 Subject: [PATCH 091/147] docs: list the new setup checks The kit spec's Health table, invariant, custodian rows, runbook and adopter interfaces, the authorization matrix's deploy steps and the testing guide list the new wiring checks and the refusal of the vault as its own custodian. The README shows a deploy run that prints the new health() fields. --- README.md | 14 +++++++------- docs/AUTHORIZATION_MATRIX.md | 2 +- docs/TESTING.md | 3 ++- docs/kit-spec.md | 29 +++++++++++++++++------------ 4 files changed, 27 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 11c655b8..9481dbbb 100644 --- a/README.md +++ b/README.md @@ -112,12 +112,12 @@ 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 CCAE3ZUI4B2LSOF7HBS4F35SOHYDG7FOLSQ4DPKAS7YU3JB7IN2U2WOR -compliance CAFIN446IPMTL7E2RMHSSDFS5BRYKU6BJS7L7DBIDHLUDGPWEHVOSE2D -identity_verifier CCNJIIWUXN76GHMGNJW4WIDWYOLBGL6JPUT2J5FU2F6T74VUYNT6M7PW -share_token CCXMRDX67EUA563K3Z7BYJU5HM2PACTYQRHCGI7AXKZZDKEVZNPBYMDT -nav_oracle CDNW5MROHAB2ULG3ODWEVSMSVBTOAHDATES74HYAEZIPQCJB4HUB7KXD -async_vault CC2GSF7NZYFSUXX6UP7FFSIDXCKJV7SLN2HS7SD4XO42WHPXHQW3SJQM +asset contract CCW7OKVOXBBBX3YK6O7F6TZPNIFG32OG4UY7R2K6NSWZUI4R75QCEPVT +compliance CDPCD2BQQKVQBQFAFI4YBLN3TRX34J5NZUQQMUBGHPYNY64LCEM63DTL +identity_verifier CC4SRUIMKOBZSEO2GKGVQMDWPWCBJBBIMYYE7BBCUEZE6VMD3SFQDAMW +share_token CDC7SRTDZIMJSLC5AONUD3FIFYZGMFOHRAS7LLYV2ATJRT7RMTMQO2DG +nav_oracle CCUUBP3RGOIZZL4DUGRL4FBOSQJZKBPNQ7VUWIN6UOOYBJYYKGNT5E52 +async_vault CB2KVM2WHLXDZRHGCYPTGE7PZM6OORXCNVBUMC3L7GY5KX4BA6NMADXE clients generated vault holds share_token manager role share_token bound to the vault, setup finished @@ -126,7 +126,7 @@ share_token bound to compliance compliance bound to the vault, setup finished nav_oracle bound to the vault, setup finished vault setup finished -health {"allowlisted":true,"compliance_bound":true,"feed_valid":false,"freshness_covers_notice":true,"oracle_bound":true,"setup_open":false,"sole_token_manager":true,"token_bound":true,"vault_unfrozen":true} +health {"addresses_distinct":true,"allowlisted":true,"compliance_bound":true,"custodian_set":true,"decimals_match":true,"feed_answers":true,"feed_valid":false,"freshness_covers_notice":true,"oracle_bound":true,"setup_open":false,"sole_token_manager":true,"token_bound":true,"vault_unfrozen":true} wrote deployed.local.json ``` diff --git a/docs/AUTHORIZATION_MATRIX.md b/docs/AUTHORIZATION_MATRIX.md index 877f5bfa..f4abf5c1 100644 --- a/docs/AUTHORIZATION_MATRIX.md +++ b/docs/AUTHORIZATION_MATRIX.md @@ -43,7 +43,7 @@ In `scripts/harness/deploy.ts` and automated deployment scripts, the separation - It then calls `token.bind_vault({ vault })` and `token.finish_setup()`, each signed by 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). - It calls `compliance.bind_token({ token, operator: governance })`, then `compliance.bind_vault({ vault })` and `compliance.finish_setup()`, and stops if `compliance.setup_open()` still reads true. From then on the compliance contract's module, token-binding and admin changes wait for the vault's timelock (§5.5). - It calls `oracle.bind_vault({ vault })` and `oracle.finish_setup()`, 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). -- Last, it calls `vault.set_custodian({ custodian })` and `vault.finish_setup()`, and stops if `vault.setup_open()` still reads true. `finish_setup` refuses with `SetupIncomplete` unless the token and the oracle report this vault with their setup closed, the vault is the token's only `manager`, the token has no shares outstanding, the vault passes the identity check, its address is not frozen on the token and the token is bound to its compliance contract. A compliance contract that answers `vault()` and `setup_open()` must report this vault with its setup closed; one that does not answer them is an external module and is accepted. From then on the vault takes deposits and its admin changes wait for its timelock (§5.1). +- Last, it calls `vault.set_custodian({ custodian })` and `vault.finish_setup()`, and stops if `vault.setup_open()` still reads true. `finish_setup` refuses with `SetupIncomplete` unless the token and the oracle report this vault with their setup closed, the vault is the token's only `manager`, the token has no shares outstanding, the vault passes the identity check, its address is not frozen on the token and the token is bound to its compliance contract. It also needs the asset to have the token's decimals, four different addresses for the asset, the token, the oracle and the vault, an oracle that answers as a feed, and a custodian other than the vault. A compliance contract that answers `vault()` and `setup_open()` must report this vault with its setup closed; one that does not answer them is an external module and is accepted. The script then reads `vault.health()` and stops if any wiring field is false. From then on the vault takes deposits and its admin changes wait for its timelock (§5.1). --- diff --git a/docs/TESTING.md b/docs/TESTING.md index 00669be6..68070a7c 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -132,7 +132,8 @@ fixture wires them as the deploy does and finishes the vault's setup; | 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 returns escrowed shares as the token's `manager` after checking the receiver itself, including the compliance contract's `can_transfer`; neither its own allowlist place nor the token pause stops a return | [`contracts/async-vault/src/test/share_returns.rs`](../contracts/async-vault/src/test/share_returns.rs) | `a_delisted_vault_still_returns_escrowed_shares`, `a_token_pause_stops_trading_but_not_a_share_return`, `a_return_the_compliance_rules_refuse_stays_escrowed` | | 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` | -| The vault finishes setup only once the token and the oracle report it with their own setup closed, it is the token's only `manager`, the token has no shares outstanding, it passes the identity check, its address is not frozen and the token is bound to compliance, which runs on this vault's clock with its setup closed unless it is an external module that does not answer `VaultBinding`; before that it takes no deposit | [`contracts/async-vault/src/test/setup.rs`](../contracts/async-vault/src/test/setup.rs) | `every_wiring_step_is_checked_before_setup_finishes`, `a_compliance_contract_from_outside_the_kit_is_accepted`, `a_deposit_before_setup_finishes_is_refused`, `setup_cannot_finish_while_a_hand_over_is_pending` | +| The vault finishes setup only once the token and the oracle report it with their own setup closed, it is the token's only `manager`, the token has no shares outstanding, it passes the identity check, its address is not frozen, the token is bound to compliance (which runs on this vault's clock with its setup closed unless it is an external module that does not answer `VaultBinding`), the asset has the token's decimals, the asset, token, oracle and vault have four different addresses, the oracle answers as a feed, and a custodian other than the vault is set. `health()` reports each check, and before setup finishes the vault takes no deposit | [`contracts/async-vault/src/test/setup.rs`](../contracts/async-vault/src/test/setup.rs), [`contracts/async-vault/src/test/health.rs`](../contracts/async-vault/src/test/health.rs) | `every_wiring_step_is_checked_before_setup_finishes`, `each_missing_wiring_step_shows_in_its_own_flag`, `a_token_that_cannot_answer_fails_its_checks_instead_of_trapping`, `a_compliance_contract_from_outside_the_kit_is_accepted`, `a_deposit_before_setup_finishes_is_refused`, `setup_cannot_finish_while_a_hand_over_is_pending` | +| The vault is never its own custodian: the direct setter, during setup and after, and a `Custodian` change, when proposed and when applied, refuse it with `InvalidCustodian` | [`contracts/async-vault/src/test/treasury.rs`](../contracts/async-vault/src/test/treasury.rs), [`contracts/async-vault/src/test/governance.rs`](../contracts/async-vault/src/test/governance.rs), [`contracts/async-vault/src/test/guards.rs`](../contracts/async-vault/src/test/guards.rs) | `setting_the_vault_as_its_own_custodian_is_refused`, `a_custodian_change_to_the_vault_is_refused_when_proposed_and_when_applied`, `custodian_not_the_vault_refuses_the_vault_itself` | | 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; 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 b15c9e13..a685abd6 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -85,7 +85,7 @@ The contracts enforce a set of mathematical and state-machine invariants across - **Testable Property**: - Upgrade delay $\in [7 \text{ days}, 90 \text{ days}]$ and $\text{delay} \ge \text{notice}$, checked when a delay change is proposed and again when it applies. - Wind-down delay $\in [7 \text{ days}, 90 \text{ days}]$ (`WindDownDelayTooShort`, `WindDownDelayTooLong`); 7 days when none was set. - - The vault takes no deposit until its one-time setup closes (`SetupOpen`). `finish_setup` refuses with `SetupIncomplete` until the share token and the oracle report this vault with their own setup closed, the vault is the token's only `manager`, the token has no shares outstanding, the vault passes the token's identity check, the token has not frozen the vault's address, and the token is bound to its compliance contract. A compliance contract that answers `vault()` and `setup_open()` must report this vault with its own setup closed; one that does not answer them is an external module and is accepted. Shares minted before setup closes would redeem against depositors' money. It refuses with `AdminTransferPending` while an admin hand-over offered during setup is pending. Setup never reopens (`SetupClosed`). + - The vault takes no deposit until its one-time setup closes (`SetupOpen`). `finish_setup` refuses with `SetupIncomplete` until the share token and the oracle report this vault with their own setup closed, the vault is the token's only `manager`, the token has no shares outstanding, the vault passes the token's identity check, the token has not frozen the vault's address, and the token is bound to its compliance contract. It also needs the asset to have the token's decimals, four different addresses for the asset, the token, the oracle and the vault, an oracle that answers `state()` and `freshness_duration()`, and a custodian other than the vault. A compliance contract that answers `vault()` and `setup_open()` must report this vault with its own setup closed; one that does not answer them is an external module and is accepted. Shares minted before setup closes would redeem against depositors' money. It refuses with `AdminTransferPending` while an admin hand-over offered during setup is pending. Setup never reopens (`SetupClosed`). - After setup, the vault's custodian, role grants, revoking `manager`, admin hand-overs and lowering the notice or the wind-down delay go through the queue; a direct call is refused with `UseTimelock`. Raising the notice or the wind-down delay, the deposit cap, `pause`/`unpause` and revoking any other role stay instant. A `Notice` or `WindDownDelay` change is checked against its bounds when proposed and again when it applies. - Every change waits in the queue (`propose_change` → `apply_change`). Applying before its eta reverts with `ChangeNotReady`. - A change whose delay is shorter than the notice standing at apply time reverts with `UpgradeDelayBelowNotice`. @@ -157,7 +157,7 @@ The `async_vault` constructor strictly verifies pairwise separation across autho | Parameter | Method | Authority | Constraints & Defaults | |---|---|---|---| | **Standing Notice** | `set_notice(secs: u64)`; after setup, lowering goes through `propose_change(Notice(secs))` → `apply_change(id)` | Governance | Notice wait before pricing. Default: `0`. Must satisfy $\text{secs} \le \text{upgrade\_delay}$ and $\text{secs} \le 30 \text{ days}$ (`MAX_NOTICE_SECS`), checked again when a `Notice` change applies. Raising is instant. | -| **Custodian Address** | `set_custodian(custodian: Address)` during setup; afterwards `propose_change(Custodian(address))` → `apply_change(id)` | Governance | Recipient address for off-chain capital deployment. Required before `deploy_to_custodian`. The deploy sets it before setup closes. | +| **Custodian Address** | `set_custodian(custodian: Address)` during setup; afterwards `propose_change(Custodian(address))` → `apply_change(id)` | Governance | Recipient address for off-chain capital deployment. Required before `finish_setup` and `deploy_to_custodian`. Never the vault itself (`InvalidCustodian`), checked when it is set, proposed and applied. The deploy sets it before setup closes. | | **Deposit Cap** | `set_deposit_cap(cap: Option)` | Governance | Maximum capacity for deposited capital. `None` = unbounded. Refuses negative cap. Instant both ways, also after setup. | | **Wind-Down Delay** | `set_wind_down_delay(secs: u64)`; after setup, lowering goes through `propose_change(WindDownDelay(secs))` → `apply_change(id)` | Governance | Minimum notice before wind-down activation. Default and minimum 7 days (`MIN_WIND_DOWN_DELAY`), maximum 90 days (`MAX_WIND_DOWN_DELAY`). Raising is instant. | | **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}$. | @@ -166,7 +166,7 @@ The `async_vault` constructor strictly verifies pairwise separation across autho ### Health View -`health()` returns one flag per check. A deployment is ready when `setup_open` is false and every other flag is true. `health()` leaves out two `finish_setup` checks: that the share token has no shares outstanding, which stops being true once the vault has holders, and that no admin hand-over offered during setup is pending (`AdminTransferPending`). If `finish_setup` refuses with `SetupIncomplete` while `token_bound`, `oracle_bound`, `sole_token_manager`, `allowlisted`, `vault_unfrozen` and `compliance_bound` are all true, shares were minted before setup; the token's `total_supply()` shows them. +`health()` returns one flag per check. A deployment is ready when `setup_open` is false and every other flag is true. `health()` leaves out two `finish_setup` checks: that the share token has no shares outstanding, which stops being true once the vault has holders, and that no admin hand-over offered during setup is pending (`AdminTransferPending`). If `finish_setup` refuses with `SetupIncomplete` while every field from `token_bound` to `feed_answers` is true, shares were minted before setup; the token's `total_supply()` shows them. | Field | True when | |---|---| @@ -177,6 +177,10 @@ The `async_vault` constructor strictly verifies pairwise separation across autho | `allowlisted` | This vault passes the share token's identity verifier. Escrowing and returning shares do not need it. Only a delisted holder's conversion does: it mints the shares into the vault's own escrow. While this flag is false, those conversions wait until the vault is allowlisted again, and nothing is lost. | | `vault_unfrozen` | The share token has not frozen this vault's address. The vault's own share moves ignore an address freeze, so freezing the vault stops nothing; `finish_setup` still refuses a frozen vault. | | `compliance_bound` | The share token is in its compliance contract's `linked_tokens`, and a compliance contract that answers `vault()` and `setup_open()` names this vault with its own setup finished. One that does not answer them is an external module and passes; its own governance is a trust assumption. | +| `decimals_match` | The asset's `decimals()` equals the share token's. Pricing converts base units at the attested price, so a mismatch would put every deposit and exit off by a power of ten. | +| `addresses_distinct` | The asset, the share token, the oracle and the vault are four different addresses. | +| `custodian_set` | A custodian is set, and it is not the vault itself. | +| `feed_answers` | The oracle answers `state()` and `freshness_duration()`. A new feed with no report passes. `oracle_bound` cannot tell a feed from another contract bound to this vault, such as the compliance contract. | | `feed_valid` | The oracle reports `Valid`: a report exists, it has not expired and the NAV is not paused. | | `freshness_covers_notice` | The oracle's `freshness_duration` is at least the vault's notice. | @@ -217,7 +221,7 @@ Strata distributes control across five native Stellar multisig authorities and p | **`AsyncVault`** | `pause` | Guardian | `#[only_role(caller, "guardian")]` | N/A | | **`AsyncVault`** | `unpause` | Governance | `caller` must be the admin (`AdminRequired`), then `caller.require_auth()` | N/A | | **`AsyncVault`** | `set_notice` | Governance | `#[only_admin]`; raising is instant, lowering is instant during setup, then `propose_change` | No | -| **`AsyncVault`** | `set_custodian` | Governance | `#[only_admin]`; instant during setup, then `propose_change` | No | +| **`AsyncVault`** | `set_custodian` | Governance | `#[only_admin]`; instant during setup, then `propose_change`; never the vault itself (`InvalidCustodian`) | No | | **`AsyncVault`** | `set_deposit_cap` | Governance | `#[only_admin]`; instant both ways | No | | **`AsyncVault`** | `set_wind_down_delay` | Governance | `#[only_admin]`; raising is instant, lowering is instant during setup, then `propose_change`; never below 7 days | No | | **`AsyncVault`** | `finish_setup` | Governance | `#[only_admin]`; needs the wiring complete and no pending admin hand-over; once | No | @@ -295,8 +299,8 @@ A clean deployment executes in three sequenced phases: - Call `IdentityVerifier::allow(vault_address, true)`, signed by the compliance key, so a delisted holder's priced deposit can be minted into the vault's escrow; `finish_setup` requires it. - Call `Compliance::bind_token(share_token_address, governance)` to link SEP-57 rule verification, then `Compliance::bind_vault(vault_address)` and `Compliance::finish_setup()`. Its module and binding changes are instant until then and wait for the vault's timelock afterwards. - 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. - - On the vault, call `set_custodian(custodian)`, `set_notice(secs)` and, if initial capital intake is bounded, `set_deposit_cap(cap)`. All three are instant during setup, so deposits never open without the cap. - - Then call `finish_setup()` on the vault. It refuses with `SetupIncomplete` until every wiring step above is in place, the token has no shares outstanding and the vault's address is not frozen on it, and with `AdminTransferPending` while an admin hand-over offered during setup is pending. The vault takes no deposit before it. + - On the vault, call `set_custodian(custodian)`, `set_notice(secs)` and, if initial capital intake is bounded, `set_deposit_cap(cap)`. All three are instant during setup, so deposits never open without the cap. The custodian is required, and the vault's own address is refused (`InvalidCustodian`). + - Then call `finish_setup()` on the vault. It refuses with `SetupIncomplete` until every wiring step above is in place, the token has no shares outstanding and the vault's address is not frozen on it. It also needs the asset to have the token's decimals, four different addresses for the asset, the token, the oracle and the vault, and an oracle that answers `state()` and `freshness_duration()`. It refuses with `AdminTransferPending` while an admin hand-over offered during setup is pending. `health()` shows which check is false. The vault takes no deposit before it. 3. **Phase 3: Operational Parameterization** - Oracle reporter submits the genesis NAV attestation (`attest`). @@ -376,7 +380,7 @@ implementation against what the vault calls. | Interface | Defined in | Implemented here by | What the vault calls | |---|---|---|---| | `OracleFeed` | `crates/strata-bindings` | `nav-oracle` | `state`, `nav_per_share`, `attested_at`, `freshness_duration` | -| `FungibleToken` | OpenZeppelin `stellar-tokens` 0.7.2 | `share-token` | `transfer`, `balance`, `total_supply` | +| `FungibleToken` | OpenZeppelin `stellar-tokens` 0.7.2 | `share-token` | `transfer`, `balance`, `total_supply`, `decimals` | | `RWAToken` | OpenZeppelin `stellar-tokens` 0.7.2 | `share-token` | `mint`, `burn`, `forced_transfer` | | `IdentityVerifier` | OpenZeppelin `stellar-tokens` 0.7.2 | `identity-verifier` (testnet stub) | `verify_identity`, called by the share token | @@ -396,8 +400,9 @@ The vault's `finish_setup` reads the token and the oracle through closed. It reads the compliance contract the same way, but accepts one that does not answer `VaultBinding`: such an external module keeps its own governance, which is a trust assumption. It also reads the token's -`manager` members (OpenZeppelin `AccessControl`) and its `total_supply` -(`FungibleToken`), runs `verify_identity` on its own address, and reads -`linked_tokens` from the token's compliance contract (OpenZeppelin -`TokenBinder`). A replacement for any of these must answer those calls, or the -vault's setup cannot finish. +`manager` members (OpenZeppelin `AccessControl`), its `total_supply` and +`decimals` (`FungibleToken`) and the asset's `decimals`, runs +`verify_identity` on its own address, reads `linked_tokens` from the token's +compliance contract (OpenZeppelin `TokenBinder`), and calls the oracle's +`state` and `freshness_duration` (`OracleFeed`). A replacement for any of these +must answer those calls, or the vault's setup cannot finish. From 9f7dc20f70783618fd1a2ebfccd097159074e1c9 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:44:19 -0300 Subject: [PATCH 092/147] fix: refuse an oracle swap to a non-feed An Oracle change checked only that the new address names this vault with its setup closed. Another kit contract bound to the vault, such as the compliance contract, passed at proposal and then trapped at apply on a raw state() call. feed_bound_here now also needs state() and freshness_duration() to answer, so such a swap is refused with FeedNotBound when it is proposed and again when it applies. The setup check and the swap guard share one helper. --- contracts/async-vault/src/error.rs | 6 +++-- contracts/async-vault/src/governance.rs | 18 ++++++------- contracts/async-vault/src/guards.rs | 3 ++- contracts/async-vault/src/test/guards.rs | 15 ++++++++--- contracts/async-vault/src/test/oracle_swap.rs | 25 +++++++++++++++++++ docs/AUTHORIZATION_MATRIX.md | 4 +-- docs/kit-spec.md | 2 +- 7 files changed, 54 insertions(+), 19 deletions(-) diff --git a/contracts/async-vault/src/error.rs b/contracts/async-vault/src/error.rs index 5394f89e..6d8420b4 100644 --- a/contracts/async-vault/src/error.rs +++ b/contracts/async-vault/src/error.rs @@ -142,8 +142,10 @@ pub enum VaultError { /// returned or moved, until compliance lifts the freeze. A partial freeze /// refuses only what would touch frozen shares. Frozen = 6071, - /// The proposed feed does not name this vault as its own, or its setup is - /// still open. Its admin could then change its limits with no delay. + /// The proposed feed does not name this vault as its own, its setup is + /// still open, or it does not answer `state()` and `freshness_duration()`. + /// Its admin could then change its limits with no delay, or no epoch could + /// be priced from it. FeedNotBound = 6072, /// The holder may not receive shares (delisted, frozen or refused by the /// compliance rules), so the escrowed shares stay with the vault and the diff --git a/contracts/async-vault/src/governance.rs b/contracts/async-vault/src/governance.rs index f5b2a0ca..47909e2e 100644 --- a/contracts/async-vault/src/governance.rs +++ b/contracts/async-vault/src/governance.rs @@ -75,7 +75,6 @@ pub(crate) struct Wiring { pub decimals_match: bool, pub addresses_distinct: bool, pub custodian_set: bool, - /// A new feed has no report yet, so this asks only that the feed answers. pub feed_answers: bool, } @@ -131,6 +130,13 @@ pub(crate) fn bound_here(e: &Env, contract: &Address) -> bool { && matches!(binding.try_setup_open(), Ok(Ok(false))) } +/// True when `feed` answers the views the vault prices from. A new feed has no +/// report yet, so this asks only for answers, not for a valid price. +pub(crate) fn answers_as_feed(e: &Env, feed: &Address) -> bool { + let feed = OracleFeedClient::new(e, feed); + matches!(feed.try_state(), Ok(Ok(_))) && matches!(feed.try_freshness_duration(), Ok(Ok(_))) +} + fn compliance_bound(e: &Env, compliance: &Address, token: &Address) -> bool { let linked = matches!( TokenBinderClient::new(e, compliance).try_linked_tokens(), @@ -160,7 +166,6 @@ pub(crate) fn wiring(e: &Env) -> Wiring { let roles = AccessControlClient::new(e, &token); let rwa = RWATokenClient::new(e, &token); let shares = FungibleTokenClient::new(e, &token); - let feed = OracleFeedClient::new(e, &oracle); Wiring { token_bound: bound_here(e, &token), oracle_bound: bound_here(e, &oracle), @@ -191,8 +196,7 @@ pub(crate) fn wiring(e: &Env) -> Wiring { addresses_distinct: all_different(&[&asset, &token, &oracle, &me]), custodian_set: state::get_addr_opt(e, &DataKey::Custodian) .is_some_and(|custodian| custodian != me), - feed_answers: matches!(feed.try_state(), Ok(Ok(_))) - && matches!(feed.try_freshness_duration(), Ok(Ok(_))), + feed_answers: answers_as_feed(e, &oracle), } } @@ -249,11 +253,7 @@ pub(crate) fn apply(e: &Env, id: u32) { access_control::revoke_role_no_auth(e, account, role, &admin) } VaultChange::Admin(next) => offer_admin(e, &admin, next), - VaultChange::Oracle(feed) => { - // A feed that cannot answer would stall every epoch after the swap. - OracleFeedClient::new(e, feed).state(); - state::set_addr(e, &DataKey::Oracle, feed); - } + VaultChange::Oracle(feed) => state::set_addr(e, &DataKey::Oracle, feed), } ChangeApplied { id, change }.publish(e); } diff --git a/contracts/async-vault/src/guards.rs b/contracts/async-vault/src/guards.rs index e072de91..2eab6ec7 100644 --- a/contracts/async-vault/src/guards.rs +++ b/contracts/async-vault/src/guards.rs @@ -154,8 +154,9 @@ pub(crate) fn custodian_not_the_vault(e: &Env, custodian: &Address) { } } +/// A feed that cannot answer would stall every epoch after the swap. pub(crate) fn feed_bound_here(e: &Env, feed: &Address) { - if !governance::bound_here(e, feed) { + if !governance::bound_here(e, feed) || !governance::answers_as_feed(e, feed) { panic_with_error!(e, VaultError::FeedNotBound); } } diff --git a/contracts/async-vault/src/test/guards.rs b/contracts/async-vault/src/test/guards.rs index 40220177..f3b8226d 100644 --- a/contracts/async-vault/src/test/guards.rs +++ b/contracts/async-vault/src/test/guards.rs @@ -281,7 +281,7 @@ fn custodian_not_the_vault_refuses_the_vault_itself() { } #[test] -fn feed_bound_here_refuses_a_feed_not_bound_to_this_vault() { +fn feed_bound_here_refuses_an_unbound_feed_or_a_contract_that_is_not_a_feed() { let f = setup(); holds(&f, || guards::feed_bound_here(&f.e, &f.oracle.address)); @@ -294,9 +294,12 @@ fn feed_bound_here_refuses_a_feed_not_bound_to_this_vault() { open_feed(), ), ); - refuses(&f, VaultError::FeedNotBound, || { - guards::feed_bound_here(&f.e, &unbound) - }); + // The compliance contract is bound to this vault but answers no feed view. + for not_a_feed_here in [unbound, f.share.compliance()] { + refuses(&f, VaultError::FeedNotBound, || { + guards::feed_bound_here(&f.e, ¬_a_feed_here) + }); + } } #[test] @@ -334,6 +337,10 @@ fn change_in_bounds_checks_the_bounds_of_each_change() { VaultChange::Oracle(stranger.clone()), VaultError::FeedNotBound, ), + ( + VaultChange::Oracle(f.share.compliance()), + VaultError::FeedNotBound, + ), ( VaultChange::Custodian(f.vault.address.clone()), VaultError::InvalidCustodian, diff --git a/contracts/async-vault/src/test/oracle_swap.rs b/contracts/async-vault/src/test/oracle_swap.rs index 2748ea0a..365fbdcd 100644 --- a/contracts/async-vault/src/test/oracle_swap.rs +++ b/contracts/async-vault/src/test/oracle_swap.rs @@ -15,6 +15,10 @@ impl FeedStub { pub fn state(_e: Env) -> strata_bindings::OracleState { strata_bindings::OracleState::Valid } + + pub fn freshness_duration(_e: Env) -> u64 { + 3_600 + } } #[contractimpl(contracttrait)] @@ -158,3 +162,24 @@ fn a_feed_that_stops_naming_this_vault_is_never_installed() { refused(f.vault.try_apply_change(&id), VaultError::FeedNotBound); assert_eq!(f.vault.oracle(), f.oracle.address); } + +#[test] +fn a_contract_that_is_not_a_feed_is_refused_when_proposed_and_when_applied() { + let f = setup(); + // Bound to this vault with its setup closed, but it answers no feed view. + let change = VaultChange::Oracle(f.share.compliance()); + + refused( + f.vault.try_propose_change(&change), + VaultError::FeedNotBound, + ); + + // Skips the proposal's check, as a change queued by an earlier version would. + let id = f.e.as_contract(&f.vault.address, || { + strata_timelock::queue::propose::(&f.e, &change).unwrap() + }); + f.advance(MIN_UPGRADE_DELAY); + + refused(f.vault.try_apply_change(&id), VaultError::FeedNotBound); + assert_eq!(f.vault.oracle(), f.oracle.address); +} diff --git a/docs/AUTHORIZATION_MATRIX.md b/docs/AUTHORIZATION_MATRIX.md index f4abf5c1..f5d2f3ba 100644 --- a/docs/AUTHORIZATION_MATRIX.md +++ b/docs/AUTHORIZATION_MATRIX.md @@ -106,8 +106,8 @@ Tests: [`controls.rs`](../contracts/async-vault/src/test/controls.rs), [`epochs. | `propose_change` | `#[only_admin]` | Not winding down, setup finished, one code change at a time, an `UpgradeDelay` in `[MIN, MAX]` and `>= notice`, a `Notice` within its bounds, a `WindDownDelay` in `[MIN_WIND_DOWN_DELAY, MAX_WIND_DOWN_DELAY]` | `test::upgrade::only_governance_proposes_and_cancels`, `test::upgrade::nothing_is_proposed_during_a_wind_down`, `test::setup::nothing_is_proposed_during_setup` | | `cancel_change` | `#[only_admin]` | The change must be pending | `test::upgrade::only_governance_proposes_and_cancels` | | `apply_change` | `#[only_admin]` | Eta passed, not paused, not winding down, delay `>= notice`; an `UpgradeDelay`, `Notice` or `WindDownDelay` change is checked against its bounds again | `test::upgrade::only_governance_applies`, `test::governance::a_change_shorter_than_a_raised_notice_never_lands`, `test::governance::nothing_timelocked_moves_during_a_wind_down_a_manager_revoke_included` | -| `propose_change` (`Oracle(address)`) | `#[only_admin]` | The feed names this vault and its setup is closed | `test::oracle_swap::a_feed_outside_this_vault_is_never_proposed` | -| `apply_change` (`Oracle(address)`) | `#[only_admin]` | Delay run; the feed still names this vault with its setup closed, and answers `state()` | `test::oracle_swap::the_oracle_changes_only_through_the_timelock`, `test::oracle_swap::a_feed_that_stops_naming_this_vault_is_never_installed` | +| `propose_change` (`Oracle(address)`) | `#[only_admin]` | The feed names this vault, its setup is closed, and it answers `state()` and `freshness_duration()` (`FeedNotBound`) | `test::oracle_swap::a_feed_outside_this_vault_is_never_proposed`, `test::oracle_swap::a_contract_that_is_not_a_feed_is_refused_when_proposed_and_when_applied` | +| `apply_change` (`Oracle(address)`) | `#[only_admin]` | Delay run; the feed still names this vault with its setup closed, and still answers `state()` and `freshness_duration()` (`FeedNotBound`) | `test::oracle_swap::the_oracle_changes_only_through_the_timelock`, `test::oracle_swap::a_feed_that_stops_naming_this_vault_is_never_installed`, `test::oracle_swap::a_contract_that_is_not_a_feed_is_refused_when_proposed_and_when_applied` | | `pending_changes` | Open view | None | None (view) | | `renounce_admin` | Refused always (`VaultError::AdminRequired`) | None | `test::controls::governance_cannot_renounce_itself_out_of_the_vault`; `test::controls::governance_hands_over_in_two_steps_instead`; `test::governance::an_applied_admin_change_starts_a_hand_over_the_next_admin_accepts`. Hand over by proposing and applying an `Admin` change (`transfer_admin_role` during setup), then the nominee calls `accept_admin_transfer`. | diff --git a/docs/kit-spec.md b/docs/kit-spec.md index a685abd6..c6a45e72 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -162,7 +162,7 @@ The `async_vault` constructor strictly verifies pairwise separation across autho | **Wind-Down Delay** | `set_wind_down_delay(secs: u64)`; after setup, lowering goes through `propose_change(WindDownDelay(secs))` → `apply_change(id)` | Governance | Minimum notice before wind-down activation. Default and minimum 7 days (`MIN_WIND_DOWN_DELAY`), maximum 90 days (`MAX_WIND_DOWN_DELAY`). Raising is instant. | | **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. Applying a `Config` change sets every field, freshness included; to keep a cut made after the proposal, cancel the queued change and propose again. | -| **Oracle Feed** | `propose_change(Oracle(address))` → `apply_change(id)` | Governance | Waits for the timelock. The new feed must name this vault through `vault()` with its own setup finished, at proposal and again at apply, and must answer `state()`. The next fulfilment prices from it, including an epoch sealed before the swap. | +| **Oracle Feed** | `propose_change(Oracle(address))` → `apply_change(id)` | Governance | Waits for the timelock. The new feed must name this vault through `vault()` with its own setup finished, and must answer `state()` and `freshness_duration()`, at proposal and again at apply (`FeedNotBound`). The next fulfilment prices from it, including an epoch sealed before the swap. | ### Health View From 8138a343b48fd2e860ecac6fd199e7e9f85dbab1 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:45:11 -0300 Subject: [PATCH 093/147] docs: list the new setup checks in every table The design doc's governance row lists the conditions setup closes on, and the authorization matrix lists each vault entrypoint's guards. Both now include matching decimals, four distinct addresses, an oracle that answers the feed interface and a custodian other than the vault. The matrix rows for set_custodian, propose_change and apply_change name InvalidCustodian and its tests. --- docs/AUTHORIZATION_MATRIX.md | 8 ++++---- docs/strata-product-and-architecture.md | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/AUTHORIZATION_MATRIX.md b/docs/AUTHORIZATION_MATRIX.md index f5d2f3ba..b1174021 100644 --- a/docs/AUTHORIZATION_MATRIX.md +++ b/docs/AUTHORIZATION_MATRIX.md @@ -91,11 +91,11 @@ Tests: [`controls.rs`](../contracts/async-vault/src/test/controls.rs), [`epochs. | `deploy_to_custodian` | `#[only_role(caller, "treasury")]` | `assets <= free_reserve`, not winding down | `test::treasury::deploying_needs_the_treasury_role` | | `pause` | `#[only_role(caller, "guardian")]` | Contract not already paused | `test::controls::only_the_guardian_pauses` | | `unpause` | `caller` must be the stored admin (`AdminRequired`), then `caller.require_auth()` | Contract must be paused | `test::controls::the_guardian_pauses_but_only_governance_unpauses`, `test::controls::a_caller_other_than_the_admin_cannot_unpause_even_when_it_signs` | -| `set_custodian` | `#[only_admin]` | Instant during setup; `UseTimelock` after | `test::governance::the_custodian_changes_through_the_timelock`, `test::treasury::setting_the_custodian_needs_the_admin_signature` | +| `set_custodian` | `#[only_admin]` | Never the vault itself (`InvalidCustodian`, checked first). Instant during setup; `UseTimelock` after | `test::governance::the_custodian_changes_through_the_timelock`, `test::treasury::setting_the_custodian_needs_the_admin_signature`, `test::treasury::setting_the_vault_as_its_own_custodian_is_refused` | | `set_notice` | `#[only_admin]` | `secs <= MAX_NOTICE_SECS`, `secs <= upgrade_delay`. Raising is instant; lowering is `UseTimelock` after setup | `test::governance::raising_the_notice_is_instant_and_lowering_waits`, `test::notice::setting_the_notice_needs_governance_authorisation` | | `set_wind_down_delay` | `#[only_admin]` | `MIN_WIND_DOWN_DELAY <= secs <= MAX_WIND_DOWN_DELAY`. Raising is instant; lowering is `UseTimelock` after setup | `test::governance::raising_the_wind_down_delay_is_instant_and_lowering_waits`, `test::governance::a_wind_down_delay_below_the_minimum_cannot_be_proposed`, `test::wind_down::only_governance_sets_the_delay` | | `set_deposit_cap` | `#[only_admin]` | Not negative. Instant both ways, also after setup | `test::governance::the_deposit_cap_stays_instant_both_ways` | -| `finish_setup` | `#[only_admin]` | Once. The token and the oracle report this vault with their setup closed, the vault is the token's only `manager`, the token's `total_supply` is zero, the vault passes `verify_identity`, the token's `is_frozen` is false for the vault, and the token is in its compliance contract's `linked_tokens`; a compliance contract that answers `vault()` and `setup_open()` reports this vault with its setup closed (one that does not answer them is an external module and is accepted); no admin hand-over offered during setup is pending | `test::setup::every_wiring_step_is_checked_before_setup_finishes`, `test::setup::a_compliance_contract_from_outside_the_kit_is_accepted`, `test::setup::setup_cannot_finish_while_a_hand_over_is_pending`, `test::setup::setup_never_finishes_twice`, `test::setup::only_governance_finishes_setup` | +| `finish_setup` | `#[only_admin]` | Once. The token and the oracle report this vault with their setup closed, the vault is the token's only `manager`, the token's `total_supply` is zero, the vault passes `verify_identity`, the token's `is_frozen` is false for the vault, and the token is in its compliance contract's `linked_tokens`; a compliance contract that answers `vault()` and `setup_open()` reports this vault with its setup closed (one that does not answer them is an external module and is accepted); the asset's `decimals` equal the token's; the asset, the token, the oracle and the vault are four different addresses; the oracle answers `state()` and `freshness_duration()`; a custodian other than the vault is set; no admin hand-over offered during setup is pending | `test::setup::every_wiring_step_is_checked_before_setup_finishes`, `test::setup::a_compliance_contract_from_outside_the_kit_is_accepted`, `test::setup::setup_cannot_finish_while_a_hand_over_is_pending`, `test::setup::setup_never_finishes_twice`, `test::setup::only_governance_finishes_setup` | | `grant_role` / `transfer_admin_role` | Admin | Instant during setup, `UseTimelock` after. A zero `live_until_ledger` cancels instantly | `test::governance::grants_and_manager_revokes_wait_for_the_timelock`, `test::governance::an_admin_hand_over_waits_but_cancelling_one_does_not` | | `accept_admin_transfer` | The pending admin's auth | A hand-over is pending; an applied `Admin` change stays open for seven days of ledgers | `test::governance::an_applied_admin_change_starts_a_hand_over_the_next_admin_accepts`, `test::governance::the_hand_over_offer_lasts_seven_days_of_ledgers`, `test::governance::an_admin_hand_over_waits_but_cancelling_one_does_not`, `test::setup::setup_cannot_finish_while_a_hand_over_is_pending` | | `revoke_role` | Admin | `treasury`, `guardian`: instant. `manager`: instant during setup, `UseTimelock` after | `test::governance::grants_and_manager_revokes_wait_for_the_timelock` (instant case: `test::governance::revoking_treasury_or_guardian_stays_instant`; caller check: `test::governance::only_the_admin_revokes_a_role`) | @@ -103,9 +103,9 @@ Tests: [`controls.rs`](../contracts/async-vault/src/test/controls.rs), [`epochs. | `set_role_admin` | — | Always refused (`UseTimelock`) | `test::governance::no_role_ever_gets_a_role_admin` | | `propose_wind_down` | `#[only_admin]` | No proposal standing, not active | `test::wind_down::only_governance_proposes` | | `cancel_wind_down_proposal` | `#[only_admin]` | Must be proposed, not active | `test::wind_down::governance_cancels_a_proposal_before_activation` | -| `propose_change` | `#[only_admin]` | Not winding down, setup finished, one code change at a time, an `UpgradeDelay` in `[MIN, MAX]` and `>= notice`, a `Notice` within its bounds, a `WindDownDelay` in `[MIN_WIND_DOWN_DELAY, MAX_WIND_DOWN_DELAY]` | `test::upgrade::only_governance_proposes_and_cancels`, `test::upgrade::nothing_is_proposed_during_a_wind_down`, `test::setup::nothing_is_proposed_during_setup` | +| `propose_change` | `#[only_admin]` | Not winding down, setup finished, one code change at a time, an `UpgradeDelay` in `[MIN, MAX]` and `>= notice`, a `Notice` within its bounds, a `WindDownDelay` in `[MIN_WIND_DOWN_DELAY, MAX_WIND_DOWN_DELAY]`, a `Custodian` that is not the vault (`InvalidCustodian`) | `test::upgrade::only_governance_proposes_and_cancels`, `test::upgrade::nothing_is_proposed_during_a_wind_down`, `test::setup::nothing_is_proposed_during_setup`, `test::governance::a_custodian_change_to_the_vault_is_refused_when_proposed_and_when_applied` | | `cancel_change` | `#[only_admin]` | The change must be pending | `test::upgrade::only_governance_proposes_and_cancels` | -| `apply_change` | `#[only_admin]` | Eta passed, not paused, not winding down, delay `>= notice`; an `UpgradeDelay`, `Notice` or `WindDownDelay` change is checked against its bounds again | `test::upgrade::only_governance_applies`, `test::governance::a_change_shorter_than_a_raised_notice_never_lands`, `test::governance::nothing_timelocked_moves_during_a_wind_down_a_manager_revoke_included` | +| `apply_change` | `#[only_admin]` | Eta passed, not paused, not winding down, delay `>= notice`; an `UpgradeDelay`, `Notice`, `WindDownDelay` or `Custodian` change is checked against its bounds again | `test::upgrade::only_governance_applies`, `test::governance::a_change_shorter_than_a_raised_notice_never_lands`, `test::governance::nothing_timelocked_moves_during_a_wind_down_a_manager_revoke_included`, `test::governance::a_custodian_change_to_the_vault_is_refused_when_proposed_and_when_applied` | | `propose_change` (`Oracle(address)`) | `#[only_admin]` | The feed names this vault, its setup is closed, and it answers `state()` and `freshness_duration()` (`FeedNotBound`) | `test::oracle_swap::a_feed_outside_this_vault_is_never_proposed`, `test::oracle_swap::a_contract_that_is_not_a_feed_is_refused_when_proposed_and_when_applied` | | `apply_change` (`Oracle(address)`) | `#[only_admin]` | Delay run; the feed still names this vault with its setup closed, and still answers `state()` and `freshness_duration()` (`FeedNotBound`) | `test::oracle_swap::the_oracle_changes_only_through_the_timelock`, `test::oracle_swap::a_feed_that_stops_naming_this_vault_is_never_installed`, `test::oracle_swap::a_contract_that_is_not_a_feed_is_refused_when_proposed_and_when_applied` | | `pending_changes` | Open view | None | None (view) | diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index abcbc3cb..6fabcb42 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -606,7 +606,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 vault's own custodian, role grants, `manager` revocations, admin handovers and lowering of the notice or the wind-down delay wait on that clock once its one-time setup closes, and it takes no deposit before then; revoking its treasury or guardian stays instant. Setup closes only once the token and the oracle run on the vault's clock with their own setup closed, the vault is the token's only minter, no shares exist yet (shares minted earlier would redeem against depositors' money), the vault passes the identity check, its address is not frozen on the token and the token is bound to its compliance contract; the kit's own compliance contract must also run on the vault's clock with its setup closed. 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. On the kit's compliance contract, adding or removing a module, binding or unbinding a token and handing over the admin 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 vault's own custodian, role grants, `manager` revocations, admin handovers and lowering of the notice or the wind-down delay wait on that clock once its one-time setup closes, and it takes no deposit before then; revoking its treasury or guardian stays instant. Setup closes only once the token and the oracle run on the vault's clock with their own setup closed, the vault is the token's only minter, no shares exist yet (shares minted earlier would redeem against depositors' money), the vault passes the identity check, its address is not frozen on the token, the token is bound to its compliance contract, the asset has the token's decimals, the asset, the token, the oracle and the vault are four different addresses, the oracle answers the feed interface, and a custodian other than the vault is set; the kit's own compliance contract must also run on the vault's clock with its setup closed. 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. On the kit's compliance contract, adding or removing a module, binding or unbinding a token and handing over the admin wait on the same clock once its setup closes. | | Mis-wired deployment | A vault goes live without every binding wired | `health()` reports each wiring step; `finish_setup` refuses until all hold, and the vault takes no deposit before that | | 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 | From 381146c4f2a10839ac21f75930b1400b7ebf9883 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 01:56:04 -0300 Subject: [PATCH 094/147] fix: extend new share balances in the same call OpenZeppelin extends a balance or freeze entry when it reads it, not when it creates it, so a new one started at the network's minimum lifetime and the next caller paid to extend it. On testnet that minimum is about seven days, and a transaction simulated before the entry existed then failed for too low a fee; the first redemption, which creates the vault's own share balance, hit this. Every token call that writes a balance or a freeze entry now reads it back through OpenZeppelin's own reads, so a new entry reaches its extend target in the call that creates it. The tests set the ledger's minimum lifetime to testnet's. --- contracts/async-vault/src/test/ttl.rs | 49 +++++ contracts/share-token/src/contract.rs | 16 +- contracts/share-token/src/extend.rs | 18 ++ contracts/share-token/src/lib.rs | 3 + contracts/share-token/src/test_ttl.rs | 284 ++++++++++++++++++++++++++ 5 files changed, 369 insertions(+), 1 deletion(-) create mode 100644 contracts/share-token/src/extend.rs create mode 100644 contracts/share-token/src/test_ttl.rs diff --git a/contracts/async-vault/src/test/ttl.rs b/contracts/async-vault/src/test/ttl.rs index 9bc2ab65..234774a6 100644 --- a/contracts/async-vault/src/test/ttl.rs +++ b/contracts/async-vault/src/test/ttl.rs @@ -1,4 +1,6 @@ use soroban_sdk::testutils::storage::{Instance as _, Persistent as _}; +use soroban_sdk::{IntoVal, Val}; +use stellar_tokens::fungible::{FungibleStorageKey, BALANCE_EXTEND_AMOUNT}; use strata_storage::{INSTANCE_EXTEND_AMOUNT, PERSISTENT_EXTEND_AMOUNT}; use super::*; @@ -6,10 +8,30 @@ use crate::keys::DataKey; const TWO_DAYS: u64 = 2 * DAY_DURATION; +/// Testnet's minimum lifetime of a new persistent entry, in ledgers. +const TESTNET_MIN_PERSISTENT_TTL: u32 = 120_960; + fn persistent_ttl(f: &Fixture, key: &DataKey) -> u32 { f.e.as_contract(&f.vault.address, || f.e.storage().persistent().get_ttl(key)) } +fn ttl_in>(e: &Env, contract: &Address, key: &K) -> u32 { + e.as_contract(contract, || e.storage().persistent().get_ttl(key)) +} + +fn share_balance_ttl(f: &Fixture, account: &Address) -> u32 { + ttl_in( + &f.e, + &f.share.address, + &FungibleStorageKey::Balance(account.clone()), + ) +} + +fn testnet_minimum(e: &Env) { + e.ledger() + .set_min_persistent_entry_ttl(TESTNET_MIN_PERSISTENT_TTL); +} + fn instance_ttl(f: &Fixture) -> u32 { f.e.as_contract(&f.vault.address, || f.e.storage().instance().get_ttl()) } @@ -106,3 +128,30 @@ fn a_pending_request_still_claims_after_most_of_a_month() { assert_eq!(f.vault.claim_deposit(&user, &epoch), 200); } + +#[test] +fn a_deposit_claim_writes_the_new_share_balance_with_the_full_ttl() { + let f = setup(); + let user = f.investor(1_000); + let epoch = f.vault.request_deposit(&user, &400); + f.fulfill_epoch(wad(2)); + testnet_minimum(&f.e); + + f.vault.claim_deposit(&user, &epoch); + + assert_eq!(share_balance_ttl(&f, &user), BALANCE_EXTEND_AMOUNT); +} + +#[test] +fn the_first_redemption_writes_the_vaults_share_balance_with_the_full_ttl() { + let f = setup(); + let holder = f.holder(100); + testnet_minimum(&f.e); + + f.vault.request_redeem(&holder, &100); + + assert_eq!( + share_balance_ttl(&f, &f.vault.address), + BALANCE_EXTEND_AMOUNT + ); +} diff --git a/contracts/share-token/src/contract.rs b/contracts/share-token/src/contract.rs index 28f98927..4617da68 100644 --- a/contracts/share-token/src/contract.rs +++ b/contracts/share-token/src/contract.rs @@ -1,5 +1,6 @@ use crate::error::ShareTokenError; use crate::event::LegalTransfer; +use crate::extend; use crate::governance::{self, TokenChange, TokenPendingChange}; use crate::guards; use crate::roles::COMPLIANCE_ROLE; @@ -111,6 +112,7 @@ impl ShareToken { guards::identity_verified(e, &to); strata_storage::bump_instance(e); RWA::forced_transfer(e, &from, &to, amount); + extend::balances(e, &[&from, &to]); LegalTransfer { from, to, @@ -157,11 +159,13 @@ impl FungibleToken for ShareToken { fn transfer(e: &Env, from: Address, to: MuxedAddress, amount: i128) { strata_storage::bump_instance(e); ::transfer(e, &from, &to, amount); + extend::balances(e, &[&from, &to.address()]); } fn transfer_from(e: &Env, spender: Address, from: Address, to: Address, amount: i128) { strata_storage::bump_instance(e); ::transfer_from(e, &spender, &from, &to, amount); + extend::balances(e, &[&from, &to]); } fn approve(e: &Env, owner: Address, spender: Address, amount: i128, live_until_ledger: u32) { @@ -221,11 +225,13 @@ impl RWAToken for ShareToken { fn mint(e: &Env, to: Address, amount: i128, operator: Address) { strata_storage::bump_instance(e); RWA::mint(e, &to, amount); + extend::balances(e, &[&to]); } #[only_role(operator, "manager")] fn burn(e: &Env, user_address: Address, amount: i128, operator: Address) { strata_storage::bump_instance(e); RWA::burn(e, &user_address, amount); + extend::balances(e, &[&user_address]); } /// The vault escrows redemptions through it. Compliance moves shares with /// `legal_transfer` instead. @@ -233,6 +239,7 @@ impl RWAToken for ShareToken { fn forced_transfer(e: &Env, from: Address, to: Address, amount: i128, operator: Address) { strata_storage::bump_instance(e); RWA::forced_transfer(e, &from, &to, amount); + extend::balances(e, &[&from, &to]); } /// Moves a whole balance to the account the identity verifier names, so /// it needs the admin's co-signature like a legal transfer, and never @@ -248,24 +255,31 @@ impl RWAToken for ShareToken { guards::not_vault_address(e, &old_account); guards::not_vault_address(e, &new_account); strata_storage::bump_instance(e); - RWA::recover_balance(e, &old_account, &new_account) + let recovered = RWA::recover_balance(e, &old_account, &new_account); + extend::balances(e, &[&new_account]); + extend::address_frozen(e, &new_account); + extend::frozen_tokens(e, &new_account); + recovered } #[only_role(operator, "compliance")] fn set_address_frozen(e: &Env, user_address: Address, freeze: bool, operator: Address) { guards::not_freezing_vault(e, &user_address, freeze); strata_storage::bump_instance(e); RWA::set_address_frozen(e, &user_address, freeze); + extend::address_frozen(e, &user_address); } #[only_role(operator, "compliance")] fn freeze_partial_tokens(e: &Env, user_address: Address, amount: i128, operator: Address) { guards::not_vault_address(e, &user_address); strata_storage::bump_instance(e); RWA::freeze_partial_tokens(e, &user_address, amount); + extend::frozen_tokens(e, &user_address); } #[only_role(operator, "compliance")] fn unfreeze_partial_tokens(e: &Env, user_address: Address, amount: i128, operator: Address) { strata_storage::bump_instance(e); RWA::unfreeze_partial_tokens(e, &user_address, amount); + extend::frozen_tokens(e, &user_address); } fn set_compliance(e: &Env, compliance: Address, operator: Address) { guards::caller_is_admin(e, &operator); diff --git a/contracts/share-token/src/extend.rs b/contracts/share-token/src/extend.rs new file mode 100644 index 00000000..bc5c65fa --- /dev/null +++ b/contracts/share-token/src/extend.rs @@ -0,0 +1,18 @@ +//! OpenZeppelin extends an entry when it reads it, not when it creates it. + +use soroban_sdk::{Address, Env}; +use stellar_tokens::{fungible::Base, rwa::RWA}; + +pub(crate) fn balances(e: &Env, accounts: &[&Address]) { + for account in accounts { + Base::balance(e, account); + } +} + +pub(crate) fn address_frozen(e: &Env, account: &Address) { + RWA::is_frozen(e, account); +} + +pub(crate) fn frozen_tokens(e: &Env, account: &Address) { + RWA::get_frozen_tokens(e, account); +} diff --git a/contracts/share-token/src/lib.rs b/contracts/share-token/src/lib.rs index 5dbd9b0e..a9f55cdd 100644 --- a/contracts/share-token/src/lib.rs +++ b/contracts/share-token/src/lib.rs @@ -2,6 +2,7 @@ mod contract; mod error; mod event; +mod extend; mod governance; mod guards; mod roles; @@ -18,3 +19,5 @@ mod test; mod test_governance; #[cfg(test)] mod test_guards; +#[cfg(test)] +mod test_ttl; diff --git a/contracts/share-token/src/test_ttl.rs b/contracts/share-token/src/test_ttl.rs new file mode 100644 index 00000000..21e16624 --- /dev/null +++ b/contracts/share-token/src/test_ttl.rs @@ -0,0 +1,284 @@ +//! A balance or freeze the token creates reaches its extend target in the call that +//! creates it, even where the network's minimum lifetime is shorter. + +use soroban_sdk::{ + contract, contractimpl, symbol_short, + testutils::{storage::Persistent as _, Address as _, Ledger as _}, + Address, Env, IntoVal, String, Symbol, Val, +}; +use stellar_tokens::{ + fungible::{FungibleStorageKey, BALANCE_EXTEND_AMOUNT}, + rwa::{identity_verification, RWAStorageKey, FROZEN_EXTEND_AMOUNT}, +}; + +use crate::{ShareToken, ShareTokenClient}; +use compliance::{Compliance, ComplianceClient}; + +/// Testnet's minimum lifetime of a new persistent entry, in ledgers. +const TESTNET_MIN_PERSISTENT_TTL: u32 = 120_960; +/// Mainnet's, in ledgers: longer than any extend target. +const MAINNET_MIN_PERSISTENT_TTL: u32 = 2_073_600; + +/// Accepts every account and names the recovery target a test sets. +#[contract] +struct OpenVerifier; + +#[contractimpl] +impl OpenVerifier { + pub fn set_recovery_target(e: Env, target: Address) { + e.storage() + .instance() + .set(&symbol_short!("target"), &target); + } +} + +#[contractimpl(contracttrait)] +impl identity_verification::IdentityVerifier for OpenVerifier { + fn verify_identity(_e: &Env, _account: &Address) {} + + fn recovery_target(e: &Env, _old_account: &Address) -> Option
{ + e.storage().instance().get(&symbol_short!("target")) + } + + fn set_claim_topics_and_issuers( + _e: &Env, + _claim_topics_and_issuers: Address, + _operator: Address, + ) { + } +} + +struct Fixture<'a> { + e: Env, + token: ShareTokenClient<'a>, + verifier: OpenVerifierClient<'a>, + admin: Address, + compliance_authority: Address, +} + +impl Fixture<'_> { + fn persistent_ttl>(&self, key: &K) -> u32 { + self.e.as_contract(&self.token.address, || { + self.e.storage().persistent().get_ttl(key) + }) + } + + fn balance_ttl(&self, account: &Address) -> u32 { + self.persistent_ttl(&FungibleStorageKey::Balance(account.clone())) + } + + /// A manager to mint with, and a holder of `shares`. + fn holder(&self, shares: i128) -> (Address, Address) { + let manager = Address::generate(&self.e); + self.token + .grant_role(&manager, &manager_role(), &self.admin); + let holder = Address::generate(&self.e); + self.token.mint(&holder, &shares, &manager); + (manager, holder) + } + + fn order(&self) -> String { + String::from_str(&self.e, "court-order-2026-117") + } +} + +fn manager_role() -> Symbol { + symbol_short!("manager") +} + +/// A token in setup on a ledger with testnet's minimum lifetime; its verifier accepts everyone. +fn setup<'a>() -> Fixture<'a> { + let e = Env::default(); + e.mock_all_auths(); + e.ledger() + .set_min_persistent_entry_ttl(TESTNET_MIN_PERSISTENT_TTL); + let admin = Address::generate(&e); + let compliance_authority = Address::generate(&e); + let rules = ComplianceClient::new(&e, &e.register(Compliance, (admin.clone(),))); + let verifier = OpenVerifierClient::new(&e, &e.register(OpenVerifier, ())); + let token = ShareTokenClient::new( + &e, + &e.register( + ShareToken, + ( + String::from_str(&e, "Strata Vault USDC"), + String::from_str(&e, "bvUSDC"), + admin.clone(), + compliance_authority.clone(), + rules.address.clone(), + verifier.address.clone(), + ), + ), + ); + rules.bind_token(&token.address, &admin); + Fixture { + e, + token, + verifier, + admin, + compliance_authority, + } +} + +#[test] +fn a_mint_writes_the_new_balance_with_the_full_ttl() { + let f = setup(); + + let (_, holder) = f.holder(100); + + assert_eq!(f.balance_ttl(&holder), BALANCE_EXTEND_AMOUNT); +} + +#[test] +fn a_transfer_writes_the_new_balance_with_the_full_ttl() { + let f = setup(); + let (_, holder) = f.holder(100); + let to = Address::generate(&f.e); + + f.token.transfer(&holder, &to, &40); + + assert_eq!(f.balance_ttl(&to), BALANCE_EXTEND_AMOUNT); +} + +#[test] +fn a_transfer_from_writes_the_new_balance_with_the_full_ttl() { + let f = setup(); + let (_, holder) = f.holder(100); + let spender = Address::generate(&f.e); + let to = Address::generate(&f.e); + f.token + .approve(&holder, &spender, &40, &(f.e.ledger().sequence() + 100)); + + f.token.transfer_from(&spender, &holder, &to, &40); + + assert_eq!(f.balance_ttl(&to), BALANCE_EXTEND_AMOUNT); +} + +#[test] +fn an_escrow_writes_the_receiving_balance_with_the_full_ttl() { + let f = setup(); + let (manager, holder) = f.holder(100); + let vault = Address::generate(&f.e); + + f.token.forced_transfer(&holder, &vault, &40, &manager); + + assert_eq!(f.balance_ttl(&vault), BALANCE_EXTEND_AMOUNT); +} + +#[test] +fn a_legal_transfer_writes_the_new_balance_with_the_full_ttl() { + let f = setup(); + let (_, holder) = f.holder(100); + let to = Address::generate(&f.e); + + f.token + .legal_transfer(&holder, &to, &40, &f.order(), &f.compliance_authority); + + assert_eq!(f.balance_ttl(&to), BALANCE_EXTEND_AMOUNT); +} + +#[test] +fn a_recovery_writes_the_new_balance_with_the_full_ttl() { + let f = setup(); + let (_, holder) = f.holder(100); + let new_account = Address::generate(&f.e); + f.verifier.set_recovery_target(&new_account); + + assert!(f + .token + .recover_balance(&holder, &new_account, &f.compliance_authority)); + + assert_eq!(f.balance_ttl(&new_account), BALANCE_EXTEND_AMOUNT); +} + +#[test] +fn a_recovery_writes_the_freezes_it_carries_with_the_full_ttl() { + let f = setup(); + let (_, holder) = f.holder(100); + f.token + .freeze_partial_tokens(&holder, &30, &f.compliance_authority); + f.token + .set_address_frozen(&holder, &true, &f.compliance_authority); + let new_account = Address::generate(&f.e); + f.verifier.set_recovery_target(&new_account); + + assert!(f + .token + .recover_balance(&holder, &new_account, &f.compliance_authority)); + + assert_eq!( + f.persistent_ttl(&RWAStorageKey::FrozenTokens(new_account.clone())), + FROZEN_EXTEND_AMOUNT + ); + assert_eq!( + f.persistent_ttl(&RWAStorageKey::AddressFrozen(new_account)), + FROZEN_EXTEND_AMOUNT + ); +} + +/// OpenZeppelin writes a zero balance for an account that sends nothing and +/// held nothing. +#[test] +fn sending_nothing_from_an_empty_account_writes_its_balance_with_the_full_ttl() { + let f = setup(); + let (manager, _) = f.holder(100); + let to = Address::generate(&f.e); + let empty = || Address::generate(&f.e); + let (by_transfer, by_transfer_from, by_escrow, by_order, by_burn) = + (empty(), empty(), empty(), empty(), empty()); + + f.token.transfer(&by_transfer, &to, &0); + f.token.transfer_from(&to, &by_transfer_from, &to, &0); + f.token.forced_transfer(&by_escrow, &to, &0, &manager); + f.token + .legal_transfer(&by_order, &to, &0, &f.order(), &f.compliance_authority); + f.token.burn(&by_burn, &0, &manager); + + for account in [by_transfer, by_transfer_from, by_escrow, by_order, by_burn] { + assert_eq!(f.balance_ttl(&account), BALANCE_EXTEND_AMOUNT); + } +} + +#[test] +fn a_freeze_writes_its_entry_with_the_full_ttl() { + let f = setup(); + let account = Address::generate(&f.e); + + f.token + .set_address_frozen(&account, &true, &f.compliance_authority); + + assert_eq!( + f.persistent_ttl(&RWAStorageKey::AddressFrozen(account)), + FROZEN_EXTEND_AMOUNT + ); +} + +#[test] +fn a_partial_freeze_writes_its_entry_with_the_full_ttl() { + let f = setup(); + let (_, holder) = f.holder(100); + let empty = Address::generate(&f.e); + + f.token + .freeze_partial_tokens(&holder, &30, &f.compliance_authority); + f.token + .unfreeze_partial_tokens(&empty, &0, &f.compliance_authority); + + for account in [holder, empty] { + assert_eq!( + f.persistent_ttl(&RWAStorageKey::FrozenTokens(account)), + FROZEN_EXTEND_AMOUNT + ); + } +} + +#[test] +fn a_network_minimum_longer_than_the_extend_target_is_kept() { + let f = setup(); + f.e.ledger() + .set_min_persistent_entry_ttl(MAINNET_MIN_PERSISTENT_TTL); + + let (_, holder) = f.holder(100); + + assert_eq!(f.balance_ttl(&holder), MAINNET_MIN_PERSISTENT_TTL - 1); +} From 469031df02b4b568fc33f994ebce2bd7c1aafaae Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 01:58:29 -0300 Subject: [PATCH 095/147] fix: extend role entries in the granting call A role grant writes up to four persistent entries: the account's role, its place in the member list, the member count and the list of roles. OpenZeppelin extends them when it reads them, not when it creates them, so the first keeper or investor whose call checked the role paid to extend it from the network's minimum. Every grant the vault, the share token and the oracle make (in their constructors, in grant_role and in an applied GrantRole change) now reads those entries back through OpenZeppelin's own reads, so the granter pays and each entry reaches the role lifetime in the same call. --- contracts/async-vault/src/extend.rs | 13 +++ contracts/async-vault/src/governance.rs | 4 +- contracts/async-vault/src/lib.rs | 5 + contracts/async-vault/src/test/ttl.rs | 94 +++++++++++++++++- contracts/nav-oracle/src/extend.rs | 13 +++ contracts/nav-oracle/src/governance.rs | 4 +- contracts/nav-oracle/src/lib.rs | 4 + contracts/nav-oracle/src/test_governance.rs | 103 +++++++++++++++++++- contracts/share-token/src/contract.rs | 10 +- contracts/share-token/src/extend.rs | 12 ++- contracts/share-token/src/governance.rs | 4 +- contracts/share-token/src/test_ttl.rs | 100 ++++++++++++++++++- 12 files changed, 348 insertions(+), 18 deletions(-) create mode 100644 contracts/async-vault/src/extend.rs create mode 100644 contracts/nav-oracle/src/extend.rs diff --git a/contracts/async-vault/src/extend.rs b/contracts/async-vault/src/extend.rs new file mode 100644 index 00000000..2de355a3 --- /dev/null +++ b/contracts/async-vault/src/extend.rs @@ -0,0 +1,13 @@ +//! OpenZeppelin extends a role entry when it reads it, not when it creates it. + +use soroban_sdk::{Address, Env, Symbol}; +use stellar_access::access_control; + +/// The role, its member-list slot, the member count and the role list: all a grant writes. +pub(crate) fn role_entries(e: &Env, account: &Address, role: &Symbol) { + if let Some(index) = access_control::has_role(e, account, role) { + access_control::get_role_member(e, role, index); + } + access_control::get_role_member_count(e, role); + access_control::get_existing_roles(e); +} diff --git a/contracts/async-vault/src/governance.rs b/contracts/async-vault/src/governance.rs index 47909e2e..f0904ced 100644 --- a/contracts/async-vault/src/governance.rs +++ b/contracts/async-vault/src/governance.rs @@ -22,6 +22,7 @@ use strata_timelock::{queue, setup, Clock, Refusal}; use crate::error::VaultError; use crate::event::{ChangeApplied, ChangeCancelled, ChangeProposed, NoticeSet, SetupFinished}; +use crate::extend; use crate::guards; use crate::keys::DataKey; use crate::state; @@ -247,7 +248,8 @@ pub(crate) fn apply(e: &Env, id: u32) { VaultChange::Notice(secs) => set_notice(e, *secs), VaultChange::WindDownDelay(secs) => wind_down::set_delay(e, *secs), VaultChange::GrantRole(role, account) => { - access_control::grant_role_no_auth(e, account, role, &admin) + access_control::grant_role_no_auth(e, account, role, &admin); + extend::role_entries(e, account, role); } VaultChange::RevokeRole(role, account) => { access_control::revoke_role_no_auth(e, account, role, &admin) diff --git a/contracts/async-vault/src/lib.rs b/contracts/async-vault/src/lib.rs index 1dcb990a..72fb288c 100644 --- a/contracts/async-vault/src/lib.rs +++ b/contracts/async-vault/src/lib.rs @@ -4,6 +4,7 @@ mod deposit; mod epoch; mod error; mod event; +mod extend; mod governance; mod guards; mod health; @@ -86,8 +87,11 @@ impl AsyncVault { access_control::set_admin(e, &roles.governance); access_control::grant_role_no_auth(e, &roles.manager, &MANAGER_ROLE, &roles.governance); + extend::role_entries(e, &roles.manager, &MANAGER_ROLE); access_control::grant_role_no_auth(e, &roles.treasury, &TREASURY_ROLE, &roles.governance); + extend::role_entries(e, &roles.treasury, &TREASURY_ROLE); access_control::grant_role_no_auth(e, &roles.guardian, &GUARDIAN_ROLE, &roles.governance); + extend::role_entries(e, &roles.guardian, &GUARDIAN_ROLE); state::set_addr(e, &DataKey::Asset, &asset); state::set_addr(e, &DataKey::ShareToken, &share_token); @@ -404,6 +408,7 @@ impl AccessControl for AsyncVault { fn grant_role(e: &Env, account: Address, role: Symbol, caller: Address) { guards::direct_change_allowed(e); access_control::grant_role(e, &account, &role, &caller); + extend::role_entries(e, &account, &role); } /// Instant for every role but `manager`: taking `treasury` or `guardian` diff --git a/contracts/async-vault/src/test/ttl.rs b/contracts/async-vault/src/test/ttl.rs index 234774a6..bab85502 100644 --- a/contracts/async-vault/src/test/ttl.rs +++ b/contracts/async-vault/src/test/ttl.rs @@ -1,10 +1,12 @@ use soroban_sdk::testutils::storage::{Instance as _, Persistent as _}; -use soroban_sdk::{IntoVal, Val}; +use soroban_sdk::{contracttype, IntoVal, Symbol, Val}; +use stellar_access::access_control::{AccessControlStorageKey, ROLE_EXTEND_AMOUNT}; use stellar_tokens::fungible::{FungibleStorageKey, BALANCE_EXTEND_AMOUNT}; use strata_storage::{INSTANCE_EXTEND_AMOUNT, PERSISTENT_EXTEND_AMOUNT}; use super::*; use crate::keys::DataKey; +use crate::roles::{GUARDIAN_ROLE, MANAGER_ROLE, TREASURY_ROLE}; const TWO_DAYS: u64 = 2 * DAY_DURATION; @@ -19,6 +21,44 @@ fn ttl_in>(e: &Env, contract: &Address, key: &K) -> u32 { e.as_contract(contract, || e.storage().persistent().get_ttl(key)) } +/// OpenZeppelin keeps the type of a role's member-list key private. A type +/// with the same variant name and fields encodes the same key. +#[contracttype] +struct RoleAccountKey { + role: Symbol, + index: u32, +} + +#[contracttype] +enum RoleMemberKey { + RoleAccounts(RoleAccountKey), +} + +/// Every entry a grant writes: the role, its member-list slot, the member count, the role list. +fn role_entry_ttls(e: &Env, contract: &Address, account: &Address, role: &Symbol) -> [u32; 4] { + let has_role = AccessControlStorageKey::HasRole(account.clone(), role.clone()); + let index: u32 = e.as_contract(contract, || { + e.storage().persistent().get(&has_role).unwrap() + }); + [ + ttl_in(e, contract, &has_role), + ttl_in( + e, + contract, + &RoleMemberKey::RoleAccounts(RoleAccountKey { + role: role.clone(), + index, + }), + ), + ttl_in( + e, + contract, + &AccessControlStorageKey::RoleAccountsCount(role.clone()), + ), + ttl_in(e, contract, &AccessControlStorageKey::ExistingRoles), + ] +} + fn share_balance_ttl(f: &Fixture, account: &Address) -> u32 { ttl_in( &f.e, @@ -155,3 +195,55 @@ fn the_first_redemption_writes_the_vaults_share_balance_with_the_full_ttl() { BALANCE_EXTEND_AMOUNT ); } + +#[test] +fn the_constructor_writes_each_role_with_the_full_ttl() { + let e = Env::default(); + testnet_minimum(&e); + let roles = distinct_roles(&e); + + let vault = register_with(&e, roles.clone()); + + for (account, role) in [ + (&roles.manager, MANAGER_ROLE), + (&roles.treasury, TREASURY_ROLE), + (&roles.guardian, GUARDIAN_ROLE), + ] { + assert_eq!( + role_entry_ttls(&e, &vault, account, &role), + [ROLE_EXTEND_AMOUNT; 4] + ); + } +} + +#[test] +fn a_grant_during_setup_writes_the_role_with_the_full_ttl() { + let f = setup_unfinished(); + let next = Address::generate(&f.e); + testnet_minimum(&f.e); + + f.vault.grant_role(&next, &GUARDIAN_ROLE, &f.admin); + + assert_eq!( + role_entry_ttls(&f.e, &f.vault.address, &next, &GUARDIAN_ROLE), + [ROLE_EXTEND_AMOUNT; 4] + ); +} + +#[test] +fn a_grant_through_the_timelock_writes_the_role_with_the_full_ttl() { + let f = setup(); + let next = Address::generate(&f.e); + let id = f + .vault + .propose_change(&VaultChange::GrantRole(GUARDIAN_ROLE, next.clone())); + f.advance(MIN_UPGRADE_DELAY); + testnet_minimum(&f.e); + + f.vault.apply_change(&id); + + assert_eq!( + role_entry_ttls(&f.e, &f.vault.address, &next, &GUARDIAN_ROLE), + [ROLE_EXTEND_AMOUNT; 4] + ); +} diff --git a/contracts/nav-oracle/src/extend.rs b/contracts/nav-oracle/src/extend.rs new file mode 100644 index 00000000..2de355a3 --- /dev/null +++ b/contracts/nav-oracle/src/extend.rs @@ -0,0 +1,13 @@ +//! OpenZeppelin extends a role entry when it reads it, not when it creates it. + +use soroban_sdk::{Address, Env, Symbol}; +use stellar_access::access_control; + +/// The role, its member-list slot, the member count and the role list: all a grant writes. +pub(crate) fn role_entries(e: &Env, account: &Address, role: &Symbol) { + if let Some(index) = access_control::has_role(e, account, role) { + access_control::get_role_member(e, role, index); + } + access_control::get_role_member_count(e, role); + access_control::get_existing_roles(e); +} diff --git a/contracts/nav-oracle/src/governance.rs b/contracts/nav-oracle/src/governance.rs index 153bc649..2ceab364 100644 --- a/contracts/nav-oracle/src/governance.rs +++ b/contracts/nav-oracle/src/governance.rs @@ -6,6 +6,7 @@ use stellar_access::{ use stellar_contract_utils::upgradeable; use strata_timelock::{queue, setup, Clock, Refusal, TimelockHost}; +use crate::extend; use crate::guards; use crate::state::OracleConfig; use crate::{ @@ -121,7 +122,8 @@ pub(crate) fn apply(e: &Env, id: u32) { match &change { OracleChange::Upgrade(hash) => upgradeable::upgrade(e, hash), OracleChange::GrantRole(role, account) => { - access_control::grant_role_no_auth(e, account, role, &admin) + access_control::grant_role_no_auth(e, account, role, &admin); + extend::role_entries(e, account, role); } OracleChange::RevokeRole(role, account) => { access_control::revoke_role_no_auth(e, account, role, &admin) diff --git a/contracts/nav-oracle/src/lib.rs b/contracts/nav-oracle/src/lib.rs index d7c7d4e3..f94deabb 100644 --- a/contracts/nav-oracle/src/lib.rs +++ b/contracts/nav-oracle/src/lib.rs @@ -1,5 +1,6 @@ #![no_std] +mod extend; mod governance; mod guards; mod state; @@ -189,6 +190,7 @@ impl AccessControl for NavOracleContract { guards::direct_change_allowed(e); strata_storage::bump_instance(e); access_control::grant_role(e, &account, &role, &caller); + extend::role_entries(e, &account, &role); } /// Cancelling a pending hand-over (`live_until_ledger == 0`) keeps the @@ -236,7 +238,9 @@ impl NavOracleContract { guards::valid_config(e, &config); access_control::set_admin(e, &admin); access_control::grant_role_no_auth(e, &attester, &ATTESTER_ROLE, &admin); + extend::role_entries(e, &attester, &ATTESTER_ROLE); access_control::grant_role_no_auth(e, &guardian, &GUARDIAN_ROLE, &admin); + extend::role_entries(e, &guardian, &GUARDIAN_ROLE); state::set_config(e, &config); state::set_nav_paused(e, false); diff --git a/contracts/nav-oracle/src/test_governance.rs b/contracts/nav-oracle/src/test_governance.rs index 6a64dec0..27a675b7 100644 --- a/contracts/nav-oracle/src/test_governance.rs +++ b/contracts/nav-oracle/src/test_governance.rs @@ -1,13 +1,15 @@ extern crate std; use soroban_sdk::{ - contract, contractimpl, symbol_short, + contract, contractimpl, contracttype, symbol_short, testutils::{ - storage::Instance as _, Address as _, AuthorizedFunction, AuthorizedInvocation, - Events as _, Ledger as _, MockAuth, MockAuthInvoke, + storage::{Instance as _, Persistent as _}, + Address as _, AuthorizedFunction, AuthorizedInvocation, Events as _, Ledger as _, MockAuth, + MockAuthInvoke, }, - Address, BytesN, Env, Event as _, IntoVal, Symbol, + Address, BytesN, Env, Event as _, IntoVal, Symbol, Val, }; +use stellar_access::access_control::{AccessControlStorageKey, ROLE_EXTEND_AMOUNT}; use stellar_access::role_transfer::RoleTransferError; use strata_storage::INSTANCE_EXTEND_AMOUNT; use strata_timelock::{Clock, DAY_DURATION, MIN_DELAY}; @@ -21,6 +23,8 @@ use crate::{ const SCALE: i128 = 1_000_000_000_000_000_000; const TWO_DAYS_IN_LEDGERS: u32 = 2 * 17_280; +/// Testnet's minimum lifetime of a new persistent entry, in ledgers. +const TESTNET_MIN_PERSISTENT_TTL: u32 = 120_960; /// Plays the vault: reports whatever clock a test sets, and counts the reads. #[contract] @@ -937,6 +941,97 @@ fn setup_grants_hand_overs_and_changes_restore_the_instance_ttl() { f.restores_the_instance(|| f.oracle.apply_change(&id)); } +/// OpenZeppelin keeps the type of a role's member-list key private. A type +/// with the same variant name and fields encodes the same key. +#[contracttype] +struct RoleAccountKey { + role: Symbol, + index: u32, +} + +#[contracttype] +enum RoleMemberKey { + RoleAccounts(RoleAccountKey), +} + +impl Fixture<'_> { + fn persistent_ttl>(&self, key: &K) -> u32 { + self.e.as_contract(&self.oracle.address, || { + self.e.storage().persistent().get_ttl(key) + }) + } + + /// Every entry a grant writes: the role, its member-list slot, the member count, the role list. + fn role_entry_ttls(&self, account: &Address, role: &Symbol) -> [u32; 4] { + let has_role = AccessControlStorageKey::HasRole(account.clone(), role.clone()); + let index: u32 = self.e.as_contract(&self.oracle.address, || { + self.e.storage().persistent().get(&has_role).unwrap() + }); + [ + self.persistent_ttl(&has_role), + self.persistent_ttl(&RoleMemberKey::RoleAccounts(RoleAccountKey { + role: role.clone(), + index, + })), + self.persistent_ttl(&AccessControlStorageKey::RoleAccountsCount(role.clone())), + self.persistent_ttl(&AccessControlStorageKey::ExistingRoles), + ] + } +} + +fn testnet_minimum(e: &Env) { + e.ledger() + .set_min_persistent_entry_ttl(TESTNET_MIN_PERSISTENT_TTL); +} + +#[test] +fn the_constructor_writes_each_role_with_the_full_ttl() { + let f = setup_with(|e, args| { + testnet_minimum(e); + e.register(NavOracleContract, args) + }); + + assert_eq!( + f.role_entry_ttls(&f.attester, &ATTESTER_ROLE), + [ROLE_EXTEND_AMOUNT; 4] + ); + assert_eq!( + f.role_entry_ttls(&f.guardian, &GUARDIAN_ROLE), + [ROLE_EXTEND_AMOUNT; 4] + ); +} + +#[test] +fn a_grant_during_setup_writes_the_role_with_the_full_ttl() { + let f = setup(); + let next = Address::generate(&f.e); + testnet_minimum(&f.e); + + f.oracle.grant_role(&next, &GUARDIAN_ROLE, &f.admin); + + assert_eq!( + f.role_entry_ttls(&next, &GUARDIAN_ROLE), + [ROLE_EXTEND_AMOUNT; 4] + ); +} + +#[test] +fn a_grant_through_the_timelock_writes_the_role_with_the_full_ttl() { + let f = setup(); + f.finish(); + let next = Address::generate(&f.e); + let id = f.propose(OracleChange::GrantRole(GUARDIAN_ROLE, next.clone())); + f.advance(MIN_DELAY); + testnet_minimum(&f.e); + + f.oracle.apply_change(&id); + + assert_eq!( + f.role_entry_ttls(&next, &GUARDIAN_ROLE), + [ROLE_EXTEND_AMOUNT; 4] + ); +} + #[test] fn each_admin_call_asks_the_admin_for_one_signature() { let f = setup(); diff --git a/contracts/share-token/src/contract.rs b/contracts/share-token/src/contract.rs index 4617da68..09abb995 100644 --- a/contracts/share-token/src/contract.rs +++ b/contracts/share-token/src/contract.rs @@ -37,12 +37,9 @@ impl ShareToken { ) { Base::set_metadata(e, 7, name, symbol); access_control::set_admin(e, &admin); - access_control::grant_role_no_auth( - e, - &compliance_authority, - &Symbol::new(e, COMPLIANCE_ROLE), - &admin, - ); + let compliance_role = Symbol::new(e, COMPLIANCE_ROLE); + access_control::grant_role_no_auth(e, &compliance_authority, &compliance_role, &admin); + extend::role_entries(e, &compliance_authority, &compliance_role); RWA::set_compliance(e, &compliance); RWA::set_identity_verifier(e, &identity_verifier); upgradeable::set_schema_version(e, 1); @@ -187,6 +184,7 @@ impl AccessControl for ShareToken { guards::direct_change_allowed(e); strata_storage::bump_instance(e); access_control::grant_role(e, &account, &role, &caller); + extend::role_entries(e, &account, &role); } /// Instant for `compliance`. Revoking `manager` waits like a grant, diff --git a/contracts/share-token/src/extend.rs b/contracts/share-token/src/extend.rs index bc5c65fa..352dbaa2 100644 --- a/contracts/share-token/src/extend.rs +++ b/contracts/share-token/src/extend.rs @@ -1,6 +1,7 @@ //! OpenZeppelin extends an entry when it reads it, not when it creates it. -use soroban_sdk::{Address, Env}; +use soroban_sdk::{Address, Env, Symbol}; +use stellar_access::access_control; use stellar_tokens::{fungible::Base, rwa::RWA}; pub(crate) fn balances(e: &Env, accounts: &[&Address]) { @@ -16,3 +17,12 @@ pub(crate) fn address_frozen(e: &Env, account: &Address) { pub(crate) fn frozen_tokens(e: &Env, account: &Address) { RWA::get_frozen_tokens(e, account); } + +/// The role, its member-list slot, the member count and the role list: all a grant writes. +pub(crate) fn role_entries(e: &Env, account: &Address, role: &Symbol) { + if let Some(index) = access_control::has_role(e, account, role) { + access_control::get_role_member(e, role, index); + } + access_control::get_role_member_count(e, role); + access_control::get_existing_roles(e); +} diff --git a/contracts/share-token/src/governance.rs b/contracts/share-token/src/governance.rs index bc4aa2ca..970d073a 100644 --- a/contracts/share-token/src/governance.rs +++ b/contracts/share-token/src/governance.rs @@ -9,6 +9,7 @@ use strata_timelock::{queue, setup, Clock, Refusal, TimelockHost}; use crate::error::ShareTokenError; use crate::event::{ChangeApplied, ChangeCancelled, ChangeProposed, SetupFinished, VaultBound}; +use crate::extend; use crate::guards; use crate::ShareToken; @@ -106,7 +107,8 @@ pub(crate) fn apply(e: &Env, id: u32) { match &change { TokenChange::Upgrade(hash) => upgradeable::upgrade(e, hash), TokenChange::GrantRole(role, account) => { - access_control::grant_role_no_auth(e, account, role, &admin) + access_control::grant_role_no_auth(e, account, role, &admin); + extend::role_entries(e, account, role); } TokenChange::RevokeRole(role, account) => { access_control::revoke_role_no_auth(e, account, role, &admin) diff --git a/contracts/share-token/src/test_ttl.rs b/contracts/share-token/src/test_ttl.rs index 21e16624..0d415d07 100644 --- a/contracts/share-token/src/test_ttl.rs +++ b/contracts/share-token/src/test_ttl.rs @@ -1,17 +1,19 @@ -//! A balance or freeze the token creates reaches its extend target in the call that +//! An entry the token creates reaches its extend target in the call that //! creates it, even where the network's minimum lifetime is shorter. use soroban_sdk::{ - contract, contractimpl, symbol_short, + contract, contractimpl, contracttype, symbol_short, testutils::{storage::Persistent as _, Address as _, Ledger as _}, Address, Env, IntoVal, String, Symbol, Val, }; +use stellar_access::access_control::{AccessControlStorageKey, ROLE_EXTEND_AMOUNT}; use stellar_tokens::{ fungible::{FungibleStorageKey, BALANCE_EXTEND_AMOUNT}, rwa::{identity_verification, RWAStorageKey, FROZEN_EXTEND_AMOUNT}, }; +use strata_timelock::{Clock, MIN_DELAY}; -use crate::{ShareToken, ShareTokenClient}; +use crate::{ShareToken, ShareTokenClient, TokenChange}; use compliance::{Compliance, ComplianceClient}; /// Testnet's minimum lifetime of a new persistent entry, in ledgers. @@ -48,6 +50,36 @@ impl identity_verification::IdentityVerifier for OpenVerifier { } } +/// Plays the vault: its clock lets a timelocked change land. +#[contract] +struct VaultStub; + +#[contractimpl(contracttrait)] +impl strata_timelock::ClockSource for VaultStub { + fn timelock_clock(_e: &Env) -> Clock { + Clock { + delay: MIN_DELAY, + exit_window: 0, + paused_secs: 0, + paused: false, + winding_down: false, + } + } +} + +/// OpenZeppelin keeps the type of a role's member-list key private. A type +/// with the same variant name and fields encodes the same key. +#[contracttype] +struct RoleAccountKey { + role: Symbol, + index: u32, +} + +#[contracttype] +enum RoleMemberKey { + RoleAccounts(RoleAccountKey), +} + struct Fixture<'a> { e: Env, token: ShareTokenClient<'a>, @@ -67,6 +99,23 @@ impl Fixture<'_> { self.persistent_ttl(&FungibleStorageKey::Balance(account.clone())) } + /// Every entry a grant writes: the role, its member-list slot, the member count, the role list. + fn role_entry_ttls(&self, account: &Address, role: &Symbol) -> [u32; 4] { + let has_role = AccessControlStorageKey::HasRole(account.clone(), role.clone()); + let index: u32 = self.e.as_contract(&self.token.address, || { + self.e.storage().persistent().get(&has_role).unwrap() + }); + [ + self.persistent_ttl(&has_role), + self.persistent_ttl(&RoleMemberKey::RoleAccounts(RoleAccountKey { + role: role.clone(), + index, + })), + self.persistent_ttl(&AccessControlStorageKey::RoleAccountsCount(role.clone())), + self.persistent_ttl(&AccessControlStorageKey::ExistingRoles), + ] + } + /// A manager to mint with, and a holder of `shares`. fn holder(&self, shares: i128) -> (Address, Address) { let manager = Address::generate(&self.e); @@ -272,6 +321,51 @@ fn a_partial_freeze_writes_its_entry_with_the_full_ttl() { } } +#[test] +fn the_constructor_writes_the_compliance_role_with_the_full_ttl() { + let f = setup(); + + assert_eq!( + f.role_entry_ttls(&f.compliance_authority, &Symbol::new(&f.e, "compliance")), + [ROLE_EXTEND_AMOUNT; 4] + ); +} + +#[test] +fn a_grant_during_setup_writes_the_role_with_the_full_ttl() { + let f = setup(); + let vault = Address::generate(&f.e); + + f.token.grant_role(&vault, &manager_role(), &f.admin); + + assert_eq!( + f.role_entry_ttls(&vault, &manager_role()), + [ROLE_EXTEND_AMOUNT; 4] + ); +} + +#[test] +fn a_grant_through_the_timelock_writes_the_role_with_the_full_ttl() { + let f = setup(); + f.token.bind_vault(&f.e.register(VaultStub, ())); + f.token.finish_setup(); + let next = Address::generate(&f.e); + let id = f + .token + .propose_change(&TokenChange::GrantRole(manager_role(), next.clone())); + f.e.ledger().with_mut(|l| { + l.timestamp += MIN_DELAY; + l.sequence_number += (MIN_DELAY / 5) as u32; + }); + + f.token.apply_change(&id); + + assert_eq!( + f.role_entry_ttls(&next, &manager_role()), + [ROLE_EXTEND_AMOUNT; 4] + ); +} + #[test] fn a_network_minimum_longer_than_the_extend_target_is_kept() { let f = setup(); From df5e9978d10ba873b4f7cdb3c9b56847bc7196cd Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 02:00:16 -0300 Subject: [PATCH 096/147] docs: rewrite the ttl policy for each network The kit spec said OpenZeppelin extends balances when it writes them; it extends them only when it reads them. The TTL policy now says the contracts read back each balance, freeze and role entry they write, and lists each network's minimum lifetime and longest extension (testnet about 7 days, mainnet about 120, read over RPC), what that means on each network, why the kit keeps one set of values everywhere, who pays, and what frontends should do. The testing guide lists the new tests. --- docs/TESTING.md | 11 ++++- docs/kit-spec.md | 119 ++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 110 insertions(+), 20 deletions(-) diff --git a/docs/TESTING.md b/docs/TESTING.md index 68070a7c..a4176f49 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -53,14 +53,21 @@ contract extends, who pays, and how to restore an archived one. | 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` | +| Every call that writes a share balance or a freeze gives a new one its full TTL, under testnet's minimum lifetime; so do a deposit claim and the first redemption, through the vault | [`contracts/share-token/src/test_ttl.rs`](../contracts/share-token/src/test_ttl.rs), [`contracts/async-vault/src/test/ttl.rs`](../contracts/async-vault/src/test/ttl.rs) | `a_mint_writes_the_new_balance_with_the_full_ttl`, `an_escrow_writes_the_receiving_balance_with_the_full_ttl`, `a_recovery_writes_the_new_balance_with_the_full_ttl`, `sending_nothing_from_an_empty_account_writes_its_balance_with_the_full_ttl`, `a_partial_freeze_writes_its_entry_with_the_full_ttl`, `the_first_redemption_writes_the_vaults_share_balance_with_the_full_ttl` | +| Every role grant in the vault, the token and the oracle gives the role's entries their full TTL: in the constructor, during setup and through the timelock | [`contracts/share-token/src/test_ttl.rs`](../contracts/share-token/src/test_ttl.rs), [`contracts/async-vault/src/test/ttl.rs`](../contracts/async-vault/src/test/ttl.rs), [`contracts/nav-oracle/src/test_governance.rs`](../contracts/nav-oracle/src/test_governance.rs) | `the_constructor_writes_each_role_with_the_full_ttl`, `a_grant_during_setup_writes_the_role_with_the_full_ttl`, `a_grant_through_the_timelock_writes_the_role_with_the_full_ttl` | +| A network minimum longer than the target, as on mainnet, is kept | [`contracts/share-token/src/test_ttl.rs`](../contracts/share-token/src/test_ttl.rs) | `a_network_minimum_longer_than_the_extend_target_is_kept` | | 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. So these tests check the TTL numbers the contracts set, not what the network does once an entry is archived. -The share token's balances and roles are stored by the OpenZeppelin Stellar -library, which extends them when they are read or written. It never extends the +The share token's balances and freezes, and the roles of the vault, the token +and the oracle, are stored by the OpenZeppelin Stellar library. It extends an +entry when it reads it, not when it creates it, so the contracts read back each +one they write. Without that, a new entry would start at the network's minimum +lifetime. The tests for it set the ledger's minimum to testnet's 120,960 +ledgers; the test `Env` defaults to 4,096. OpenZeppelin never extends the instance, so the token, the identity verifier and the compliance contract extend their own on every call that does work. diff --git a/docs/kit-spec.md b/docs/kit-spec.md index c6a45e72..e0a2a62c 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -312,18 +312,29 @@ Soroban archives a ledger entry when its time to live (TTL) runs out. An archived entry cannot be read until it is restored, so a contract whose instance is archived refuses every call. +The kit runs the same code on every network. Each persistent entry, the +contract instance included, has a target lifetime and a threshold one day +shorter. A call that reads or writes an entry extends it back to its target +once its TTL has fallen to the threshold. The transaction that creates an entry +extends it to its target at once; the compliance stub's modules and bound +tokens are the one exception (see **What OpenZeppelin extends**). The network +decides only how long a new entry lives before any extension (see **Network +settings**). + | 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 | +| `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 29 days or less remain | | `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`, `legal_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 | +| `share_token` | Its instance, each balance and freeze entry it writes, 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`, `legal_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, and each pending governance change it reads or writes | Every hook the token calls (`can_create`, `can_transfer`, `created`, `transferred`, `destroyed`) and its admin calls | Same | +| `async_vault`, `share_token`, `nav_oracle` | The role entries each grant writes | Each grant (in the constructor, `grant_role` and an applied `GrantRole` change), then every role check | 90 days (1,555,200 ledgers), once 89 days or less remain | -The lengths are the constants in `crates/strata-storage` +The kit's own lengths are the constants in `crates/strata-storage` (`PERSISTENT_EXTEND_AMOUNT`, `INSTANCE_EXTEND_AMOUNT`, and thresholds one day -shorter). They are fixed in code: a governance setting would add an admin power -for little gain. +shorter). The entries OpenZeppelin stores keep OpenZeppelin's own lengths. All +of them 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`, `share_token`, `nav_oracle` or `compliance` lives as a persistent entry, @@ -336,19 +347,90 @@ its `pending_changes()` view, builds each one's storage key the way the timelock crate does, and extends it the same 30 days as every instance and Wasm. -**What OpenZeppelin extends.** `stellar-tokens` 0.7.2 stores the share token's -balances and frozen amounts, and the compliance contract's modules and bound -tokens, and extends each to 30 days when it is read or written. `stellar-access` -0.7.2 stores every contract's roles and extends them to 90 days the same way. -Allowances are temporary entries that live until their `live_until_ledger`. -OpenZeppelin never extends an instance; the kit does, as above. The token's -total supply and metadata live in its instance. +**What OpenZeppelin extends, and what the kit adds.** `stellar-tokens` 0.7.2 +stores the share token's balances and freeze entries, and the compliance +contract's modules and bound tokens. `stellar-access` 0.7.2 stores the roles of +the vault, the token and the oracle. OpenZeppelin extends an existing entry +when it reads it: balances, freezes, modules and bound tokens to 30 days, roles +to 90. It does not extend an entry when it creates one, so a new entry would +start at the network's minimum lifetime and wait for a later caller to read it. +The kit closes that gap by reading back what it writes, through OpenZeppelin's +own reads, in the same call: + +- the share token reads back each balance and freeze entry after `mint`, + `burn`, `transfer`, `transfer_from`, `forced_transfer`, `legal_transfer`, + `recover_balance`, `set_address_frozen`, `freeze_partial_tokens` and + `unfreeze_partial_tokens`; +- the vault, the token and the oracle read back every entry a grant writes (the + account's role, its place in the member list, the member count and the list + of roles) after each grant in their constructors, in `grant_role`, and in an + applied `GrantRole` change. + +So a new balance, freeze or role entry reaches its target in the transaction +that creates it, and the kit keeps OpenZeppelin's thresholds without copying +them. The compliance stub does not read back its modules and bound tokens when +it writes them. The vault's `finish_setup` reads the bound tokens, which +extends them, and every hook the token calls reads its modules; the hooks after +a mint, transfer or burn read the bound tokens too. Allowances are temporary +entries that live until their `live_until_ledger`. OpenZeppelin never extends an +instance; the kit does, as above. The token's total supply and metadata live in +its instance. + +**Network settings.** Each network sets how long a new persistent entry lives +and how far one extension can reach. Validators can change these settings by +vote; the kit hardcodes neither. Check +[Stellar Lab's network limits](https://lab.stellar.org/network-limits) for +current values. + +| Setting | Testnet | Mainnet | +|---|---|---| +| Minimum lifetime of a new persistent entry | 120,960 ledgers (about 7 days) | 2,073,600 ledgers (about 120 days) | +| Longest lifetime one extension can reach | 3,110,400 ledgers (about 180 days) | 3,110,400 ledgers (about 180 days) | + +Read over RPC on 2026-10-20. + +**What this means on each network.** On testnet a new entry would live about 7 +days, less than any threshold, so renewals start in its first week: the +transaction that creates it extends it to its target, and from the next day any +call that touches it renews it. On mainnet a new entry already lives 120 +days, more than any target, so the extension in the creating transaction +changes nothing. No call renews a 30-day entry there until about the fourth +month (day 91), or a role until day 31. A restored entry comes back with the +network's minimum lifetime, so a mainnet restore pays at least 120 days of +rent. + +**Why the same values on every network.** The protocol already applies each +network's minimum, so mainnet entries start at 120 days without the kit knowing +it. The same Wasm runs on testnet and on mainnet, so what is tested on testnet +is what ships; a build-time network profile would mean two Wasm files to +review. The threshold one day under the target means an entry in daily use is +renewed at most once a day, for about a day of rent. Capping each extension +(`extend_ttl_with_limits`) would let rarely used entries archive sooner, and +each of those restores would pay at least 120 days of rent on mainnet. **Who pays.** The account that sends a transaction pays the rent for every -extension that transaction triggers. One call extends each entry it touches to -at most 30 days, so no caller pays for more than 30 days of any entry. - -**Idle deployments.** A deployment nobody calls for 30 days archives. Run +extension that transaction triggers. The transaction that creates an entry pays +for its target, or for the network's minimum when that is longer: 30 or 90 days +on testnet, 120 days on mainnet. A later call pays only to bring an entry back +to its target once it has fallen to its threshold: about a day of rent when +the entry is read daily, up to a whole target after a long quiet period. A +restore pays for the network's minimum, as above. + +**What frontends should do.** + +- Expect uneven renewal fees. The first call after a quiet period pays to renew + every entry it touches that has fallen to its threshold, so the same call can + cost more after a quiet week than on a busy day. +- Simulate again just before submitting. The fee comes from the simulation. If + an entry falls to its threshold between simulation and submission, the call + pays a renewal the fee does not cover, and the transaction fails. +- Let RPC add a restore when simulation asks for one. When a call needs an + archived entry, simulation adds its restore to the transaction (see + **Restoring**). Submit the transaction as simulated, restore included. + +**Idle deployments.** An entry nobody reads archives when its lifetime runs +out: 30 days after its last renewal (90 for a role), and on mainnet never +sooner than 120 days after it was created. Run `STELLAR_ACCOUNT= npm run extend -w scripts/harness` after every deploy and before a long quiet period. It restores, when archived, and extends by 30 days three kinds of entry only: each contract instance, the Wasm @@ -366,8 +448,9 @@ one instance at a time. **Production modules.** The compliance and identity contracts in this repository are testnet stubs. A production compliance module or identity -registry must extend its own instance and entries the same way, or the share -token stops working when they archive. +registry must extend its own instance and entries the same way, each entry in +the transaction that creates it, or the share token stops working when they +archive. --- From 151c25334988f219e2f84188abdcbb073b1e1834 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 02:46:00 -0300 Subject: [PATCH 097/147] test: check when mainnet first renews an entry The mainnet test only checked that a new balance starts at the network's 120-day minimum. It now also checks the spec's claim about renewals: a read on day 90 leaves the balance alone, and a read on day 91 renews it to 30 days. A second test does the same for a role, which a role check renews on day 31 and not on day 30. Test comments are one line each. --- contracts/async-vault/src/test/ttl.rs | 3 +- contracts/nav-oracle/src/test_governance.rs | 3 +- contracts/share-token/src/test_ttl.rs | 53 +++++++++++++++++---- docs/TESTING.md | 2 +- 4 files changed, 47 insertions(+), 14 deletions(-) diff --git a/contracts/async-vault/src/test/ttl.rs b/contracts/async-vault/src/test/ttl.rs index bab85502..2a7a7485 100644 --- a/contracts/async-vault/src/test/ttl.rs +++ b/contracts/async-vault/src/test/ttl.rs @@ -21,8 +21,7 @@ fn ttl_in>(e: &Env, contract: &Address, key: &K) -> u32 { e.as_contract(contract, || e.storage().persistent().get_ttl(key)) } -/// OpenZeppelin keeps the type of a role's member-list key private. A type -/// with the same variant name and fields encodes the same key. +/// Mirrors OpenZeppelin's private member-list key: same variant and fields, same encoding. #[contracttype] struct RoleAccountKey { role: Symbol, diff --git a/contracts/nav-oracle/src/test_governance.rs b/contracts/nav-oracle/src/test_governance.rs index 27a675b7..abdf1d10 100644 --- a/contracts/nav-oracle/src/test_governance.rs +++ b/contracts/nav-oracle/src/test_governance.rs @@ -941,8 +941,7 @@ fn setup_grants_hand_overs_and_changes_restore_the_instance_ttl() { f.restores_the_instance(|| f.oracle.apply_change(&id)); } -/// OpenZeppelin keeps the type of a role's member-list key private. A type -/// with the same variant name and fields encodes the same key. +/// Mirrors OpenZeppelin's private member-list key: same variant and fields, same encoding. #[contracttype] struct RoleAccountKey { role: Symbol, diff --git a/contracts/share-token/src/test_ttl.rs b/contracts/share-token/src/test_ttl.rs index 0d415d07..cbbee7c4 100644 --- a/contracts/share-token/src/test_ttl.rs +++ b/contracts/share-token/src/test_ttl.rs @@ -1,5 +1,4 @@ -//! An entry the token creates reaches its extend target in the call that -//! creates it, even where the network's minimum lifetime is shorter. +//! A new balance, freeze or role entry gets its full TTL in the call that creates it. use soroban_sdk::{ contract, contractimpl, contracttype, symbol_short, @@ -20,6 +19,7 @@ use compliance::{Compliance, ComplianceClient}; const TESTNET_MIN_PERSISTENT_TTL: u32 = 120_960; /// Mainnet's, in ledgers: longer than any extend target. const MAINNET_MIN_PERSISTENT_TTL: u32 = 2_073_600; +const DAY_IN_LEDGERS: u32 = 17_280; /// Accepts every account and names the recovery target a test sets. #[contract] @@ -67,8 +67,7 @@ impl strata_timelock::ClockSource for VaultStub { } } -/// OpenZeppelin keeps the type of a role's member-list key private. A type -/// with the same variant name and fields encodes the same key. +/// Mirrors OpenZeppelin's private member-list key: same variant and fields, same encoding. #[contracttype] struct RoleAccountKey { role: Symbol, @@ -129,6 +128,12 @@ impl Fixture<'_> { fn order(&self) -> String { String::from_str(&self.e, "court-order-2026-117") } + + fn age_days(&self, days: u32) { + self.e + .ledger() + .with_mut(|l| l.sequence_number += days * DAY_IN_LEDGERS); + } } fn manager_role() -> Symbol { @@ -265,8 +270,7 @@ fn a_recovery_writes_the_freezes_it_carries_with_the_full_ttl() { ); } -/// OpenZeppelin writes a zero balance for an account that sends nothing and -/// held nothing. +/// OpenZeppelin writes a zero balance for an empty account that sends nothing. #[test] fn sending_nothing_from_an_empty_account_writes_its_balance_with_the_full_ttl() { let f = setup(); @@ -367,12 +371,43 @@ fn a_grant_through_the_timelock_writes_the_role_with_the_full_ttl() { } #[test] -fn a_network_minimum_longer_than_the_extend_target_is_kept() { +fn under_mainnets_minimum_a_new_balance_is_first_renewed_on_day_91() { let f = setup(); f.e.ledger() .set_min_persistent_entry_ttl(MAINNET_MIN_PERSISTENT_TTL); - let (_, holder) = f.holder(100); - assert_eq!(f.balance_ttl(&holder), MAINNET_MIN_PERSISTENT_TTL - 1); + + f.age_days(90); + f.token.balance(&holder); + assert_eq!( + f.balance_ttl(&holder), + MAINNET_MIN_PERSISTENT_TTL - 1 - 90 * DAY_IN_LEDGERS + ); + + f.age_days(1); + f.token.balance(&holder); + assert_eq!(f.balance_ttl(&holder), BALANCE_EXTEND_AMOUNT); +} + +#[test] +fn under_mainnets_minimum_a_new_role_is_first_renewed_on_day_31() { + let f = setup(); + f.e.ledger() + .set_min_persistent_entry_ttl(MAINNET_MIN_PERSISTENT_TTL); + let vault = Address::generate(&f.e); + f.token.grant_role(&vault, &manager_role(), &f.admin); + let has_role = AccessControlStorageKey::HasRole(vault.clone(), manager_role()); + assert_eq!(f.persistent_ttl(&has_role), MAINNET_MIN_PERSISTENT_TTL - 1); + + f.age_days(30); + f.token.has_role(&vault, &manager_role()); + assert_eq!( + f.persistent_ttl(&has_role), + MAINNET_MIN_PERSISTENT_TTL - 1 - 30 * DAY_IN_LEDGERS + ); + + f.age_days(1); + f.token.has_role(&vault, &manager_role()); + assert_eq!(f.persistent_ttl(&has_role), ROLE_EXTEND_AMOUNT); } diff --git a/docs/TESTING.md b/docs/TESTING.md index a4176f49..6c9a596a 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -55,7 +55,7 @@ contract extends, who pays, and how to restore an archived one. | 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` | | Every call that writes a share balance or a freeze gives a new one its full TTL, under testnet's minimum lifetime; so do a deposit claim and the first redemption, through the vault | [`contracts/share-token/src/test_ttl.rs`](../contracts/share-token/src/test_ttl.rs), [`contracts/async-vault/src/test/ttl.rs`](../contracts/async-vault/src/test/ttl.rs) | `a_mint_writes_the_new_balance_with_the_full_ttl`, `an_escrow_writes_the_receiving_balance_with_the_full_ttl`, `a_recovery_writes_the_new_balance_with_the_full_ttl`, `sending_nothing_from_an_empty_account_writes_its_balance_with_the_full_ttl`, `a_partial_freeze_writes_its_entry_with_the_full_ttl`, `the_first_redemption_writes_the_vaults_share_balance_with_the_full_ttl` | | Every role grant in the vault, the token and the oracle gives the role's entries their full TTL: in the constructor, during setup and through the timelock | [`contracts/share-token/src/test_ttl.rs`](../contracts/share-token/src/test_ttl.rs), [`contracts/async-vault/src/test/ttl.rs`](../contracts/async-vault/src/test/ttl.rs), [`contracts/nav-oracle/src/test_governance.rs`](../contracts/nav-oracle/src/test_governance.rs) | `the_constructor_writes_each_role_with_the_full_ttl`, `a_grant_during_setup_writes_the_role_with_the_full_ttl`, `a_grant_through_the_timelock_writes_the_role_with_the_full_ttl` | -| A network minimum longer than the target, as on mainnet, is kept | [`contracts/share-token/src/test_ttl.rs`](../contracts/share-token/src/test_ttl.rs) | `a_network_minimum_longer_than_the_extend_target_is_kept` | +| Under mainnet's longer minimum a new entry starts at that minimum; a balance is first renewed on day 91, a role on day 31 | [`contracts/share-token/src/test_ttl.rs`](../contracts/share-token/src/test_ttl.rs) | `under_mainnets_minimum_a_new_balance_is_first_renewed_on_day_91`, `under_mainnets_minimum_a_new_role_is_first_renewed_on_day_31` | | 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. From 891fc79414912e97e8348537eafd00d2700fc1b1 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 02:46:14 -0300 Subject: [PATCH 098/147] docs: say which calls renew each role entry The TTL policy said every role check renews a role's entries and that any read or write extends an entry. A role check extends only that account's role entry; the member-list slot, the member count and the role list are extended only by grants and by calls that list role members. Writing an entry never extends it on its own: the kit's storage helpers extend on every read and write, OpenZeppelin only when it reads an existing entry, so the kit reads back what it creates. A revoke moves entries with plain writes and extends none of them. --- docs/TESTING.md | 2 +- docs/kit-spec.md | 35 ++++++++++++++++++++--------------- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/docs/TESTING.md b/docs/TESTING.md index 6c9a596a..8a23b76d 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -65,7 +65,7 @@ does once an entry is archived. The share token's balances and freezes, and the roles of the vault, the token and the oracle, are stored by the OpenZeppelin Stellar library. It extends an entry when it reads it, not when it creates it, so the contracts read back each -one they write. Without that, a new entry would start at the network's minimum +one they create. Without that, a new entry would start at the network's minimum lifetime. The tests for it set the ledger's minimum to testnet's 120,960 ledgers; the test `Env` defaults to 4,096. OpenZeppelin never extends the instance, so the token, the identity verifier and the compliance contract diff --git a/docs/kit-spec.md b/docs/kit-spec.md index e0a2a62c..5856c667 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -314,12 +314,15 @@ instance is archived refuses every call. The kit runs the same code on every network. Each persistent entry, the contract instance included, has a target lifetime and a threshold one day -shorter. A call that reads or writes an entry extends it back to its target -once its TTL has fallen to the threshold. The transaction that creates an entry -extends it to its target at once; the compliance stub's modules and bound -tokens are the one exception (see **What OpenZeppelin extends**). The network -decides only how long a new entry lives before any extension (see **Network -settings**). +shorter. Writing an entry does not extend it on its own: an entry goes back to +its target only when a call asks for that and its TTL has fallen to the +threshold. The kit's storage helpers ask on every read and every write of the +kit's own entries. OpenZeppelin asks when it reads an entry that already +exists, never when it writes one, so the kit reads back each entry it creates +through OpenZeppelin. The transaction that creates an entry therefore extends +it to its target at once; the compliance stub's modules and bound tokens are +the one exception (see **What OpenZeppelin extends**). The network decides only +how long a new entry lives before any extension (see **Network settings**). | Contract | What it extends | When | To how long | |---|---|---|---| @@ -328,7 +331,7 @@ settings**). | `share_token` | Its instance, each balance and freeze entry it writes, 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`, `legal_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, and each pending governance change it reads or writes | Every hook the token calls (`can_create`, `can_transfer`, `created`, `transferred`, `destroyed`) and its admin calls | Same | -| `async_vault`, `share_token`, `nav_oracle` | The role entries each grant writes | Each grant (in the constructor, `grant_role` and an applied `GrantRole` change), then every role check | 90 days (1,555,200 ledgers), once 89 days or less remain | +| `async_vault`, `share_token`, `nav_oracle` | The four entries a grant writes: the account's role, its member-list slot, the member count and the role list | Each grant (in the constructor, `grant_role` and an applied `GrantRole` change) extends all four. After that, a role check extends only that account's role entry; the slot, the count and the list are extended only by grants and by calls that list role members (such as the vault's `manager()` or the token's `get_role_member_count`) | 90 days (1,555,200 ledgers), once 89 days or less remain | The kit's own lengths are the constants in `crates/strata-storage` (`PERSISTENT_EXTEND_AMOUNT`, `INSTANCE_EXTEND_AMOUNT`, and thresholds one day @@ -352,7 +355,7 @@ stores the share token's balances and freeze entries, and the compliance contract's modules and bound tokens. `stellar-access` 0.7.2 stores the roles of the vault, the token and the oracle. OpenZeppelin extends an existing entry when it reads it: balances, freezes, modules and bound tokens to 30 days, roles -to 90. It does not extend an entry when it creates one, so a new entry would +to 90. It does not extend an entry when it writes one, so a new entry would start at the network's minimum lifetime and wait for a later caller to read it. The kit closes that gap by reading back what it writes, through OpenZeppelin's own reads, in the same call: @@ -368,13 +371,15 @@ own reads, in the same call: So a new balance, freeze or role entry reaches its target in the transaction that creates it, and the kit keeps OpenZeppelin's thresholds without copying -them. The compliance stub does not read back its modules and bound tokens when -it writes them. The vault's `finish_setup` reads the bound tokens, which +them. A revoke creates nothing: it rewrites the member count and moves the last +member into the freed slot with plain writes, and extends none of the entries +it moves. The compliance stub does not read back its modules and bound tokens +when it writes them. The vault's `finish_setup` reads the bound tokens, which extends them, and every hook the token calls reads its modules; the hooks after a mint, transfer or burn read the bound tokens too. Allowances are temporary -entries that live until their `live_until_ledger`. OpenZeppelin never extends an -instance; the kit does, as above. The token's total supply and metadata live in -its instance. +entries that live until their `live_until_ledger`. OpenZeppelin never extends +an instance; the kit does, as above. The token's total supply and metadata live +in its instance. **Network settings.** Each network sets how long a new persistent entry lives and how far one extension can reach. Validators can change these settings by @@ -392,7 +397,7 @@ Read over RPC on 2026-10-20. **What this means on each network.** On testnet a new entry would live about 7 days, less than any threshold, so renewals start in its first week: the transaction that creates it extends it to its target, and from the next day any -call that touches it renews it. On mainnet a new entry already lives 120 +call that reads it renews it. On mainnet a new entry already lives 120 days, more than any target, so the extension in the creating transaction changes nothing. No call renews a 30-day entry there until about the fourth month (day 91), or a role until day 31. A restored entry comes back with the @@ -419,7 +424,7 @@ restore pays for the network's minimum, as above. **What frontends should do.** - Expect uneven renewal fees. The first call after a quiet period pays to renew - every entry it touches that has fallen to its threshold, so the same call can + every entry it reads that has fallen to its threshold, so the same call can cost more after a quiet week than on a busy day. - Simulate again just before submitting. The fee comes from the simulation. If an entry falls to its threshold between simulation and submission, the call From bdd966d2fe44495d3a6c5c9579e1eb93f82ac744 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:07:57 -0300 Subject: [PATCH 099/147] feat: add error code ranges for kit contracts Each kit contract owns a block of error codes, and a failure inside a called contract reaches the caller with that contract's code. The blocks live in strata-bindings with a check that they never overlap, and error_enum! declares an error enum whose build fails when a variant's code leaves its contract's block. --- crates/strata-bindings/src/errors.rs | 100 +++++++++++++++++++++++++++ crates/strata-bindings/src/lib.rs | 4 ++ 2 files changed, 104 insertions(+) create mode 100644 crates/strata-bindings/src/errors.rs diff --git a/crates/strata-bindings/src/errors.rs b/crates/strata-bindings/src/errors.rs new file mode 100644 index 00000000..1197ca54 --- /dev/null +++ b/crates/strata-bindings/src/errors.rs @@ -0,0 +1,100 @@ +//! The block of error codes each kit contract owns. A failure inside a called +//! contract reaches the caller with that contract's code, so the blocks never +//! overlap. + +/// An inclusive block of contract error codes. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ErrorRange { + pub first: u32, + pub last: u32, +} + +impl ErrorRange { + pub const fn contains(&self, code: u32) -> bool { + self.first <= code && code <= self.last + } +} + +pub const ORACLE_ERRORS: ErrorRange = ErrorRange { + first: 3000, + last: 3999, +}; +pub const VAULT_ERRORS: ErrorRange = ErrorRange { + first: 6000, + last: 6999, +}; +pub const TOKEN_ERRORS: ErrorRange = ErrorRange { + first: 7000, + last: 7999, +}; +pub const COMPLIANCE_ERRORS: ErrorRange = ErrorRange { + first: 8000, + last: 8999, +}; + +const _: () = { + let ranges = [ORACLE_ERRORS, VAULT_ERRORS, TOKEN_ERRORS, COMPLIANCE_ERRORS]; + let mut i = 1; + while i < ranges.len() { + assert!( + ranges[i - 1].last < ranges[i].first, + "two contracts' error ranges overlap" + ); + i += 1; + } +}; + +/// Declares a contract's error enum. The build fails if a variant's code +/// leaves the range the contract owns. +/// +/// ``` +/// strata_bindings::error_enum! { +/// strata_bindings::VAULT_ERRORS, +/// #[repr(u32)] +/// pub enum DemoError { +/// First = 6000, +/// Last = 6999, +/// } +/// } +/// assert_eq!(DemoError::Last as u32, 6999); +/// ``` +/// +/// ```compile_fail,E0080 +/// strata_bindings::error_enum! { +/// strata_bindings::VAULT_ERRORS, +/// #[repr(u32)] +/// pub enum DemoError { +/// First = 6000, +/// FromTheToken = 7000, +/// } +/// } +/// ``` +#[macro_export] +macro_rules! error_enum { + ( + $range:path, + $(#[$attr:meta])* + $vis:vis enum $name:ident { + // `tt`, not `literal`: `contracterror` reads each code as a bare literal. + $($(#[$variant_attr:meta])* $variant:ident = $code:tt),+ $(,)? + } + ) => { + $(#[$attr])* + $vis enum $name { + $($(#[$variant_attr])* $variant = $code,)+ + } + + const _: () = { + let range: $crate::ErrorRange = $range; + $(assert!( + range.contains($code), + concat!( + stringify!($name), + "::", + stringify!($variant), + " is outside its contract's error range", + ), + );)+ + }; + }; +} diff --git a/crates/strata-bindings/src/lib.rs b/crates/strata-bindings/src/lib.rs index eee072f1..98f582c1 100644 --- a/crates/strata-bindings/src/lib.rs +++ b/crates/strata-bindings/src/lib.rs @@ -1,7 +1,11 @@ #![no_std] +mod errors; + use soroban_sdk::{contracttrait, contracttype, Address, Env}; +pub use errors::{ErrorRange, COMPLIANCE_ERRORS, ORACLE_ERRORS, TOKEN_ERRORS, VAULT_ERRORS}; + #[contracttype] #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum OracleState { From c138dfa320199342344588f537a9f9096f9a8431 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:08:32 -0300 Subject: [PATCH 100/147] feat: declare each error enum within its range The oracle, vault, share token and compliance enums go through error_enum!, so a variant outside 3000-3999, 6000-6999, 7000-7999 or 8000-8999 fails the build. Every variant now says when it happens: InvalidConfig and NoRecord get a doc, and AmountTooLarge, AccountingMismatch, InvalidAmount, WindDownActive, NotInitialized and the oracle's AdminRequired describe what the code checks. Each enum's doc names its range. --- contracts/async-vault/src/error.rs | 325 +++++++++++++++-------------- contracts/compliance/src/lib.rs | 69 +++--- contracts/nav-oracle/src/lib.rs | 125 ++++++----- contracts/share-token/src/error.rs | 106 +++++----- 4 files changed, 328 insertions(+), 297 deletions(-) diff --git a/contracts/async-vault/src/error.rs b/contracts/async-vault/src/error.rs index 6d8420b4..43f91984 100644 --- a/contracts/async-vault/src/error.rs +++ b/contracts/async-vault/src/error.rs @@ -1,161 +1,170 @@ use soroban_sdk::contracterror; -/// Vault failures. Codes are stable once assigned and are never reused; a -/// removed variant leaves its number retired. -#[contracterror] -#[derive(Copy, Clone, Debug, Eq, PartialEq)] -#[repr(u32)] -pub enum VaultError { - /// The constructor was given the same address for two authorities that - /// must be held separately. - RolesNotDistinct = 6000, - /// The holder has no request in the given epoch. - RequestNotFound = 6001, - /// An amount or share quantity of zero or less was specified. - InvalidAmount = 6007, - /// The holder already has an unclaimed request in this epoch. A - /// second request is rejected rather than added to the first, so that at - /// most one request per holder per epoch holds by construction. - RequestOutstanding = 6009, - /// The epoch total would exceed `i128::MAX`. - AmountTooLarge = 6014, - /// No epoch is stored under the given id. - EpochNotFound = 6029, - /// An entry the constructor writes is absent from instance storage, which - /// means the instance was archived or the contract was never constructed. - NotInitialized = 6030, - /// The oracle returned a share price of zero or less. Unreachable with a - /// correctly configured feed; kept because the oracle sits behind a - /// settable address. - InvalidSharePrice = 6031, - /// The epoch is not `Open`, so it cannot be closed or take new requests. - EpochNotOpen = 6032, - /// The epoch is not `Pending`, so it cannot be priced. An - /// epoch must be closed before it can be fulfilled. - EpochNotPending = 6038, - /// The epoch counter would exceed `u64::MAX`. - EpochOverflow = 6033, - /// The epoch has not been fulfilled yet, so it has no share price to - /// claim against. - EpochNotFulfilled = 6034, - /// The request was already claimed. Claiming is idempotent by rejection, - /// not by silently minting nothing twice. - AlreadyClaimed = 6035, - /// The vault does not hold enough to pay this claim yet. The liability - /// stands and the claim succeeds once the reserve covers it. - ClaimNotCovered = 6037, - /// The amount would deploy assets already owed to holders whose exit has - /// been priced but not yet claimed. - ReserveCommittedToExits = 6005, - /// No custodian has been set, so capital has nowhere to go. - CustodianNotSet = 6012, - /// The epoch is priced, so the request can no longer be cancelled. The - /// holder claims instead. - AlreadyPriced = 6039, - /// The call needs the admin. `unpause` refuses any other caller. - /// `renounce_admin` is always refused, because the vault must always have - /// an admin: governance hands over in two steps rather than stepping down - /// into nobody. - AdminRequired = 6040, - /// The epoch is sealed and the feed can price it, so the price it will take - /// is already knowable. Cancelling now would be declining a price after - /// seeing it. Fulfil and claim instead. - PriceAvailable = 6041, - /// The epoch closed less than its notice ago, so its price is not fixed - /// yet and every request in it is still waiting. - NoticeNotElapsed = 6042, - /// The standing valuation was accepted before the epoch closed. Pricing - /// against it would use a number the close already outran. - AttestationBeforeClose = 6043, - /// The feed is paused or stale, so the epoch has no price to take. - FeedNotValid = 6044, - /// The notice exceeds the longest the vault accepts. A notice no investor - /// could outlive would strand their exit with no way to undo it. - NoticeTooLong = 6045, - /// The vault is winding down. Entry, pricing and transfers to the custodian - /// are closed for the rest of its life. - WindDownActive = 6046, - /// No wind-down proposal stands. - WindDownNotProposed = 6047, - /// A proposal already stands. Re-proposing would let governance push the - /// activation date back indefinitely. - WindDownAlreadyProposed = 6048, - /// The announcement has not run its delay yet. - WindDownDelayNotElapsed = 6049, - /// The wind-down has not been activated, so there is nothing to distribute. - WindDownNotActive = 6050, - /// No round has been finalised, so there is no snapshot to claim against. - DistributionNotStarted = 6051, - /// The round would distribute nothing, or there is no supply to divide by. - NothingToDistribute = 6052, - /// The delay exceeds the longest the vault accepts. A delay nobody could - /// outlive would mean the vault never closes. - WindDownDelayTooLong = 6053, - /// Nothing to surrender and nothing owed. Claiming is idempotent by - /// rejection, as elsewhere in this contract. - NoEntitlement = 6054, - /// A code change already waits. Replacing one means cancelling it first, so - /// the standing eta is never quietly moved. - UpgradeProposalExists = 6055, - /// No pending change has this id. - ChangeNotFound = 6056, - /// The change's delay has not elapsed. - ChangeNotReady = 6057, - /// The delay is below the shortest the vault accepts. - UpgradeDelayTooShort = 6058, - /// The delay is shorter than the notice, so an investor could not complete - /// an exit before the change applies. Checked when a change is proposed and - /// again when it applies, since the notice may have risen in between. - UpgradeDelayBelowNotice = 6059, - /// The delay exceeds the longest the vault accepts. - UpgradeDelayTooLong = 6060, - /// The notice exceeds the upgrade delay, so an investor could not complete - /// an exit before an upgrade lands. - NoticeAboveUpgradeDelay = 6061, - /// A deposit request would breach the vault's configured deposit cap. - DepositCapExceeded = 6062, - /// As many changes as the timelock holds already wait. Apply or cancel one - /// first. - TooManyPendingChanges = 6063, - /// Setup is still open, so the timelock protects nothing yet: the vault - /// takes no deposit and queues no change. Governance finishes setup first. - SetupOpen = 6064, - /// A direct call cannot make this change. After setup it goes through - /// `propose_change`; setting a role admin is never allowed. - UseTimelock = 6065, - /// Setup has finished, and it never reopens. - SetupClosed = 6066, - /// A wiring check `health()` reports is false (token, oracle and compliance - /// bound, sole token manager, allowlisted, not frozen, matching decimals, - /// distinct addresses, a feed that answers, a custodian) or the token has - /// shares outstanding, so setup cannot finish. - SetupIncomplete = 6067, - /// The holder fails the share token's identity check, so the vault takes no - /// deposit request from them and returns no escrowed shares to them. - NotAllowlisted = 6068, - /// The wind-down delay is below the shortest the vault accepts. - WindDownDelayTooShort = 6069, - /// A running total would go below zero, which means the vault's books - /// disagree with themselves. Fails loudly instead of clamping to zero. - AccountingMismatch = 6070, - /// The holder is frozen. What the vault owes them is held, not paid, - /// returned or moved, until compliance lifts the freeze. A partial freeze - /// refuses only what would touch frozen shares. - Frozen = 6071, - /// The proposed feed does not name this vault as its own, its setup is - /// still open, or it does not answer `state()` and `freshness_duration()`. - /// Its admin could then change its limits with no delay, or no epoch could - /// be priced from it. - FeedNotBound = 6072, - /// The holder may not receive shares (delisted, frozen or refused by the - /// compliance rules), so the escrowed shares stay with the vault and the - /// request stays open. - HolderNotEligible = 6073, - /// 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 = 6074, - /// The custodian would be the vault itself. Capital deployed to it would - /// never leave, and the books would count it as both held and deployed. - InvalidCustodian = 6075, +strata_bindings::error_enum! { + strata_bindings::VAULT_ERRORS, + + /// Vault failures, coded 6000 to 6999. Codes are stable once assigned and + /// are never reused; a removed variant leaves its number retired. + #[contracterror] + #[derive(Copy, Clone, Debug, Eq, PartialEq)] + #[repr(u32)] + pub enum VaultError { + /// The constructor was given the same address for two authorities that + /// must be held separately. + RolesNotDistinct = 6000, + /// The holder has no request in the given epoch. + RequestNotFound = 6001, + /// An amount or share quantity of zero or less was specified, or a + /// negative deposit cap. + InvalidAmount = 6007, + /// The holder already has an unclaimed request in this epoch. A + /// second request is rejected rather than added to the first, so that at + /// most one request per holder per epoch holds by construction. + RequestOutstanding = 6009, + /// A sum, count or conversion would not fit its type: a running total or + /// a conversion of assets or shares past `i128`, or an epoch's count of + /// open requests past `u32`. + AmountTooLarge = 6014, + /// No epoch is stored under the given id. + EpochNotFound = 6029, + /// An entry the constructor writes is missing from instance storage. A + /// constructed vault always has it. + NotInitialized = 6030, + /// The oracle returned a share price of zero or less. Unreachable with a + /// correctly configured feed; kept because the oracle sits behind a + /// settable address. + InvalidSharePrice = 6031, + /// The epoch is not `Open`, so it cannot be closed or take new requests. + EpochNotOpen = 6032, + /// The epoch is not `Pending`, so it cannot be priced. An + /// epoch must be closed before it can be fulfilled. + EpochNotPending = 6038, + /// The epoch counter would exceed `u64::MAX`. + EpochOverflow = 6033, + /// The epoch has not been fulfilled yet, so it has no share price to + /// claim against. + EpochNotFulfilled = 6034, + /// The request was already claimed. Claiming is idempotent by rejection, + /// not by silently minting nothing twice. + AlreadyClaimed = 6035, + /// The vault does not hold enough to pay this claim yet. The liability + /// stands and the claim succeeds once the reserve covers it. + ClaimNotCovered = 6037, + /// The amount would deploy assets already owed to holders whose exit has + /// been priced but not yet claimed. + ReserveCommittedToExits = 6005, + /// No custodian has been set, so capital has nowhere to go. + CustodianNotSet = 6012, + /// The epoch is priced, so the request can no longer be cancelled. The + /// holder claims instead. + AlreadyPriced = 6039, + /// The call needs the admin. `unpause` refuses any other caller. + /// `renounce_admin` is always refused, because the vault must always have + /// an admin: governance hands over in two steps rather than stepping down + /// into nobody. + AdminRequired = 6040, + /// The epoch is sealed and the feed can price it, so the price it will take + /// is already knowable. Cancelling now would be declining a price after + /// seeing it. Fulfil and claim instead. + PriceAvailable = 6041, + /// The epoch closed less than its notice ago, so its price is not fixed + /// yet and every request in it is still waiting. + NoticeNotElapsed = 6042, + /// The standing valuation was accepted before the epoch closed. Pricing + /// against it would use a number the close already outran. + AttestationBeforeClose = 6043, + /// The feed is paused or stale, so the epoch has no price to take. + FeedNotValid = 6044, + /// The notice exceeds the longest the vault accepts. A notice no investor + /// could outlive would strand their exit with no way to undo it. + NoticeTooLong = 6045, + /// The vault is winding down. New requests, epoch closes, pricing, + /// transfers to the custodian and governance changes are closed for the + /// rest of its life. + WindDownActive = 6046, + /// No wind-down proposal stands. + WindDownNotProposed = 6047, + /// A proposal already stands. Re-proposing would let governance push the + /// activation date back indefinitely. + WindDownAlreadyProposed = 6048, + /// The announcement has not run its delay yet. + WindDownDelayNotElapsed = 6049, + /// The wind-down has not been activated, so there is nothing to distribute. + WindDownNotActive = 6050, + /// No round has been finalised, so there is no snapshot to claim against. + DistributionNotStarted = 6051, + /// The round would distribute nothing, or there is no supply to divide by. + NothingToDistribute = 6052, + /// The delay exceeds the longest the vault accepts. A delay nobody could + /// outlive would mean the vault never closes. + WindDownDelayTooLong = 6053, + /// Nothing to surrender and nothing owed. Claiming is idempotent by + /// rejection, as elsewhere in this contract. + NoEntitlement = 6054, + /// A code change already waits. Replacing one means cancelling it first, so + /// the standing eta is never quietly moved. + UpgradeProposalExists = 6055, + /// No pending change has this id. + ChangeNotFound = 6056, + /// The change's delay has not elapsed. + ChangeNotReady = 6057, + /// The delay is below the shortest the vault accepts. + UpgradeDelayTooShort = 6058, + /// The delay is shorter than the notice, so an investor could not complete + /// an exit before the change applies. Checked when a change is proposed and + /// again when it applies, since the notice may have risen in between. + UpgradeDelayBelowNotice = 6059, + /// The delay exceeds the longest the vault accepts. + UpgradeDelayTooLong = 6060, + /// The notice exceeds the upgrade delay, so an investor could not complete + /// an exit before an upgrade lands. + NoticeAboveUpgradeDelay = 6061, + /// A deposit request would breach the vault's configured deposit cap. + DepositCapExceeded = 6062, + /// As many changes as the timelock holds already wait. Apply or cancel one + /// first. + TooManyPendingChanges = 6063, + /// Setup is still open, so the timelock protects nothing yet: the vault + /// takes no deposit and queues no change. Governance finishes setup first. + SetupOpen = 6064, + /// A direct call cannot make this change. After setup it goes through + /// `propose_change`; setting a role admin is never allowed. + UseTimelock = 6065, + /// Setup has finished, and it never reopens. + SetupClosed = 6066, + /// A wiring check `health()` reports is false (token, oracle and compliance + /// bound, sole token manager, allowlisted, not frozen, matching decimals, + /// distinct addresses, a feed that answers, a custodian) or the token has + /// shares outstanding, so setup cannot finish. + SetupIncomplete = 6067, + /// The holder fails the share token's identity check, so the vault takes no + /// deposit request from them and returns no escrowed shares to them. + NotAllowlisted = 6068, + /// The wind-down delay is below the shortest the vault accepts. + WindDownDelayTooShort = 6069, + /// A running total would go below zero, or the deposited capital the cap + /// is checked against would go below `i128::MIN`. Either means the vault's + /// books disagree with themselves; it fails loudly instead of clamping. + AccountingMismatch = 6070, + /// The holder is frozen. What the vault owes them is held, not paid, + /// returned or moved, until compliance lifts the freeze. A partial freeze + /// refuses only what would touch frozen shares. + Frozen = 6071, + /// The proposed feed does not name this vault as its own, its setup is + /// still open, or it does not answer `state()` and `freshness_duration()`. + /// Its admin could then change its limits with no delay, or no epoch could + /// be priced from it. + FeedNotBound = 6072, + /// The holder may not receive shares (delisted, frozen or refused by the + /// compliance rules), so the escrowed shares stay with the vault and the + /// request stays open. + HolderNotEligible = 6073, + /// 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 = 6074, + /// The custodian would be the vault itself. Capital deployed to it would + /// never leave, and the books would count it as both held and deployed. + InvalidCustodian = 6075, + } } diff --git a/contracts/compliance/src/lib.rs b/contracts/compliance/src/lib.rs index 34f9b4d0..5b7822b6 100644 --- a/contracts/compliance/src/lib.rs +++ b/contracts/compliance/src/lib.rs @@ -20,38 +20,43 @@ use strata_bindings::VaultBinding; pub use governance::{ComplianceChange, CompliancePendingChange, ADMIN_ACCEPT_LEDGERS}; -/// Compliance contract failures. Codes are stable once assigned and are never -/// reused; a removed variant leaves its number retired. -#[contracterror] -#[derive(Copy, Clone, Debug, Eq, PartialEq)] -#[repr(u32)] -pub enum ComplianceContractError { - /// No pending change has this id. - ChangeNotFound = 8001, - /// The change's delay has not elapsed. - ChangeNotReady = 8002, - /// As many changes as the timelock holds already wait. - TooManyPendingChanges = 8003, - /// Changes are instant until setup finishes; there is nothing to propose yet. - SetupOpen = 8004, - /// A direct call cannot make this change after setup: it goes through - /// `propose_change`. - UseTimelock = 8005, - /// Only possible while setup is open, and only once. - SetupClosed = 8006, - /// Setup cannot finish before the contract is bound to its vault. - VaultNotBound = 8007, - /// The vault is winding down, so no change may be proposed or applied. - WindDownActive = 8008, - /// The vault is paused, which holds every timelocked change. - VaultPaused = 8009, - /// The change's delay is shorter than the vault's notice, so an investor - /// could not complete an exit before it lands. - DelayBelowNotice = 8010, - /// 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 = 8011, +strata_bindings::error_enum! { + strata_bindings::COMPLIANCE_ERRORS, + + /// Compliance contract failures, coded 8000 to 8999. Codes are stable once + /// assigned and are never reused; a removed variant leaves its number + /// retired. + #[contracterror] + #[derive(Copy, Clone, Debug, Eq, PartialEq)] + #[repr(u32)] + pub enum ComplianceContractError { + /// No pending change has this id. + ChangeNotFound = 8001, + /// The change's delay has not elapsed. + ChangeNotReady = 8002, + /// As many changes as the timelock holds already wait. + TooManyPendingChanges = 8003, + /// Changes are instant until setup finishes; there is nothing to propose yet. + SetupOpen = 8004, + /// A direct call cannot make this change after setup: it goes through + /// `propose_change`. + UseTimelock = 8005, + /// Only possible while setup is open, and only once. + SetupClosed = 8006, + /// Setup cannot finish before the contract is bound to its vault. + VaultNotBound = 8007, + /// The vault is winding down, so no change may be proposed or applied. + WindDownActive = 8008, + /// The vault is paused, which holds every timelocked change. + VaultPaused = 8009, + /// The change's delay is shorter than the vault's notice, so an investor + /// could not complete an exit before it lands. + DelayBelowNotice = 8010, + /// 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 = 8011, + } } #[contractevent] diff --git a/contracts/nav-oracle/src/lib.rs b/contracts/nav-oracle/src/lib.rs index f94deabb..99623779 100644 --- a/contracts/nav-oracle/src/lib.rs +++ b/contracts/nav-oracle/src/lib.rs @@ -32,62 +32,75 @@ pub const MAX_FRESHNESS_SECS: u64 = 35 * strata_timelock::DAY_DURATION; /// below where the deviation arithmetic could overflow. pub const MAX_ANSWER: i128 = 1_000_000 * WAD; -#[contracterror] -#[derive(Copy, Clone, Debug, Eq, PartialEq)] -#[repr(u32)] -pub enum OracleError { - InvalidConfig = 3001, - /// `nav_per_share <= 0`, or outside `[min_answer, max_answer]`. - NavOutOfBand = 3002, - /// Attested before `cooldown_secs` elapsed since the last record. - CooldownActive = 3003, - /// The move from the last record exceeds `max_up_bps` (a rise) or - /// `max_down_bps` (a fall). - DeviationExceeded = 3004, - /// `expires_at` not in the future — the record would be born stale. - ExpiresInPast = 3005, - NoRecord = 3006, - /// `ensure_consumable` found the feed in a state other than `Valid`. - NotConsumable = 3007, - /// `clear_latest` was called while the NAV was not paused. - NavNotPaused = 3008, - /// 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, - /// The configuration the constructor writes is absent from instance - /// storage. - NotInitialized = 3026, +strata_bindings::error_enum! { + strata_bindings::ORACLE_ERRORS, + + /// Oracle failures, coded 3000 to 3999. Codes are stable once assigned and + /// are never reused; a removed variant leaves its number retired. + #[contracterror] + #[derive(Copy, Clone, Debug, Eq, PartialEq)] + #[repr(u32)] + pub enum OracleError { + /// A config breaks a bound: `freshness_duration` from 1 s to 35 days, + /// `cooldown_secs` from 1 s to `freshness_duration`, `min_answer` above + /// zero and at most `max_answer`, `max_answer` at most `MAX_ANSWER`, + /// and each move cap from 1 to 10,000 bps. + InvalidConfig = 3001, + /// `nav_per_share <= 0`, or outside `[min_answer, max_answer]`. + NavOutOfBand = 3002, + /// Attested before `cooldown_secs` elapsed since the last record. + CooldownActive = 3003, + /// The move from the last record exceeds `max_up_bps` (a rise) or + /// `max_down_bps` (a fall). + DeviationExceeded = 3004, + /// `expires_at` not in the future — the record would be born stale. + ExpiresInPast = 3005, + /// No report is stored: none has been accepted yet, or `clear_latest` + /// removed it. `latest`, `nav_per_share` and `attested_at` fail with it. + NoRecord = 3006, + /// `ensure_consumable` found the feed in a state other than `Valid`. + NotConsumable = 3007, + /// `clear_latest` was called while the NAV was not paused. + NavNotPaused = 3008, + /// `renounce_admin` is always refused: 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, + /// The configuration the constructor writes is absent from instance + /// storage. + NotInitialized = 3026, + } } #[contractevent] diff --git a/contracts/share-token/src/error.rs b/contracts/share-token/src/error.rs index 7d9779b5..276c38b7 100644 --- a/contracts/share-token/src/error.rs +++ b/contracts/share-token/src/error.rs @@ -1,54 +1,58 @@ use soroban_sdk::contracterror; -/// Share token failures. Codes are stable once assigned and are never reused; a -/// removed variant leaves its number retired. -#[contracterror] -#[derive(Copy, Clone, Debug, Eq, PartialEq)] -#[repr(u32)] -pub enum ShareTokenError { - /// The call needs the admin. `pause`, `unpause`, `set_compliance` and - /// `set_identity_verifier` refuse any other caller. `renounce_admin` is - /// always refused, because the token must always have an admin: hand it - /// over instead by proposing and applying an `Admin` change - /// (`transfer_admin_role` during setup), then the nominee calls - /// `accept_admin_transfer`. - AdminRequired = 7000, - /// No pending change has this id. - ChangeNotFound = 7001, - /// The change's delay has not elapsed. - ChangeNotReady = 7002, - /// As many changes as the timelock holds already wait. - TooManyPendingChanges = 7003, - /// Changes are instant until setup finishes; there is nothing to propose yet. - SetupOpen = 7004, - /// A direct call cannot make this change. After setup it goes through - /// `propose_change`; setting a role admin is never allowed. - UseTimelock = 7005, - /// Only possible while setup is open, and only once. - SetupClosed = 7006, - /// Setup cannot finish before the token is bound to its vault. - VaultNotBound = 7007, - /// The vault is winding down, so no change may be proposed or applied. - WindDownActive = 7008, - /// The vault is paused, which holds every timelocked change. - VaultPaused = 7009, - /// The change's delay is shorter than the vault's notice, so an investor - /// could not complete an exit before it lands. - DelayBelowNotice = 7010, - /// An upgrade already waits. Cancel it before proposing another. - UpgradeAlreadyPending = 7011, - /// The vault's own address cannot be frozen, and a legal transfer or a - /// recovery never moves shares out of it or into it: its balance is other - /// investors' escrow. - VaultAddress = 7012, - /// A legal transfer or a recovery needs the compliance authority and the - /// admin as two different accounts, so no single key moves value. - CoSignRequired = 7013, - /// A legal transfer names the order behind it: a reference of 1 to 128 - /// bytes. - InvalidReference = 7015, - /// 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 = 7016, +strata_bindings::error_enum! { + strata_bindings::TOKEN_ERRORS, + + /// Share token failures, coded 7000 to 7999. Codes are stable once assigned + /// and are never reused; a removed variant leaves its number retired. + #[contracterror] + #[derive(Copy, Clone, Debug, Eq, PartialEq)] + #[repr(u32)] + pub enum ShareTokenError { + /// The call needs the admin. `pause`, `unpause`, `set_compliance` and + /// `set_identity_verifier` refuse any other caller. `renounce_admin` is + /// always refused, because the token must always have an admin: hand it + /// over instead by proposing and applying an `Admin` change + /// (`transfer_admin_role` during setup), then the nominee calls + /// `accept_admin_transfer`. + AdminRequired = 7000, + /// No pending change has this id. + ChangeNotFound = 7001, + /// The change's delay has not elapsed. + ChangeNotReady = 7002, + /// As many changes as the timelock holds already wait. + TooManyPendingChanges = 7003, + /// Changes are instant until setup finishes; there is nothing to propose yet. + SetupOpen = 7004, + /// A direct call cannot make this change. After setup it goes through + /// `propose_change`; setting a role admin is never allowed. + UseTimelock = 7005, + /// Only possible while setup is open, and only once. + SetupClosed = 7006, + /// Setup cannot finish before the token is bound to its vault. + VaultNotBound = 7007, + /// The vault is winding down, so no change may be proposed or applied. + WindDownActive = 7008, + /// The vault is paused, which holds every timelocked change. + VaultPaused = 7009, + /// The change's delay is shorter than the vault's notice, so an investor + /// could not complete an exit before it lands. + DelayBelowNotice = 7010, + /// An upgrade already waits. Cancel it before proposing another. + UpgradeAlreadyPending = 7011, + /// The vault's own address cannot be frozen, and a legal transfer or a + /// recovery never moves shares out of it or into it: its balance is other + /// investors' escrow. + VaultAddress = 7012, + /// A legal transfer or a recovery needs the compliance authority and the + /// admin as two different accounts, so no single key moves value. + CoSignRequired = 7013, + /// A legal transfer names the order behind it: a reference of 1 to 128 + /// bytes. + InvalidReference = 7015, + /// 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 = 7016, + } } From df23868319e150121b19644887c6e2f9d546cd22 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:08:41 -0300 Subject: [PATCH 101/147] docs: document the oracle entrypoints Every oracle entrypoint and the trait methods it overrides carry a doc in the contract spec: a summary, the arguments with units, the errors, the events and who signs. --- contracts/nav-oracle/src/lib.rs | 324 ++++++++++++++++++++++++++++++-- 1 file changed, 310 insertions(+), 14 deletions(-) diff --git a/contracts/nav-oracle/src/lib.rs b/contracts/nav-oracle/src/lib.rs index 99623779..fc6c0983 100644 --- a/contracts/nav-oracle/src/lib.rs +++ b/contracts/nav-oracle/src/lib.rs @@ -196,9 +196,28 @@ 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. + /// Grants `role` to `account` at once. Only during setup; after it, propose + /// a `GrantRole` change. Revoking stays instant: taking an attester or a + /// guardian away moves no value. + /// + /// # Arguments + /// + /// * `account` - The account that gets the role. + /// * `role` - `attester` or `guardian`. + /// * `caller` - The admin. + /// + /// # Errors + /// + /// * `UseTimelock` - Setup has finished. + /// * `AccessControlError::Unauthorized` - `caller` is not the admin. + /// + /// # Events + /// + /// * `RoleGranted`, unless `account` already holds `role`. + /// + /// # Authorization + /// + /// `caller` signs. fn grant_role(e: &Env, account: Address, role: Symbol, caller: Address) { guards::direct_change_allowed(e); strata_storage::bump_instance(e); @@ -206,8 +225,32 @@ impl AccessControl for NavOracleContract { extend::role_entries(e, &account, &role); } - /// Cancelling a pending hand-over (`live_until_ledger == 0`) keeps the - /// current admin, so it stays instant. + /// Offers the admin role to `new_admin`, who takes it with + /// `accept_admin_transfer`. An offer is instant only during setup; after + /// it, propose an `Admin` change. Cancelling an offer stays instant. + /// + /// # Arguments + /// + /// * `new_admin` - The nominee, or the pending nominee when cancelling. + /// * `live_until_ledger` - The last ledger number the nominee can accept + /// in; `0` cancels the pending offer. + /// + /// # Errors + /// + /// * `UseTimelock` - An offer after setup. + /// * `RoleTransferError::InvalidLiveUntilLedger` - The ledger has passed or + /// is beyond the longest entry lifetime. + /// * `RoleTransferError::NoPendingTransfer` - Nothing to cancel. + /// * `RoleTransferError::InvalidPendingAccount` - The cancel names another + /// nominee. + /// + /// # Events + /// + /// * `AdminTransferInitiated` + /// + /// # Authorization + /// + /// The admin signs. fn transfer_admin_role(e: &Env, new_admin: Address, live_until_ledger: u32) { guards::direct_hand_over_allowed(e, live_until_ledger); strata_storage::bump_instance(e); @@ -215,6 +258,14 @@ impl AccessControl for NavOracleContract { } /// Refused. A role admin could grant that role with no delay. + /// + /// # Arguments + /// + /// * `role`, `admin_role` - Not read. + /// + /// # Errors + /// + /// * `UseTimelock` - Always. fn set_role_admin(e: &Env, _role: Symbol, _admin_role: Symbol) { panic_with_error!(e, OracleError::UseTimelock); } @@ -223,6 +274,10 @@ impl AccessControl for NavOracleContract { /// 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`. + /// + /// # Errors + /// + /// * `AdminRequired` - Always. fn renounce_admin(e: &Env) { panic_with_error!(e, OracleError::AdminRequired); } @@ -230,10 +285,13 @@ impl AccessControl for NavOracleContract { #[contractimpl(contracttrait)] impl VaultBinding for NavOracleContract { + /// The vault whose timelock this oracle's changes wait for, once + /// `bind_vault` has named it. fn vault(e: &Env) -> Option
{ strata_timelock::setup::source(e) } + /// Whether setup is still open, so admin changes are instant. fn setup_open(e: &Env) -> bool { strata_timelock::setup::is_open(e) } @@ -241,6 +299,26 @@ impl VaultBinding for NavOracleContract { #[contractimpl] impl NavOracleContract { + /// Creates the oracle with its admin, its two roles and its limits. + /// + /// # Arguments + /// + /// * `admin` - Reconfigures the oracle and lifts a NAV pause. + /// * `attester` - Gets the `attester` role, which submits reports. + /// * `guardian` - Gets the `guardian` role, which pauses the NAV. + /// * `config` - The limits reports must meet; see `config`. + /// + /// # Errors + /// + /// * `InvalidConfig` - `config` breaks a bound. + /// + /// # Events + /// + /// * `RoleGranted` for `attester` and for `guardian`. + /// + /// # Authorization + /// + /// None: it runs once, at deployment. pub fn __constructor( e: &Env, admin: Address, @@ -260,12 +338,30 @@ impl NavOracleContract { upgradeable::set_schema_version(e, 1); } + /// The storage layout version, `1` until an upgrade migrates it. 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. + /// Names the vault whose timelock this oracle's changes wait for. Once, + /// and only during setup. + /// + /// # Arguments + /// + /// * `vault` - The vault. A contract that does not answer + /// `timelock_clock` fails the call. + /// + /// # Errors + /// + /// * `SetupClosed` - Setup has finished, or a vault is already bound. + /// + /// # Events + /// + /// * `VaultBound` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn bind_vault(e: &Env, vault: Address) { strata_storage::bump_instance(e); @@ -273,6 +369,21 @@ impl NavOracleContract { } /// Ends the window in which admin changes are instant. It never reopens. + /// + /// # Errors + /// + /// * `SetupClosed` - Setup has already finished. + /// * `AdminTransferPending` - An admin hand-over offered during setup is + /// still open. + /// * `VaultNotBound` - No vault is bound. + /// + /// # Events + /// + /// * `SetupFinished` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn finish_setup(e: &Env) { strata_storage::bump_instance(e); @@ -280,34 +391,108 @@ impl NavOracleContract { } /// Queues a change behind the vault's timelock and returns its id. + /// + /// # Arguments + /// + /// * `change` - An upgrade, a role grant or revoke, an admin hand-over or + /// a new config. + /// + /// # Errors + /// + /// * `InvalidConfig` - A `Config` change breaks a bound. + /// * `UpgradeAlreadyPending` - An upgrade already waits. + /// * `SetupOpen` - Setup has not finished; change it directly. + /// * `WindDownActive` - The vault is winding down. + /// * `TooManyPendingChanges` - The queue is full. + /// + /// # Events + /// + /// * `ChangeProposed`, with its `eta` in seconds. + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn propose_change(e: &Env, change: OracleChange) -> u32 { strata_storage::bump_instance(e); governance::propose(e, change) } + /// Drops a pending change. + /// + /// # Arguments + /// + /// * `id` - The id `propose_change` returned. + /// + /// # Errors + /// + /// * `ChangeNotFound` - No pending change has this id. + /// + /// # Events + /// + /// * `ChangeCancelled` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn cancel_change(e: &Env, id: u32) { strata_storage::bump_instance(e); governance::cancel(e, id); } - /// Lands a change whose delay has run. Refused while the vault is paused - /// or winding down. + /// Lands a change whose delay has run. + /// + /// # Arguments + /// + /// * `id` - The id `propose_change` returned. + /// + /// # Errors + /// + /// * `ChangeNotFound` - No pending change has this id. + /// * `WindDownActive` - The vault is winding down. + /// * `VaultPaused` - The vault is paused. + /// * `DelayBelowNotice` - The change's delay is shorter than the vault's + /// notice now. + /// * `ChangeNotReady` - The delay, plus any time the vault spent paused + /// since, has not run. + /// * `InvalidConfig` - A `Config` change breaks a bound. + /// * `AccessControlError::RoleNotHeld` - A `RevokeRole` names an account + /// without the role. + /// * An `Upgrade` whose Wasm is not on the network fails the call. + /// + /// # Events + /// + /// * `ChangeApplied`, after the change's own: `ConfigSet`, `RoleGranted`, + /// `RoleRevoked` or `AdminTransferInitiated`. + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn apply_change(e: &Env, id: u32) { strata_storage::bump_instance(e); governance::apply(e, id); } + /// Every change still waiting, with the time in seconds it may land on + /// the vault's clock as it reads now. pub fn pending_changes(e: &Env) -> Vec { governance::pending(e) } + /// The stored report: `nav_per_share` in WAD, `timestamp` and + /// `expires_at` in seconds. + /// + /// # Errors + /// + /// * `NoRecord` - No report is stored. pub fn latest(e: &Env) -> NavReport { get_latest(e).unwrap_or_else(|| panic_with_error!(e, OracleError::NoRecord)) } + /// True when no report is stored, it has expired, or its value is outside + /// the configured band. The NAV pause is not counted; `state` reports it. pub fn is_stale(e: &Env) -> bool { match get_latest(e) { None => true, @@ -315,10 +500,43 @@ impl NavOracleContract { } } + /// Succeeds only while `state` is `Valid`. + /// + /// # Errors + /// + /// * `NotConsumable` - The NAV is paused, or the report is missing, + /// expired or out of band. pub fn ensure_consumable(e: &Env) { guards::consumable(e); } + /// Stores a valuation for the vault to price epochs from. The stored + /// `expires_at` is the earlier of the report's and the time of acceptance + /// plus `freshness_duration`. + /// + /// # Arguments + /// + /// * `report` - `nav_per_share` in WAD: asset base units per share base + /// unit. `expires_at` in seconds: the last moment the attester vouches + /// for it. `timestamp` is ignored; the ledger time is stored. + /// * `caller` - An `attester`. + /// + /// # Errors + /// + /// * `AccessControlError::Unauthorized` - `caller` is not an `attester`. + /// * `NavPaused` - The NAV is paused. + /// * `NavOutOfBand` - `nav_per_share` is outside the band. + /// * `ExpiresInPast` - `expires_at` is not in the future. + /// * `CooldownActive` - The last report is younger than `cooldown_secs`. + /// * `DeviationExceeded` - The move from the last report exceeds its cap. + /// + /// # Events + /// + /// * `NavAttested` + /// + /// # Authorization + /// + /// `caller` signs. #[only_role(caller, "attester")] pub fn attest(e: &Env, report: NavReport, caller: Address) { guards::nav_not_paused(e); @@ -349,12 +567,43 @@ impl NavOracleContract { .publish(e); } + /// Pauses the NAV: the feed reports `Paused`, no report is accepted and + /// the vault prices nothing. Only the admin lifts it. + /// + /// # Arguments + /// + /// * `caller` - A `guardian`. + /// + /// # Errors + /// + /// * `AccessControlError::Unauthorized` - `caller` is not a `guardian`. + /// + /// # Events + /// + /// * `NavPausedSet`, with `paused` true. + /// + /// # Authorization + /// + /// `caller` signs. #[only_role(caller, "guardian")] pub fn pause_nav(e: &Env, caller: Address) { state::set_nav_paused(e, true); NavPausedSet { paused: true }.publish(e); } + /// Pauses the NAV, or lifts the pause. + /// + /// # Arguments + /// + /// * `paused` - True pauses, false lifts the pause. + /// + /// # Events + /// + /// * `NavPausedSet` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn set_nav_paused(e: &Env, paused: bool) { state::set_nav_paused(e, paused); @@ -364,6 +613,18 @@ impl NavOracleContract { /// Removes the stored record, so one in-band value can land again whatever /// its distance from the last. Only while the NAV is paused, so /// resuming is always a deliberate second act. + /// + /// # Errors + /// + /// * `NavNotPaused` - The NAV is not paused. + /// + /// # Events + /// + /// * `LatestCleared` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn clear_latest(e: &Env) { guards::nav_paused(e); @@ -371,13 +632,35 @@ impl NavOracleContract { LatestCleared {}.publish(e); } + /// The limits a report must meet: `freshness_duration` and + /// `cooldown_secs` in seconds, `max_up_bps` and `max_down_bps` in basis + /// points, `min_answer` and `max_answer` in WAD. pub fn config(e: &Env) -> OracleConfig { 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`. + /// Replaces the limits. 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`. + /// + /// # Arguments + /// + /// * `config` - The new limits, in the units `config` returns. + /// + /// # Errors + /// + /// * `InvalidConfig` - `config` breaks a bound. + /// * `UseTimelock` - After setup, it changes more than a shorter + /// `freshness_duration`. + /// + /// # Events + /// + /// * `ConfigSet` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn set_config(e: &Env, config: OracleConfig) { guards::valid_config(e, &config); @@ -388,20 +671,33 @@ impl NavOracleContract { #[contractimpl(contracttrait)] impl OracleFeed for NavOracleContract { + /// `Paused` while the NAV is paused. Otherwise `Valid` when a report is + /// stored, unexpired and in band, and `Stale` when not. fn state(e: &Env) -> OracleState { compute_state(e) } + /// The stored report's value in WAD: asset base units per share base + /// unit. + /// + /// # Errors + /// + /// * `NoRecord` - No report is stored. fn nav_per_share(e: &Env) -> i128 { Self::latest(e).nav_per_share } - /// Not the valuation date the reporter had in mind: the moment the - /// contract took the report. + /// The ledger time, in seconds, the oracle took the stored report. Not + /// the valuation date the reporter had in mind. + /// + /// # Errors + /// + /// * `NoRecord` - No report is stored. fn attested_at(e: &Env) -> u64 { Self::latest(e).timestamp } + /// How long a report stays valid after it is accepted, in seconds. fn freshness_duration(e: &Env) -> u64 { get_config(e).freshness_duration } From a6beb35e3333c081b4c05106f4457dcfd12757c8 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:08:52 -0300 Subject: [PATCH 102/147] docs: document compliance and the verifier Every entrypoint of the compliance and identity verifier stubs, and each trait method they override, carries a doc in the contract spec. --- contracts/compliance/src/lib.rs | 325 ++++++++++++++++++++++++- contracts/identity-verifier/src/lib.rs | 51 +++- 2 files changed, 362 insertions(+), 14 deletions(-) diff --git a/contracts/compliance/src/lib.rs b/contracts/compliance/src/lib.rs index 5b7822b6..63b19938 100644 --- a/contracts/compliance/src/lib.rs +++ b/contracts/compliance/src/lib.rs @@ -94,13 +94,39 @@ pub struct Compliance; #[contractimpl] impl Compliance { + /// Creates the contract with its admin. + /// + /// # Arguments + /// + /// * `admin` - Changes the modules and the bound tokens. + /// + /// # Authorization + /// + /// None: it runs once, at deployment. pub fn __constructor(e: &Env, admin: Address) { access_control::set_admin(e, &admin); strata_storage::bump_instance(e); } - /// Names the vault whose clock this contract's timelock reads. Once, and - /// only during setup. + /// Names the vault whose timelock this contract's changes wait for. Once, + /// and only during setup. + /// + /// # Arguments + /// + /// * `vault` - The vault. A contract that does not answer + /// `timelock_clock` fails the call. + /// + /// # Errors + /// + /// * `SetupClosed` - Setup has finished, or a vault is already bound. + /// + /// # Events + /// + /// * `VaultBound` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn bind_vault(e: &Env, vault: Address) { strata_storage::bump_instance(e); @@ -109,6 +135,21 @@ impl Compliance { /// Ends the window in which module and binding changes are instant. It /// never reopens. + /// + /// # Errors + /// + /// * `SetupClosed` - Setup has already finished. + /// * `AdminTransferPending` - An admin hand-over offered during setup is + /// still open. + /// * `VaultNotBound` - No vault is bound. + /// + /// # Events + /// + /// * `SetupFinished` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn finish_setup(e: &Env) { strata_storage::bump_instance(e); @@ -116,44 +157,147 @@ impl Compliance { } /// Queues a change behind the vault's timelock and returns its id. + /// + /// # Arguments + /// + /// * `change` - A module to add or remove, a token to bind or unbind, or + /// an admin hand-over. + /// + /// # Errors + /// + /// * `SetupOpen` - Setup has not finished; change it directly. + /// * `WindDownActive` - The vault is winding down. + /// * `TooManyPendingChanges` - The queue is full. + /// + /// # Events + /// + /// * `ChangeProposed`, with its `eta` in seconds. + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn propose_change(e: &Env, change: ComplianceChange) -> u32 { strata_storage::bump_instance(e); governance::propose(e, change) } + /// Drops a pending change. + /// + /// # Arguments + /// + /// * `id` - The id `propose_change` returned. + /// + /// # Errors + /// + /// * `ChangeNotFound` - No pending change has this id. + /// + /// # Events + /// + /// * `ChangeCancelled` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn cancel_change(e: &Env, id: u32) { strata_storage::bump_instance(e); governance::cancel(e, id); } - /// Lands a change whose delay has run. Refused while the vault is paused - /// or winding down. + /// Lands a change whose delay has run. + /// + /// # Arguments + /// + /// * `id` - The id `propose_change` returned. + /// + /// # Errors + /// + /// * `ChangeNotFound` - No pending change has this id. + /// * `WindDownActive` - The vault is winding down. + /// * `VaultPaused` - The vault is paused. + /// * `DelayBelowNotice` - The change's delay is shorter than the vault's + /// notice now. + /// * `ChangeNotReady` - The delay, plus any time the vault spent paused + /// since, has not run. + /// * `ComplianceError` or `TokenBinderError` - The module or token change + /// is refused as `add_module_to`, `remove_module_from`, `bind_token` or + /// `unbind_token` would refuse it. + /// + /// # Events + /// + /// * `ChangeApplied`, after the change's own: `ModuleAdded`, + /// `ModuleRemoved`, `TokenBound`, `TokenUnbound` or + /// `AdminTransferInitiated`. + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn apply_change(e: &Env, id: u32) { strata_storage::bump_instance(e); governance::apply(e, id); } + /// Every change still waiting, with the time in seconds it may land on + /// the vault's clock as it reads now. pub fn pending_changes(e: &Env) -> Vec { governance::pending(e) } - /// Instant during setup; after it, propose an `Admin` change. Cancelling a - /// pending hand-over (`live_until_ledger == 0`) keeps the current admin, - /// so it stays instant. + /// Offers the admin role to `new_admin`, who takes it with + /// `accept_admin_transfer`. An offer is instant only during setup; after + /// it, propose an `Admin` change. Cancelling an offer stays instant. + /// + /// # Arguments + /// + /// * `new_admin` - The nominee, or the pending nominee when cancelling. + /// * `live_until_ledger` - The last ledger number the nominee can accept + /// in; `0` cancels the pending offer. + /// + /// # Errors + /// + /// * `UseTimelock` - An offer after setup. + /// * `RoleTransferError::InvalidLiveUntilLedger` - The ledger has passed or + /// is beyond the longest entry lifetime. + /// * `RoleTransferError::NoPendingTransfer` - Nothing to cancel. + /// * `RoleTransferError::InvalidPendingAccount` - The cancel names another + /// nominee. + /// + /// # Events + /// + /// * `AdminTransferInitiated` + /// + /// # Authorization + /// + /// The admin signs. pub fn transfer_admin_role(e: &Env, new_admin: Address, live_until_ledger: u32) { guards::direct_hand_over_allowed(e, live_until_ledger); strata_storage::bump_instance(e); access_control::transfer_admin_role(e, &new_admin, live_until_ledger); } + /// Makes the nominee of a pending hand-over the admin. + /// + /// # Errors + /// + /// * `RoleTransferError::NoPendingTransfer` - No offer is pending. + /// * `RoleTransferError::TransferExpired` - The offer's last ledger has + /// passed. + /// + /// # Events + /// + /// * `AdminTransferCompleted` + /// + /// # Authorization + /// + /// The nominee signs. pub fn accept_admin_transfer(e: &Env) { strata_storage::bump_instance(e); access_control::accept_admin_transfer(e); } + /// The admin. pub fn get_admin(e: &Env) -> Option
{ access_control::get_admin(e) } @@ -161,19 +305,42 @@ impl Compliance { #[contractimpl(contracttrait)] impl VaultBinding for Compliance { + /// The vault whose timelock this contract's changes wait for, once + /// `bind_vault` has named it. fn vault(e: &Env) -> Option
{ strata_timelock::setup::source(e) } + /// Whether setup is still open, so module and binding changes are + /// instant. fn setup_open(e: &Env) -> bool { strata_timelock::setup::is_open(e) } } -/// Binding and unbinding are instant during setup; after it, propose a -/// `BindToken` or `UnbindToken` change. #[contractimpl(contracttrait)] impl token_binder::TokenBinder for Compliance { + /// Links `token` to these rules. Instant during setup; after it, propose a + /// `BindToken` change. + /// + /// # Arguments + /// + /// * `token` - The share token. + /// * `operator` - Not read; the admin signs instead. + /// + /// # Errors + /// + /// * `UseTimelock` - Setup has finished. + /// * `TokenBinderError::TokenAlreadyBound` - `token` is already bound. + /// * `TokenBinderError::MaxTokensReached` - No room for another token. + /// + /// # Events + /// + /// * `TokenBound` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] fn bind_token(e: &Env, token: Address, _operator: Address) { guards::direct_change_allowed(e); @@ -181,6 +348,26 @@ impl token_binder::TokenBinder for Compliance { token_binder::bind_token(e, &token); } + /// Unlinks `token`, after which its moves fail the hooks. Instant during + /// setup; after it, propose an `UnbindToken` change. + /// + /// # Arguments + /// + /// * `token` - A bound token. + /// * `operator` - Not read; the admin signs instead. + /// + /// # Errors + /// + /// * `UseTimelock` - Setup has finished. + /// * `TokenBinderError::TokenNotFound` - `token` is not bound. + /// + /// # Events + /// + /// * `TokenUnbound` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] fn unbind_token(e: &Env, token: Address, _operator: Address) { guards::direct_change_allowed(e); @@ -190,11 +377,32 @@ impl token_binder::TokenBinder for Compliance { } /// The token calls the check before and the hook after every mint, burn and -/// transfer, which is what keeps this instance alive. Adding or removing a -/// module is instant during setup; after it, propose an `AddModule` or -/// `RemoveModule` change. +/// transfer, which is what keeps this instance alive. #[contractimpl(contracttrait)] impl compliance::Compliance for Compliance { + /// Registers `module` on `hook`. Instant during setup; after it, propose + /// an `AddModule` change. + /// + /// # Arguments + /// + /// * `hook` - The check or the hook the module runs on. + /// * `module` - The module contract. + /// * `operator` - Not read; the admin signs instead. + /// + /// # Errors + /// + /// * `UseTimelock` - Setup has finished. + /// * `ComplianceError::ModuleAlreadyRegistered` - `module` is already on + /// `hook`. + /// * `ComplianceError::ModuleBoundExceeded` - `hook` has no room left. + /// + /// # Events + /// + /// * `ModuleAdded` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] fn add_module_to(e: &Env, hook: ComplianceHook, module: Address, _operator: Address) { guards::direct_change_allowed(e); @@ -202,6 +410,27 @@ impl compliance::Compliance for Compliance { compliance::storage::add_module_to(e, hook, module); } + /// Removes `module` from `hook`. Instant during setup; after it, propose a + /// `RemoveModule` change. + /// + /// # Arguments + /// + /// * `hook` - The check or the hook the module runs on. + /// * `module` - The module contract. + /// * `operator` - Not read; the admin signs instead. + /// + /// # Errors + /// + /// * `UseTimelock` - Setup has finished. + /// * `ComplianceError::ModuleNotRegistered` - `module` is not on `hook`. + /// + /// # Events + /// + /// * `ModuleRemoved` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] fn remove_module_from(e: &Env, hook: ComplianceHook, module: Address, _operator: Address) { guards::direct_change_allowed(e); @@ -209,26 +438,98 @@ impl compliance::Compliance for Compliance { compliance::storage::remove_module_from(e, hook, module); } + /// Runs the `Transferred` modules after a bound token moves shares. + /// + /// # Arguments + /// + /// * `from`, `to` - The two ends of the move. + /// * `amount` - Share base units moved. + /// * `token` - The calling token. + /// + /// # Errors + /// + /// * `ComplianceError::TokenNotBound` - `token` is not bound here. + /// * A module's own error. + /// + /// # Authorization + /// + /// `token` signs, as the calling contract. fn transferred(e: &Env, from: Address, to: Address, amount: i128, token: Address) { strata_storage::bump_instance(e); compliance::storage::transferred(e, from, to, amount, token); } + /// Runs the `Created` modules after a bound token mints shares. + /// + /// # Arguments + /// + /// * `to` - The account credited. + /// * `amount` - Share base units minted. + /// * `token` - The calling token. + /// + /// # Errors + /// + /// * `ComplianceError::TokenNotBound` - `token` is not bound here. + /// * A module's own error. + /// + /// # Authorization + /// + /// `token` signs, as the calling contract. fn created(e: &Env, to: Address, amount: i128, token: Address) { strata_storage::bump_instance(e); compliance::storage::created(e, to, amount, token); } + /// Runs the `Destroyed` modules after a bound token burns shares. + /// + /// # Arguments + /// + /// * `from` - The account debited. + /// * `amount` - Share base units burned. + /// * `token` - The calling token. + /// + /// # Errors + /// + /// * `ComplianceError::TokenNotBound` - `token` is not bound here. + /// * A module's own error. + /// + /// # Authorization + /// + /// `token` signs, as the calling contract. fn destroyed(e: &Env, from: Address, amount: i128, token: Address) { strata_storage::bump_instance(e); compliance::storage::destroyed(e, from, amount, token); } + /// Whether every `CanTransfer` module accepts the move; true when none is + /// registered. + /// + /// # Arguments + /// + /// * `from`, `to` - The two ends of the move. + /// * `amount` - Share base units. + /// * `token` - The token asking. + /// + /// # Errors + /// + /// * A module's own error. fn can_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) -> bool { strata_storage::bump_instance(e); compliance::storage::can_transfer(e, from, to, amount, token) } + /// Whether every `CanCreate` module accepts the mint; true when none is + /// registered. + /// + /// # Arguments + /// + /// * `to` - The account to credit. + /// * `amount` - Share base units. + /// * `token` - The token asking. + /// + /// # Errors + /// + /// * A module's own error. fn can_create(e: &Env, to: Address, amount: i128, token: Address) -> bool { strata_storage::bump_instance(e); compliance::storage::can_create(e, to, amount, token) diff --git a/contracts/identity-verifier/src/lib.rs b/contracts/identity-verifier/src/lib.rs index bfb7af99..77e1e0ac 100644 --- a/contracts/identity-verifier/src/lib.rs +++ b/contracts/identity-verifier/src/lib.rs @@ -24,18 +24,41 @@ pub struct IdentityVerifier; #[contractimpl] impl IdentityVerifier { + /// Creates the verifier with its admin. + /// + /// # Arguments + /// + /// * `admin` - Edits the allowlist. + /// + /// # Authorization + /// + /// None: it runs once, at deployment. pub fn __constructor(e: &Env, admin: Address) { access_control::set_admin(e, &admin); strata_storage::bump_instance(e); } /// Adds or removes an account from the allowlist. + /// + /// # Arguments + /// + /// * `account` - The account. + /// * `allowed` - True adds it, false removes it. + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn allow(e: &Env, account: Address, allowed: bool) { strata_storage::bump_instance(e); strata_storage::set_persistent(e, &DataKey::Allowed(account), &allowed); } + /// Whether `account` is on the allowlist. + /// + /// # Arguments + /// + /// * `account` - The account. pub fn is_allowed(e: &Env, account: Address) -> bool { strata_storage::bump_instance(e); strata_storage::get_persistent(e, &DataKey::Allowed(account)).unwrap_or(false) @@ -44,8 +67,17 @@ impl IdentityVerifier { #[contractimpl(contracttrait)] impl identity_verification::IdentityVerifier for IdentityVerifier { - /// The share token calls this on every mint and transfer, so it is what - /// keeps the instance alive. + /// Succeeds when `account` is on the allowlist. The share token calls this + /// on every mint and transfer, so it is what keeps the instance alive. + /// + /// # Arguments + /// + /// * `account` - The account. + /// + /// # Errors + /// + /// * `RWAError::IdentityVerificationFailed` - `account` is not on the + /// allowlist. fn verify_identity(e: &Env, account: &Address) { strata_storage::bump_instance(e); let allowed: bool = @@ -55,10 +87,25 @@ impl identity_verification::IdentityVerifier for IdentityVerifier { } } + /// Always `None`: this stub names no recovery account, so the share + /// token's `recover_balance` fails with `RWAError::IdentityMismatch`. + /// + /// # Arguments + /// + /// * `old_account` - Not read. fn recovery_target(_e: &Env, _old_account: &Address) -> Option
{ None } + /// Does nothing: this stub keeps no claim topics or issuers. + /// + /// # Arguments + /// + /// * `claim_topics_and_issuers`, `operator` - Not read. + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] fn set_claim_topics_and_issuers( e: &Env, From 06c6f09055bba5b0acae02234a8e5eaeafa2bf4c Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:08:52 -0300 Subject: [PATCH 103/147] docs: document the share token entrypoints Every share token entrypoint, and each OpenZeppelin method it overrides, carries a doc in the contract spec, including the library errors a call can return. --- contracts/share-token/src/contract.rs | 586 +++++++++++++++++++++++++- 1 file changed, 569 insertions(+), 17 deletions(-) diff --git a/contracts/share-token/src/contract.rs b/contracts/share-token/src/contract.rs index 09abb995..a8f4e952 100644 --- a/contracts/share-token/src/contract.rs +++ b/contracts/share-token/src/contract.rs @@ -26,6 +26,25 @@ pub struct ShareToken; #[contractimpl] impl ShareToken { + /// Creates the token with 7 decimals, its admin, its compliance authority + /// and the two contracts every move is checked against. + /// + /// # Arguments + /// + /// * `name`, `symbol` - The token's metadata. + /// * `admin` - Pauses and changes the token. + /// * `compliance_authority` - Gets the `compliance` role: freezes, and + /// with the admin, legal transfers and recoveries. + /// * `compliance` - The compliance contract. + /// * `identity_verifier` - The identity verifier. + /// + /// # Events + /// + /// * `RoleGranted`, `ComplianceSet` and `IdentityVerifierSet`. + /// + /// # Authorization + /// + /// None: it runs once, at deployment. pub fn __constructor( e: &Env, name: String, @@ -46,12 +65,30 @@ impl ShareToken { strata_storage::bump_instance(e); } + /// The storage layout version, `1` until an upgrade migrates it. pub fn schema_version(e: &Env) -> u32 { upgradeable::get_schema_version(e) } - /// Names the vault whose clock this token's timelock reads. Once, and only - /// during setup. + /// Names the vault whose timelock this token's changes wait for. Once, and + /// only during setup. + /// + /// # Arguments + /// + /// * `vault` - The vault. A contract that does not answer + /// `timelock_clock` fails the call. + /// + /// # Errors + /// + /// * `SetupClosed` - Setup has finished, or a vault is already bound. + /// + /// # Events + /// + /// * `VaultBound` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn bind_vault(e: &Env, vault: Address) { strata_storage::bump_instance(e); @@ -59,6 +96,21 @@ impl ShareToken { } /// Ends the window in which admin changes are instant. It never reopens. + /// + /// # Errors + /// + /// * `SetupClosed` - Setup has already finished. + /// * `AdminTransferPending` - An admin hand-over offered during setup is + /// still open. + /// * `VaultNotBound` - No vault is bound. + /// + /// # Events + /// + /// * `SetupFinished` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn finish_setup(e: &Env) { strata_storage::bump_instance(e); @@ -66,33 +118,124 @@ impl ShareToken { } /// Queues a change behind the vault's timelock and returns its id. + /// + /// # Arguments + /// + /// * `change` - An upgrade, a role grant or revoke, an admin hand-over, + /// or a new compliance contract or identity verifier. + /// + /// # Errors + /// + /// * `UpgradeAlreadyPending` - An upgrade already waits. + /// * `SetupOpen` - Setup has not finished; change it directly. + /// * `WindDownActive` - The vault is winding down. + /// * `TooManyPendingChanges` - The queue is full. + /// + /// # Events + /// + /// * `ChangeProposed`, with its `eta` in seconds. + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn propose_change(e: &Env, change: TokenChange) -> u32 { strata_storage::bump_instance(e); governance::propose(e, change) } + /// Drops a pending change. + /// + /// # Arguments + /// + /// * `id` - The id `propose_change` returned. + /// + /// # Errors + /// + /// * `ChangeNotFound` - No pending change has this id. + /// + /// # Events + /// + /// * `ChangeCancelled` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn cancel_change(e: &Env, id: u32) { strata_storage::bump_instance(e); governance::cancel(e, id); } - /// Lands a change whose delay has run. Refused while the vault is paused - /// or winding down. + /// Lands a change whose delay has run. + /// + /// # Arguments + /// + /// * `id` - The id `propose_change` returned. + /// + /// # Errors + /// + /// * `ChangeNotFound` - No pending change has this id. + /// * `WindDownActive` - The vault is winding down. + /// * `VaultPaused` - The vault is paused. + /// * `DelayBelowNotice` - The change's delay is shorter than the vault's + /// notice now. + /// * `ChangeNotReady` - The delay, plus any time the vault spent paused + /// since, has not run. + /// * `AccessControlError::RoleNotHeld` - A `RevokeRole` names an account + /// without the role. + /// * An `Upgrade` whose Wasm is not on the network fails the call. + /// + /// # Events + /// + /// * `ChangeApplied`, after the change's own: `RoleGranted`, + /// `RoleRevoked`, `AdminTransferInitiated`, `ComplianceSet` or + /// `IdentityVerifierSet`. + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn apply_change(e: &Env, id: u32) { strata_storage::bump_instance(e); governance::apply(e, id); } + /// Every change still waiting, with the time in seconds it may land on + /// the vault's clock as it reads now. pub fn pending_changes(e: &Env) -> Vec { governance::pending(e) } - /// Moves shares under a legal order, at once. The compliance authority - /// asks and the admin co-signs the same call. Never out of or into the - /// vault's address, and only to an account the identity verifier accepts. + /// Moves shares under a legal order, at once. The pause, freezes and the + /// compliance rules do not apply. + /// + /// # Arguments + /// + /// * `from` - The account debited. + /// * `to` - The account credited. + /// * `amount` - Share base units. + /// * `reference` - The order's reference, 1 to 128 bytes. + /// * `caller` - A `compliance` holder. + /// + /// # Errors + /// + /// * `AccessControlError::Unauthorized` - `caller` lacks `compliance`. + /// * `CoSignRequired` - `caller` is the admin. + /// * `InvalidReference` - `reference` is empty or too long. + /// * `VaultAddress` - `from` or `to` is the vault. + /// * `RWAError::IdentityVerificationFailed` - `to` is not allowlisted. + /// * `RWAError::InsufficientBalance` - `amount` exceeds the balance. + /// * `FungibleTokenError::LessThanZero` - `amount` is negative. + /// * `ComplianceError::TokenNotBound` - Compliance does not list the token. + /// + /// # Events + /// + /// * `Transfer`, then `LegalTransfer`. + /// + /// # Authorization + /// + /// `caller` and the admin sign, as two different accounts. #[only_role(caller, "compliance")] pub fn legal_transfer( e: &Env, @@ -122,10 +265,13 @@ impl ShareToken { #[contractimpl(contracttrait)] impl VaultBinding for ShareToken { + /// The vault whose timelock this token's changes wait for, once + /// `bind_vault` has named it. fn vault(e: &Env) -> Option
{ strata_timelock::setup::source(e) } + /// Whether setup is still open, so admin changes are instant. fn setup_open(e: &Env) -> bool { strata_timelock::setup::is_open(e) } @@ -133,12 +279,50 @@ impl VaultBinding for ShareToken { #[contractimpl(contracttrait)] impl Pausable for ShareToken { + /// Stops `transfer` and `transfer_from`. Mints, burns, the vault's escrow + /// moves and legal transfers go on. + /// + /// # Arguments + /// + /// * `caller` - The admin. + /// + /// # Errors + /// + /// * `AdminRequired` - `caller` is not the admin. + /// * `PausableError::EnforcedPause` - The token is already paused. + /// + /// # Events + /// + /// * `Paused` + /// + /// # Authorization + /// + /// `caller` signs. fn pause(e: &Env, caller: Address) { guards::caller_is_admin(e, &caller); caller.require_auth(); strata_storage::bump_instance(e); pausable::pause(e); } + + /// Lets `transfer` and `transfer_from` run again. + /// + /// # Arguments + /// + /// * `caller` - The admin. + /// + /// # Errors + /// + /// * `AdminRequired` - `caller` is not the admin. + /// * `PausableError::ExpectedPause` - The token is not paused. + /// + /// # Events + /// + /// * `Unpaused` + /// + /// # Authorization + /// + /// `caller` signs. fn unpause(e: &Env, caller: Address) { guards::caller_is_admin(e, &caller); caller.require_auth(); @@ -153,18 +337,94 @@ impl Pausable for ShareToken { impl FungibleToken for ShareToken { type ContractType = RWA; + /// Moves shares between two accounts. Refused while the token is paused, + /// when either end is frozen or fails the identity check, and when the + /// compliance rules refuse the move. + /// + /// # Arguments + /// + /// * `from` - The sender. + /// * `to` - The receiver. A muxed address is accepted; the event leaves + /// out its id. + /// * `amount` - Share base units. + /// + /// # Errors + /// + /// * `PausableError::EnforcedPause` - The token is paused. + /// * `RWAError::AddressFrozen` - `from` or `to` is frozen. + /// * `RWAError::InsufficientFreeTokens` - `amount` exceeds `from`'s + /// unfrozen shares. + /// * `RWAError::IdentityVerificationFailed` - `from` or `to` is not + /// allowlisted. + /// * `RWAError::TransferNotCompliant` - The compliance rules refuse it. + /// * `FungibleTokenError::LessThanZero` - `amount` is negative. + /// * `ComplianceError::TokenNotBound` - Compliance does not list the token. + /// + /// # Events + /// + /// * `Transfer` + /// + /// # Authorization + /// + /// `from` signs. fn transfer(e: &Env, from: Address, to: MuxedAddress, amount: i128) { strata_storage::bump_instance(e); ::transfer(e, &from, &to, amount); extend::balances(e, &[&from, &to.address()]); } + /// Moves shares against `spender`'s allowance, with the checks of + /// `transfer`. + /// + /// # Arguments + /// + /// * `spender` - Spends its allowance. + /// * `from` - The sender. + /// * `to` - The receiver. + /// * `amount` - Share base units. + /// + /// # Errors + /// + /// * The errors of `transfer`. + /// * `FungibleTokenError::InsufficientAllowance` - `amount` exceeds the + /// allowance. + /// + /// # Events + /// + /// * `Transfer` + /// + /// # Authorization + /// + /// `spender` signs. fn transfer_from(e: &Env, spender: Address, from: Address, to: Address, amount: i128) { strata_storage::bump_instance(e); ::transfer_from(e, &spender, &from, &to, amount); extend::balances(e, &[&from, &to]); } + /// Sets how many of `owner`'s shares `spender` may move, replacing any + /// earlier allowance. + /// + /// # Arguments + /// + /// * `owner` - The holder. + /// * `spender` - The account allowed to spend. + /// * `amount` - Share base units. + /// * `live_until_ledger` - The last ledger number the allowance lasts. + /// + /// # Errors + /// + /// * `FungibleTokenError::LessThanZero` - `amount` is negative. + /// * `FungibleTokenError::InvalidLiveUntilLedger` - The ledger is beyond + /// the longest entry lifetime, or has passed while `amount` is positive. + /// + /// # Events + /// + /// * `Approve` + /// + /// # Authorization + /// + /// `owner` signs. fn approve(e: &Env, owner: Address, spender: Address, amount: i128, live_until_ledger: u32) { strata_storage::bump_instance(e); ::approve( @@ -179,7 +439,27 @@ impl FungibleToken for ShareToken { #[contractimpl(contracttrait)] impl AccessControl for ShareToken { - /// Instant during setup; after it, propose the grant through the timelock. + /// Grants `role` to `account` at once. Only during setup; after it, propose + /// a `GrantRole` change. + /// + /// # Arguments + /// + /// * `account` - The account that gets the role. + /// * `role` - `manager` (the vault) or `compliance`. + /// * `caller` - The admin. + /// + /// # Errors + /// + /// * `UseTimelock` - Setup has finished. + /// * `AccessControlError::Unauthorized` - `caller` is not the admin. + /// + /// # Events + /// + /// * `RoleGranted`, unless `account` already holds `role`. + /// + /// # Authorization + /// + /// `caller` signs. fn grant_role(e: &Env, account: Address, role: Symbol, caller: Address) { guards::direct_change_allowed(e); strata_storage::bump_instance(e); @@ -187,16 +467,61 @@ impl AccessControl for ShareToken { extend::role_entries(e, &account, &role); } - /// Instant for `compliance`. Revoking `manager` waits like a grant, - /// because the vault needs it to pay claims. + /// Takes `role` from `account`. Instant for `compliance`. Revoking + /// `manager` waits like a grant after setup, because the vault needs it + /// to pay claims: propose a `RevokeRole` change. + /// + /// # Arguments + /// + /// * `account` - The account that loses the role. + /// * `role` - `manager` or `compliance`. + /// * `caller` - The admin. + /// + /// # Errors + /// + /// * `UseTimelock` - `manager`, after setup. + /// * `AccessControlError::Unauthorized` - `caller` is not the admin. + /// * `AccessControlError::RoleNotHeld` - `account` does not hold `role`. + /// + /// # Events + /// + /// * `RoleRevoked` + /// + /// # Authorization + /// + /// `caller` signs. fn revoke_role(e: &Env, account: Address, role: Symbol, caller: Address) { guards::direct_revoke_allowed(e, &role); strata_storage::bump_instance(e); access_control::revoke_role(e, &account, &role, &caller); } - /// Cancelling a pending hand-over (`live_until_ledger == 0`) keeps the - /// current admin, so it stays instant. + /// Offers the admin role to `new_admin`, who takes it with + /// `accept_admin_transfer`. An offer is instant only during setup; after + /// it, propose an `Admin` change. Cancelling an offer stays instant. + /// + /// # Arguments + /// + /// * `new_admin` - The nominee, or the pending nominee when cancelling. + /// * `live_until_ledger` - The last ledger number the nominee can accept + /// in; `0` cancels the pending offer. + /// + /// # Errors + /// + /// * `UseTimelock` - An offer after setup. + /// * `RoleTransferError::InvalidLiveUntilLedger` - The ledger has passed or + /// is beyond the longest entry lifetime. + /// * `RoleTransferError::NoPendingTransfer` - Nothing to cancel. + /// * `RoleTransferError::InvalidPendingAccount` - The cancel names another + /// nominee. + /// + /// # Events + /// + /// * `AdminTransferInitiated` + /// + /// # Authorization + /// + /// The admin signs. fn transfer_admin_role(e: &Env, new_admin: Address, live_until_ledger: u32) { guards::direct_hand_over_allowed(e, live_until_ledger); strata_storage::bump_instance(e); @@ -204,6 +529,14 @@ impl AccessControl for ShareToken { } /// Refused. A role admin could grant that role with no delay. + /// + /// # Arguments + /// + /// * `role`, `admin_role` - Not read. + /// + /// # Errors + /// + /// * `UseTimelock` - Always. fn set_role_admin(e: &Env, _role: Symbol, _admin_role: Symbol) { panic_with_error!(e, ShareTokenError::UseTimelock); } @@ -212,6 +545,10 @@ impl AccessControl for ShareToken { /// the manager role and never replace the vault that holds it. 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`. + /// + /// # Errors + /// + /// * `AdminRequired` - Always. fn renounce_admin(e: &Env) { panic_with_error!(e, ShareTokenError::AdminRequired); } @@ -219,29 +556,131 @@ impl AccessControl for ShareToken { #[contractimpl(contracttrait)] impl RWAToken for ShareToken { + /// Mints shares: the vault paying a priced deposit. The pause does not + /// apply. + /// + /// # Arguments + /// + /// * `to` - The account credited. + /// * `amount` - Share base units. + /// * `operator` - A `manager`: the vault. + /// + /// # Errors + /// + /// * `AccessControlError::Unauthorized` - `operator` is not a `manager`. + /// * `RWAError::IdentityVerificationFailed` - `to` is not allowlisted. + /// * `RWAError::MintNotCompliant` - The compliance rules refuse it. + /// * `FungibleTokenError::LessThanZero` - `amount` is negative. + /// * `FungibleTokenError::MathOverflow` - The supply would overflow. + /// * `ComplianceError::TokenNotBound` - Compliance does not list the token. + /// + /// # Events + /// + /// * `Mint` + /// + /// # Authorization + /// + /// `operator` signs. #[only_role(operator, "manager")] fn mint(e: &Env, to: Address, amount: i128, operator: Address) { strata_storage::bump_instance(e); RWA::mint(e, &to, amount); extend::balances(e, &[&to]); } + + /// Burns shares, frozen ones included, without their holder's + /// signature: the vault burns the escrow it holds, and a holder's shares + /// surrendered in a wind-down. The pause does not apply. + /// + /// # Arguments + /// + /// * `user_address` - The account debited. + /// * `amount` - Share base units. + /// * `operator` - A `manager`: the vault. + /// + /// # Errors + /// + /// * `AccessControlError::Unauthorized` - `operator` is not a `manager`. + /// * `RWAError::InsufficientBalance` - `amount` exceeds the balance. + /// * `FungibleTokenError::LessThanZero` - `amount` is negative. + /// * `ComplianceError::TokenNotBound` - Compliance does not list the token. + /// + /// # Events + /// + /// * `Burn`; `TokensUnfrozen` first when frozen shares burn. + /// + /// # Authorization + /// + /// `operator` signs. #[only_role(operator, "manager")] fn burn(e: &Env, user_address: Address, amount: i128, operator: Address) { strata_storage::bump_instance(e); RWA::burn(e, &user_address, amount); extend::balances(e, &[&user_address]); } - /// The vault escrows redemptions through it. Compliance moves shares with - /// `legal_transfer` instead. + + /// Moves shares without the sender's signature: the vault escrows + /// redemptions and returns escrow through it. The pause, freezes, the + /// identity check and the compliance rules' check do not apply. Compliance + /// moves shares with `legal_transfer` instead. + /// + /// # Arguments + /// + /// * `from` - The sender. + /// * `to` - The receiver. + /// * `amount` - Share base units. + /// * `operator` - A `manager`: the vault. + /// + /// # Errors + /// + /// * `AccessControlError::Unauthorized` - `operator` is not a `manager`. + /// * `RWAError::InsufficientBalance` - `amount` exceeds `from`'s balance. + /// * `FungibleTokenError::LessThanZero` - `amount` is negative. + /// * `ComplianceError::TokenNotBound` - Compliance does not list the token. + /// + /// # Events + /// + /// * `Transfer`; `TokensUnfrozen` first when frozen shares move. + /// + /// # Authorization + /// + /// `operator` signs. #[only_role(operator, "manager")] fn forced_transfer(e: &Env, from: Address, to: Address, amount: i128, operator: Address) { strata_storage::bump_instance(e); RWA::forced_transfer(e, &from, &to, amount); extend::balances(e, &[&from, &to]); } - /// Moves a whole balance to the account the identity verifier names, so - /// it needs the admin's co-signature like a legal transfer, and never - /// moves shares out of or into the vault's address. + + /// Moves a lost account's whole balance, and any freeze on it, to the + /// recovery account the identity verifier names. Returns false when there + /// was nothing to move. + /// + /// # Arguments + /// + /// * `old_account` - The lost account. + /// * `new_account` - Its recovery account. + /// * `operator` - A `compliance` holder. + /// + /// # Errors + /// + /// * `AccessControlError::Unauthorized` - `operator` lacks `compliance`. + /// * `CoSignRequired` - `operator` is the admin. + /// * `VaultAddress` - Either account is the vault. + /// * `RWAError::IdentityVerificationFailed` - `new_account` is not + /// allowlisted. + /// * `RWAError::IdentityMismatch` - The verifier names another recovery + /// account, or none. + /// * `ComplianceError::TokenNotBound` - Compliance does not list the token. + /// + /// # Events + /// + /// * `Transfer` and `RecoverySuccess`, with the freeze events when a + /// freeze carries over. + /// + /// # Authorization + /// + /// `operator` and the admin sign, as two different accounts. #[only_role(operator, "compliance")] fn recover_balance( e: &Env, @@ -259,6 +698,29 @@ impl RWAToken for ShareToken { extend::frozen_tokens(e, &new_account); recovered } + + /// Freezes or unfreezes an account: a frozen account can neither send nor + /// receive a `transfer`, and the vault holds what it owes the account. + /// Never freezes the vault's address once the token is bound to it. + /// + /// # Arguments + /// + /// * `user_address` - The account. + /// * `freeze` - True freezes, false unfreezes. + /// * `operator` - A `compliance` holder. + /// + /// # Errors + /// + /// * `AccessControlError::Unauthorized` - `operator` lacks `compliance`. + /// * `VaultAddress` - Freezing the vault's address. + /// + /// # Events + /// + /// * `AddressFrozen` + /// + /// # Authorization + /// + /// `operator` signs. #[only_role(operator, "compliance")] fn set_address_frozen(e: &Env, user_address: Address, freeze: bool, operator: Address) { guards::not_freezing_vault(e, &user_address, freeze); @@ -266,6 +728,31 @@ impl RWAToken for ShareToken { RWA::set_address_frozen(e, &user_address, freeze); extend::address_frozen(e, &user_address); } + + /// Freezes part of an account's shares, which then cannot be transferred. + /// Never on the vault's address once the token is bound to it. + /// + /// # Arguments + /// + /// * `user_address` - The account. + /// * `amount` - Share base units added to the frozen amount. + /// * `operator` - A `compliance` holder. + /// + /// # Errors + /// + /// * `AccessControlError::Unauthorized` - `operator` lacks `compliance`. + /// * `VaultAddress` - `user_address` is the vault. + /// * `RWAError::LessThanZero` - `amount` is negative. + /// * `RWAError::InsufficientBalance` - The frozen amount would exceed the + /// balance. + /// + /// # Events + /// + /// * `TokensFrozen` + /// + /// # Authorization + /// + /// `operator` signs. #[only_role(operator, "compliance")] fn freeze_partial_tokens(e: &Env, user_address: Address, amount: i128, operator: Address) { guards::not_vault_address(e, &user_address); @@ -273,12 +760,56 @@ impl RWAToken for ShareToken { RWA::freeze_partial_tokens(e, &user_address, amount); extend::frozen_tokens(e, &user_address); } + + /// Unfreezes part of an account's frozen shares. + /// + /// # Arguments + /// + /// * `user_address` - The account. + /// * `amount` - Share base units taken off the frozen amount. + /// * `operator` - A `compliance` holder. + /// + /// # Errors + /// + /// * `AccessControlError::Unauthorized` - `operator` lacks `compliance`. + /// * `RWAError::LessThanZero` - `amount` is negative. + /// * `RWAError::InsufficientFreeTokens` - `amount` exceeds the frozen + /// shares. + /// + /// # Events + /// + /// * `TokensUnfrozen` + /// + /// # Authorization + /// + /// `operator` signs. #[only_role(operator, "compliance")] fn unfreeze_partial_tokens(e: &Env, user_address: Address, amount: i128, operator: Address) { strata_storage::bump_instance(e); RWA::unfreeze_partial_tokens(e, &user_address, amount); extend::frozen_tokens(e, &user_address); } + + /// Points the token at another compliance contract. Instant during setup; + /// after it, propose a `Compliance` change. + /// + /// # Arguments + /// + /// * `compliance` - The compliance contract. + /// * `operator` - The admin. + /// + /// # Errors + /// + /// * `AdminRequired` - `operator` is not the admin. + /// * `UseTimelock` - Setup has finished. + /// + /// # Events + /// + /// * `ComplianceSet` + /// + /// # Authorization + /// + /// `operator` signs. fn set_compliance(e: &Env, compliance: Address, operator: Address) { guards::caller_is_admin(e, &operator); operator.require_auth(); @@ -286,6 +817,27 @@ impl RWAToken for ShareToken { strata_storage::bump_instance(e); RWA::set_compliance(e, &compliance); } + + /// Points the token at another identity verifier. Instant during setup; + /// after it, propose an `IdentityVerifier` change. + /// + /// # Arguments + /// + /// * `identity_verifier` - The identity verifier. + /// * `operator` - The admin. + /// + /// # Errors + /// + /// * `AdminRequired` - `operator` is not the admin. + /// * `UseTimelock` - Setup has finished. + /// + /// # Events + /// + /// * `IdentityVerifierSet` + /// + /// # Authorization + /// + /// `operator` signs. fn set_identity_verifier(e: &Env, identity_verifier: Address, operator: Address) { guards::caller_is_admin(e, &operator); operator.require_auth(); From d3c5c8b7f93d3017021f8df73778b99d558d2d40 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:09:07 -0300 Subject: [PATCH 104/147] docs: document the vault entrypoints Every vault entrypoint, view and overridden trait method carries a doc in the contract spec. Amounts are in asset base units, shares in share base units, prices in WAD and times in seconds. Each doc lists the errors the call can return in a consistent state, its events and who signs. --- contracts/async-vault/src/lib.rs | 768 +++++++++++++++++++++++++++++-- 1 file changed, 735 insertions(+), 33 deletions(-) diff --git a/contracts/async-vault/src/lib.rs b/contracts/async-vault/src/lib.rs index 72fb288c..6c148082 100644 --- a/contracts/async-vault/src/lib.rs +++ b/contracts/async-vault/src/lib.rs @@ -70,6 +70,30 @@ pub struct AsyncVault; #[contractimpl] impl AsyncVault { + /// Creates the vault with its asset, share token, oracle and authorities, + /// and opens epoch 1. + /// + /// # Arguments + /// + /// * `asset` - The deposit asset's token contract. + /// * `share_token` - The share token. + /// * `oracle` - The feed epochs are priced from. + /// * `roles` - `governance` becomes the admin; `manager`, `treasury` and + /// `guardian` get their roles. `compliance` and `attester` are only + /// checked for distinctness. + /// + /// # Errors + /// + /// * `RolesNotDistinct` - `treasury` equals `guardian` or `governance`, or + /// `compliance` equals `governance` or `treasury`. + /// + /// # Events + /// + /// * `RoleGranted` for `manager`, `treasury` and `guardian`. + /// + /// # Authorization + /// + /// None: it runs once, at deployment. pub fn __constructor( e: &Env, asset: Address, @@ -102,70 +126,95 @@ impl AsyncVault { upgradeable::set_schema_version(e, 1); } + /// The deposit asset's token contract. pub fn asset(e: &Env) -> Address { state::get_addr(e, &DataKey::Asset) } + /// The share token. pub fn share_token(e: &Env) -> Address { state::get_addr(e, &DataKey::ShareToken) } + /// The feed epochs are priced from. pub fn oracle(e: &Env) -> Address { state::get_addr(e, &DataKey::Oracle) } + /// The admin: governance. pub fn governance(e: &Env) -> Option
{ access_control::get_admin(e) } + /// The first holder of `manager`, which closes epochs. pub fn manager(e: &Env) -> Option
{ role_holder(e, &MANAGER_ROLE) } + /// The first holder of `treasury`, which deploys capital to the + /// custodian. pub fn treasury(e: &Env) -> Option
{ role_holder(e, &TREASURY_ROLE) } + /// The first holder of `guardian`, which pauses the vault. pub fn guardian(e: &Env) -> Option
{ role_holder(e, &GUARDIAN_ROLE) } + /// Where `deploy_to_custodian` sends capital, once set. pub fn custodian(e: &Env) -> Option
{ state::get_addr_opt(e, &DataKey::Custodian) } + /// Asset base units sent to the custodian less those funded back; + /// negative once more came back than went out. pub fn net_deployed(e: &Env) -> i128 { state::net_deployed(e) } + /// Asset base units the vault holds that nothing is owed against: what is + /// left after cancellable escrow, priced redemptions and credited + /// wind-down rounds, and never below zero. pub fn free_reserve(e: &Env) -> i128 { treasury::free_reserve(e) } + /// Asset base units owed to priced redemptions not yet claimed. pub fn committed(e: &Env) -> i128 { state::committed(e) } + /// Asset base units owed to priced redemptions beyond the liquid reserve. + /// While it is positive, nothing can be deployed to the custodian. pub fn uncovered(e: &Env) -> i128 { treasury::uncovered(e) } + /// Asset base units the vault holds, less the escrow depositors can still + /// cancel. pub fn liquid_reserve(e: &Env) -> i128 { treasury::liquid_reserve(e) } + /// Asset base units escrowed by deposits in unpriced epochs. pub fn cancellable_escrow(e: &Env) -> i128 { state::cancellable_escrow(e) } + /// Share base units priced for deposits and not yet claimed. pub fn pending_mint_shares(e: &Env) -> i128 { state::pending_mint_shares(e) } + /// Share base units of priced redemptions not yet claimed. pub fn pending_burn_shares(e: &Env) -> i128 { state::pending_burn_shares(e) } + /// Share base units outside the vault, plus those priced for deposits and + /// not yet minted: the token's supply, less the vault's own balance, plus + /// `pending_mint_shares`. pub fn total_economic_supply(e: &Env) -> i128 { let share_token = state::get_addr(e, &DataKey::ShareToken); let client = FungibleTokenClient::new(e, &share_token); @@ -177,10 +226,30 @@ impl AsyncVault { .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)) } + /// The cap on deposited capital, in asset base units; `None` means no cap. pub fn deposit_cap(e: &Env) -> Option { state::deposit_cap(e) } + /// Caps deposited capital: what the vault holds plus what it has deployed, + /// less what priced redemptions are owed. A deposit request that would take + /// it past the cap is refused. Instant, also after setup. + /// + /// # Arguments + /// + /// * `cap` - Asset base units, or `None` for no cap. + /// + /// # Errors + /// + /// * `InvalidAmount` - `cap` is negative. + /// + /// # Events + /// + /// * `DepositCapUpdated` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn set_deposit_cap(e: &Env, cap: Option) { guards::non_negative_cap(e, cap); @@ -193,8 +262,25 @@ impl AsyncVault { .publish(e); } - /// Instant during setup; afterwards the custodian changes through the - /// timelock. Never the vault itself (`InvalidCustodian`). + /// Sets where deployed capital goes. Instant during setup; afterwards the + /// custodian changes through the timelock. Never the vault itself. + /// + /// # Arguments + /// + /// * `custodian` - The account that receives deployed capital. + /// + /// # Errors + /// + /// * `InvalidCustodian` - `custodian` is the vault. + /// * `UseTimelock` - Setup has finished. + /// + /// # Events + /// + /// * `CustodianSet` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn set_custodian(e: &Env, custodian: Address) { guards::custodian_not_the_vault(e, &custodian); @@ -202,8 +288,27 @@ impl AsyncVault { treasury::set_custodian(e, &custodian); } - /// Raising is instant. Lowering shortens the warning holders get before a + /// Sets how long a wind-down proposal waits before it can start. Raising + /// is instant. Lowering shortens the warning holders get before a /// wind-down starts, so after setup it waits for the timelock. + /// + /// # Arguments + /// + /// * `secs` - Seconds, from 7 to 90 days. + /// + /// # Errors + /// + /// * `WindDownDelayTooShort` - `secs` is under 7 days. + /// * `WindDownDelayTooLong` - `secs` is over 90 days. + /// * `UseTimelock` - Lowering after setup. + /// + /// # Events + /// + /// * `WindDownDelaySet` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn set_wind_down_delay(e: &Env, secs: u64) { guards::wind_down_delay_in_bounds(e, secs); @@ -211,61 +316,172 @@ impl AsyncVault { wind_down::set_delay(e, secs); } + /// Seconds a wind-down proposal waits before it can start. pub fn wind_down_delay(e: &Env) -> u64 { wind_down::delay(e) } + /// The wind-down once proposed: whether it is proposed or active, the time + /// in seconds it may start, and the rounds finalised. pub fn wind_down(e: &Env) -> Option { wind_down::info(e) } + /// Announces a wind-down, which anyone can start once the delay has run. + /// + /// # Errors + /// + /// * `WindDownActive` - The vault is already winding down. + /// * `WindDownAlreadyProposed` - A proposal already stands. + /// + /// # Events + /// + /// * `WindDownProposed`, with `active_at` in seconds. + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn propose_wind_down(e: &Env) { wind_down::propose(e); } + /// Withdraws the standing wind-down proposal. + /// + /// # Errors + /// + /// * `WindDownActive` - The wind-down has started. + /// * `WindDownNotProposed` - No proposal stands. + /// + /// # Events + /// + /// * `WindDownProposalCancelled` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn cancel_wind_down_proposal(e: &Env) { wind_down::cancel_proposal(e); } - /// Open to anyone once the delay has passed, so the operator cannot stall - /// the wind-down it announced. + /// Starts the wind-down: from here on the vault takes no request and + /// prices nothing, and holders exit through wind-down rounds. Open to + /// anyone once the delay has passed, so the operator cannot stall the + /// wind-down it announced. + /// + /// # Errors + /// + /// * `WindDownActive` - The wind-down has already started. + /// * `WindDownNotProposed` - No proposal stands. + /// * `WindDownDelayNotElapsed` - The delay has not run. + /// + /// # Events + /// + /// * `WindDownActivated` + /// + /// # Authorization + /// + /// None: anyone may call. pub fn activate_wind_down(e: &Env) { wind_down::activate(e); } - /// Open to anyone once active, distributing the free reserve in proportion - /// to the supply snapshot. + /// Distributes the free reserve in proportion to the supply snapshot the + /// first round takes, and returns the asset base units the round credits. + /// Open to anyone once the wind-down is active. + /// + /// # Errors + /// + /// * `WindDownNotActive` - The wind-down has not started. + /// * `NothingToDistribute` - No supply to divide by, or the free reserve + /// credits nothing. + /// * `AmountTooLarge` - A figure of the round would overflow. + /// + /// # Events + /// + /// * `WindDownRoundFinalized` + /// + /// # Authorization + /// + /// None: anyone may call. pub fn finalize_wind_down_round(e: &Env) -> i128 { wind_down::finalize_round(e) } + /// Asset base units the rounds credited that holders have not claimed. pub fn wind_down_owed(e: &Env) -> i128 { wind_down::owed(e) } + /// Share base units the first round's snapshot divides by; zero before it. pub fn wind_down_supply(e: &Env) -> i128 { wind_down::supply_snapshot(e) } + /// Asset base units credited per share base unit so far, in WAD. pub fn wind_down_acc(e: &Env) -> i128 { wind_down::acc(e) } /// Surrenders the free shares the holder holds and pays their share of - /// every round finalised since they last claimed. A frozen holder is - /// refused; a partial freeze surrenders only the free shares. + /// every round finalised since they last claimed. Returns the asset base + /// units paid. A frozen holder is refused; a partial freeze surrenders + /// only the free shares. + /// + /// # Arguments + /// + /// * `holder` - The shareholder. + /// + /// # Errors + /// + /// * `DistributionNotStarted` - No round has been finalised. + /// * `Frozen` - `holder` is frozen. + /// * `NoEntitlement` - Nothing to surrender and nothing owed. + /// + /// # Events + /// + /// * `WindDownClaimed`, after the token's `Burn` when shares are + /// surrendered. + /// + /// # Authorization + /// + /// `holder` signs. pub fn claim_wind_down(e: &Env, holder: Address) -> i128 { wind_down::claim(e, &holder) } + /// What `claim_wind_down` would pay `holder` now, in asset base units: + /// zero before the first round and for a frozen holder. + /// + /// # Arguments + /// + /// * `holder` - The shareholder. pub fn wind_down_claimable(e: &Env, holder: Address) -> i128 { wind_down::claimable(e, &holder) } - /// Raising is instant. Lowering shortens the wait every closed epoch gives - /// before it prices, so after setup it waits for the timelock. + /// Sets the notice: how long a closed epoch waits before it can be priced. + /// Raising is instant. Lowering shortens the wait of every epoch closed + /// afterwards, so after setup it waits for the timelock. + /// + /// # Arguments + /// + /// * `secs` - Seconds; at most 30 days and at most the upgrade delay. + /// + /// # Errors + /// + /// * `UseTimelock` - Lowering after setup. + /// * `NoticeTooLong` - `secs` is over 30 days. + /// * `NoticeAboveUpgradeDelay` - `secs` exceeds the upgrade delay. + /// + /// # Events + /// + /// * `NoticeSet` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn set_notice(e: &Env, secs: u64) { guards::not_lowered_after_setup(e, secs, state::notice(e)); @@ -274,15 +490,30 @@ impl AsyncVault { } /// Ends the window in which admin changes are instant. Needs every wiring - /// check `health()` reports (token, oracle and compliance bound, sole token - /// manager, allowlisted, not frozen, matching decimals, distinct addresses, - /// a feed that answers, a custodian), no shares outstanding and no admin - /// hand-over pending. Deposits open from here on, and it never reopens. + /// check `health()` reports, no shares outstanding and no admin hand-over + /// pending. Deposits open from here on, and it never reopens. + /// + /// # Errors + /// + /// * `SetupClosed` - Setup has already finished. + /// * `AdminTransferPending` - An admin hand-over offered during setup is + /// still open. + /// * `SetupIncomplete` - A wiring check fails, or shares are outstanding. + /// + /// # Events + /// + /// * `SetupFinished` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn finish_setup(e: &Env) { governance::finish_setup(e); } + /// Whether setup is still open: admin changes are instant and deposits + /// are refused. pub fn setup_open(e: &Env) -> bool { strata_timelock::setup::is_open(e) } @@ -294,108 +525,461 @@ impl AsyncVault { health::health(e) } - /// Queues a change behind the timelock and returns its id. + /// Queues a change behind the timelock and returns its id. It waits the + /// upgrade delay, plus any time the vault spends paused. + /// + /// # Arguments + /// + /// * `change` - An upgrade, a new upgrade delay, custodian, notice, + /// wind-down delay or oracle, a role grant or revoke, or an admin + /// hand-over. Durations in seconds. + /// + /// # Errors + /// + /// * `WindDownActive` - The vault is winding down. + /// * `SetupOpen` - Setup has not finished; change it directly. + /// * `UpgradeDelayTooShort`, `UpgradeDelayTooLong`, + /// `UpgradeDelayBelowNotice`, `NoticeTooLong`, `NoticeAboveUpgradeDelay`, + /// `WindDownDelayTooShort`, `WindDownDelayTooLong`, `FeedNotBound`, + /// `InvalidCustodian` - The change is out of bounds. + /// * `UpgradeProposalExists` - An upgrade or an upgrade delay change, while + /// one of the two already waits. + /// * `TooManyPendingChanges` - The queue is full. + /// + /// # Events + /// + /// * `ChangeProposed`, with its `eta` in seconds. + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn propose_change(e: &Env, change: VaultChange) -> u32 { governance::propose(e, change) } + /// Drops a pending change. + /// + /// # Arguments + /// + /// * `id` - The id `propose_change` returned. + /// + /// # Errors + /// + /// * `ChangeNotFound` - No pending change has this id. + /// + /// # Events + /// + /// * `ChangeCancelled` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn cancel_change(e: &Env, id: u32) { governance::cancel(e, id); } - /// Lands a change whose delay has run. Refused while paused or winding - /// down. + /// Lands a change whose delay has run, checking its bounds again. + /// + /// # Arguments + /// + /// * `id` - The id `propose_change` returned. + /// + /// # Errors + /// + /// * `ChangeNotFound` - No pending change has this id. + /// * `WindDownActive` - The vault is winding down. + /// * `PausableError::EnforcedPause` - The vault is paused. + /// * `UpgradeDelayBelowNotice` - The change's delay is shorter than the + /// notice now. + /// * `ChangeNotReady` - The delay, plus any time paused since, has not run. + /// * The bound errors of `propose_change`, checked again. + /// * `AccessControlError::RoleNotHeld` - A `RevokeRole` names an account + /// without the role. + /// * An `Upgrade` whose Wasm is not on the network fails the call. + /// + /// # Events + /// + /// * `ChangeApplied`, after the change's own: `CustodianSet`, `NoticeSet`, + /// `WindDownDelaySet`, `RoleGranted`, `RoleRevoked` or + /// `AdminTransferInitiated`. + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn apply_change(e: &Env, id: u32) { governance::apply(e, id); } + /// Every change still waiting, with the time in seconds it may land as + /// the clock reads now. pub fn pending_changes(e: &Env) -> Vec { governance::pending(e) } + /// Seconds a proposed change waits: 7 days until an `UpgradeDelay` change + /// sets another. pub fn upgrade_delay(e: &Env) -> u64 { upgrade::delay(e) } + /// The storage layout version, `1` until an upgrade migrates it. pub fn schema_version(e: &Env) -> u32 { upgradeable::get_schema_version(e) } + /// Sends free reserve to the custodian and returns `net_deployed` after + /// it. It never sends escrow a depositor can cancel or cash owed to + /// priced exits. The pause does not stop it. + /// + /// # Arguments + /// + /// * `caller` - A `treasury` holder. + /// * `assets` - Asset base units. + /// + /// # Errors + /// + /// * `AccessControlError::Unauthorized` - `caller` lacks `treasury`. + /// * `WindDownActive` - The vault is winding down. + /// * `InvalidAmount` - `assets` is zero or negative. + /// * `CustodianNotSet` - No custodian is set. + /// * `ReserveCommittedToExits` - `assets` exceeds the free reserve. + /// * The asset's own error when the custodian cannot receive it. + /// + /// # Events + /// + /// * `Deployed` + /// + /// # Authorization + /// + /// `caller` signs. #[only_role(caller, "treasury")] pub fn deploy_to_custodian(e: &Env, caller: Address, assets: i128) -> i128 { treasury::deploy(e, assets) } + /// Pays assets into the vault, lowering `net_deployed` by them, and + /// returns `net_deployed` after it. The custodian's capital comes back + /// this way. Anyone may fund, during a pause or a wind-down too. + /// + /// # Arguments + /// + /// * `caller` - The account paying. + /// * `assets` - Asset base units. + /// + /// # Errors + /// + /// * `InvalidAmount` - `assets` is zero or negative. + /// * `AmountTooLarge` - `net_deployed` would fall below `i128::MIN`. + /// * The asset's own error when `caller` cannot pay. + /// + /// # Events + /// + /// * `Funded` + /// + /// # Authorization + /// + /// `caller` signs. pub fn fund(e: &Env, caller: Address, assets: i128) -> i128 { treasury::fund(e, &caller, assets) } + /// Seconds a closed epoch waits before it can be priced. pub fn notice(e: &Env) -> u64 { state::notice(e) } + /// The id of the open epoch, which takes new requests. pub fn current_epoch(e: &Env) -> u64 { state::current_epoch(e) } + /// An epoch: its status, deposits in asset base units, redemptions in share + /// base units, price in WAD, and close and pricing times in seconds. + /// + /// # Arguments + /// + /// * `epoch_id` - The epoch. pub fn get_epoch(e: &Env, epoch_id: u64) -> Option { state::get_epoch(e, epoch_id) } - /// What pricing promised the epoch and what has been paid against it. + /// What pricing promised the epoch and what has been paid against it: + /// open request counts, shares in share base units, assets in asset base + /// units. + /// + /// # Arguments + /// + /// * `epoch_id` - The epoch. pub fn get_epoch_settlement(e: &Env, epoch_id: u64) -> Option { settlement::get(e, epoch_id) } + /// A holder's deposit in an epoch: its amount in asset base units, and + /// whether it was claimed. + /// + /// # Arguments + /// + /// * `epoch_id` - The epoch. + /// * `holder` - The depositor. pub fn get_deposit_request(e: &Env, epoch_id: u64, holder: Address) -> Option { state::get_deposit_request(e, epoch_id, &holder) } + /// Escrows `amount` of the asset into the open epoch and returns the + /// epoch's id. The deposit can be priced once the epoch closes and its + /// notice has run. + /// + /// # Arguments + /// + /// * `holder` - The depositor. + /// * `amount` - Asset base units. + /// + /// # Errors + /// + /// * `PausableError::EnforcedPause` - The vault is paused. + /// * `WindDownActive` - The vault is winding down. + /// * `SetupOpen` - Setup has not finished. + /// * `InvalidAmount` - `amount` is zero or negative. + /// * `NotAllowlisted` - `holder` fails the identity check. + /// * `Frozen` - `holder` is frozen. + /// * `DepositCapExceeded` - The deposit would pass the cap. + /// * `RequestOutstanding` - `holder` has a deposit in this epoch already. + /// * `AmountTooLarge` - A total would overflow. + /// * The asset's own error when `holder` cannot pay. + /// + /// # Events + /// + /// * `DepositRequested` + /// + /// # Authorization + /// + /// `holder` signs. #[when_not_paused] pub fn request_deposit(e: &Env, holder: Address, amount: i128) -> u64 { deposit::request(e, &holder, amount) } - /// Returns the shares minted to the holder. Zero means none were: a deposit - /// too small for one share was refunded, or the holder is off the allowlist - /// and the shares joined an exit instead (`DepositConvertedToExit`). + /// Mints a priced deposit's shares and returns them, in share base units. + /// Zero means none: a deposit worth less than one share was refunded, or a + /// delisted holder's shares joined an exit (`DepositConvertedToExit`). + /// + /// # Arguments + /// + /// * `holder` - The depositor. + /// * `epoch_id` - The epoch the deposit joined. + /// + /// # Errors + /// + /// * `EpochNotFound` - No such epoch. + /// * `EpochNotFulfilled` - The epoch is not priced yet. + /// * `RequestNotFound` - `holder` has no deposit in the epoch. + /// * `AlreadyClaimed` - The deposit was claimed. + /// * `Frozen` - `holder` is frozen. + /// * `ClaimNotCovered` - The free reserve cannot cover a refund yet. + /// * `RWAError::MintNotCompliant` - The compliance rules refuse the mint. + /// * `RWAError::IdentityVerificationFailed` - A conversion while the vault + /// is off the allowlist. + /// + /// # Events + /// + /// * `DepositClaimed`, `DepositConvertedToExit`, or `DepositCancelled` + /// for a refund. + /// * `EpochReconciled` once the epoch's last request settles. + /// + /// # Authorization + /// + /// `holder` signs. pub fn claim_deposit(e: &Env, holder: Address, epoch_id: u64) -> i128 { deposit::claim(e, &holder, epoch_id) } - /// Cancels an unpriced deposit. Never blocked by the pause. + /// Cancels an unpriced deposit and returns the asset base units refunded. + /// Open while the epoch has no readable price: a paused vault or NAV, or + /// an expired report, reopens it. Never blocked by the pause. + /// + /// # Arguments + /// + /// * `holder` - The depositor. + /// * `epoch_id` - The epoch the deposit joined. + /// + /// # Errors + /// + /// * `EpochNotFound` - No such epoch. + /// * `AlreadyPriced` - The epoch is priced; claim instead. + /// * `PriceAvailable` - The epoch can be priced now. + /// * `RequestNotFound` - `holder` has no deposit in the epoch. + /// * `Frozen` - `holder` is frozen. + /// + /// # Events + /// + /// * `DepositCancelled` + /// + /// # Authorization + /// + /// `holder` signs. pub fn cancel_deposit(e: &Env, holder: Address, epoch_id: u64) -> i128 { deposit::cancel(e, &holder, epoch_id) } - /// Cancels an unpriced redemption. Outside a wind-down the shares come - /// back, even while the token is paused, unless the holder is delisted, - /// frozen or refused by the compliance rules; in one the escrow joins the - /// holder's entitlement, and a frozen holder is held. + /// Cancels an unpriced redemption and returns its share base units. Outside + /// a wind-down the shares come back, even while the token is paused, unless + /// the holder is delisted, frozen or refused by the compliance rules; in one + /// the escrow joins the holder's entitlement, and a frozen holder is held. + /// + /// # Arguments + /// + /// * `holder` - The redeemer. + /// * `epoch_id` - The epoch the redemption joined. + /// + /// # Errors + /// + /// * `EpochNotFound` - No such epoch. + /// * `AlreadyPriced` - The epoch is priced; claim instead. + /// * `PriceAvailable` - The epoch can be priced now. + /// * `RequestNotFound` - `holder` has no redemption in the epoch. + /// * `NotAllowlisted` - `holder` is delisted. + /// * `Frozen` - `holder` is frozen. + /// * `HolderNotEligible` - The compliance rules refuse the return. + /// + /// # Events + /// + /// * `RedeemCancelled`, or `RedemptionSurrendered` in a wind-down. + /// + /// # Authorization + /// + /// `holder` signs. pub fn cancel_redeem(e: &Env, holder: Address, epoch_id: u64) -> i128 { redeem::cancel(e, &holder, epoch_id) } + /// Escrows `shares` into the open epoch's redemptions and returns the + /// epoch's id. Open to a delisted holder, and while the vault or the token + /// is paused. + /// + /// # Arguments + /// + /// * `holder` - The redeemer. + /// * `shares` - Share base units. + /// + /// # Errors + /// + /// * `WindDownActive` - The vault is winding down. + /// * `InvalidAmount` - `shares` is zero or negative. + /// * `Frozen` - `holder` is frozen, or `shares` needs frozen shares. + /// * `RequestOutstanding` - `holder` has a redemption in this epoch + /// already. + /// * `AmountTooLarge` - A total would overflow. + /// * `RWAError::InsufficientBalance` - `shares` exceeds the balance. + /// + /// # Events + /// + /// * `RedeemRequested`, after the token's `Transfer`. + /// + /// # Authorization + /// + /// `holder` signs. pub fn request_redeem(e: &Env, holder: Address, shares: i128) -> u64 { redeem::request(e, &holder, shares) } + /// A holder's redemption in an epoch: its shares in share base units, and + /// whether it was claimed. + /// + /// # Arguments + /// + /// * `epoch_id` - The epoch. + /// * `holder` - The redeemer. pub fn get_redeem_request(e: &Env, epoch_id: u64, holder: Address) -> Option { state::get_redeem_request(e, epoch_id, &holder) } + /// Pays a priced redemption and returns the asset base units paid. Open + /// while paused and to a delisted holder. Outside a wind-down, shares + /// priced at nothing come back instead. + /// + /// # Arguments + /// + /// * `holder` - The redeemer. + /// * `epoch_id` - The epoch the redemption joined. + /// + /// # Errors + /// + /// * `EpochNotFound` - No such epoch. + /// * `EpochNotFulfilled` - The epoch is not priced yet. + /// * `RequestNotFound` - `holder` has no redemption in the epoch. + /// * `AlreadyClaimed` - The redemption was claimed. + /// * `Frozen` - `holder` is frozen. + /// * `ClaimNotCovered` - The liquid reserve cannot pay it yet. + /// * `HolderNotEligible` - Shares priced at nothing cannot come back. + /// + /// # Events + /// + /// * `RedeemClaimed`, or `RedeemCancelled` when the shares come back. + /// * `EpochReconciled` once the epoch's last request settles. + /// + /// # Authorization + /// + /// `holder` signs. pub fn claim_redeem(e: &Env, holder: Address, epoch_id: u64) -> i128 { redeem::claim(e, &holder, epoch_id) } + /// Seals the open epoch, opens the next, and returns the sealed epoch's + /// id. It can be priced once its notice has run. + /// + /// # Arguments + /// + /// * `caller` - A `manager` holder. + /// + /// # Errors + /// + /// * `AccessControlError::Unauthorized` - `caller` lacks `manager`. + /// * `WindDownActive` - The vault is winding down. + /// + /// # Events + /// + /// * `EpochClosed` + /// + /// # Authorization + /// + /// `caller` signs. #[only_role(caller, "manager")] pub fn close_epoch(e: &Env, caller: Address) -> u64 { epoch::close(e) } + /// Prices a sealed epoch at the feed's standing report and returns the + /// share price in WAD: asset base units per share base unit. Open to + /// anyone once the notice has run and a report accepted after the close + /// is valid. + /// + /// # Arguments + /// + /// * `epoch_id` - A sealed epoch. + /// + /// # Errors + /// + /// * `PausableError::EnforcedPause` - The vault is paused. + /// * `WindDownActive` - The vault is winding down. + /// * `EpochNotFound` - No such epoch. + /// * `EpochNotPending` - The epoch is open or already priced. + /// * `NoticeNotElapsed` - The notice has not run. + /// * `FeedNotValid` - The feed is paused or stale. + /// * `AttestationBeforeClose` - The report predates the close. + /// * `InvalidSharePrice` - The feed returned zero or less. + /// * `AmountTooLarge` - A total or a conversion would overflow. + /// + /// # Events + /// + /// * `EpochFulfilled` + /// * `EpochReconciled` when the epoch has no open request. + /// + /// # Authorization + /// + /// None: anyone may call. #[when_not_paused] pub fn fulfill_epoch(e: &Env, epoch_id: u64) -> i128 { epoch::fulfill(e, epoch_id) @@ -404,29 +988,102 @@ impl AsyncVault { #[contractimpl(contracttrait)] impl AccessControl for AsyncVault { - /// Instant during setup; after it, propose the grant through the timelock. + /// Grants `role` to `account` at once. Only during setup; after it, propose + /// a `GrantRole` change. + /// + /// # Arguments + /// + /// * `account` - The account that gets the role. + /// * `role` - `manager`, `treasury` or `guardian`. + /// * `caller` - The admin. + /// + /// # Errors + /// + /// * `UseTimelock` - Setup has finished. + /// * `AccessControlError::Unauthorized` - `caller` is not the admin. + /// + /// # Events + /// + /// * `RoleGranted`, unless `account` already holds `role`. + /// + /// # Authorization + /// + /// `caller` signs. fn grant_role(e: &Env, account: Address, role: Symbol, caller: Address) { guards::direct_change_allowed(e); access_control::grant_role(e, &account, &role, &caller); extend::role_entries(e, &account, &role); } - /// Instant for every role but `manager`: taking `treasury` or `guardian` - /// away moves no value. After setup, revoking `manager` waits like a grant, - /// because without it no epoch closes and no exit is priced. + /// Takes `role` from `account`. Instant for every role but `manager`: + /// taking `treasury` or `guardian` away moves no value. After setup, + /// revoking `manager` waits like a grant, because without it no epoch + /// closes and no exit is priced. + /// + /// # Arguments + /// + /// * `account` - The account that loses the role. + /// * `role` - `manager`, `treasury` or `guardian`. + /// * `caller` - The admin. + /// + /// # Errors + /// + /// * `UseTimelock` - `manager`, after setup. + /// * `AccessControlError::Unauthorized` - `caller` is not the admin. + /// * `AccessControlError::RoleNotHeld` - `account` does not hold `role`. + /// + /// # Events + /// + /// * `RoleRevoked` + /// + /// # Authorization + /// + /// `caller` signs. fn revoke_role(e: &Env, account: Address, role: Symbol, caller: Address) { guards::direct_revoke_allowed(e, &role); access_control::revoke_role(e, &account, &role, &caller); } - /// Cancelling a pending hand-over (`live_until_ledger == 0`) keeps the - /// current admin, so it stays instant. + /// Offers the admin role to `new_admin`, who takes it with + /// `accept_admin_transfer`. An offer is instant only during setup; after + /// it, propose an `Admin` change. Cancelling an offer stays instant. + /// + /// # Arguments + /// + /// * `new_admin` - The nominee, or the pending nominee when cancelling. + /// * `live_until_ledger` - The last ledger number the nominee can accept + /// in; `0` cancels the pending offer. + /// + /// # Errors + /// + /// * `UseTimelock` - An offer after setup. + /// * `RoleTransferError::InvalidLiveUntilLedger` - The ledger has passed or + /// is beyond the longest entry lifetime. + /// * `RoleTransferError::NoPendingTransfer` - Nothing to cancel. + /// * `RoleTransferError::InvalidPendingAccount` - The cancel names another + /// nominee. + /// + /// # Events + /// + /// * `AdminTransferInitiated` + /// + /// # Authorization + /// + /// The admin signs. fn transfer_admin_role(e: &Env, new_admin: Address, live_until_ledger: u32) { guards::direct_hand_over_allowed(e, live_until_ledger); access_control::transfer_admin_role(e, &new_admin, live_until_ledger); } /// Refused. A role admin could grant that role with no delay. + /// + /// # Arguments + /// + /// * `role`, `admin_role` - Not read. + /// + /// # Errors + /// + /// * `UseTimelock` - Always. fn set_role_admin(e: &Env, _role: Symbol, _admin_role: Symbol) { panic_with_error!(e, VaultError::UseTimelock); } @@ -436,6 +1093,10 @@ impl AccessControl for AsyncVault { /// is a hand-over: propose and apply an `Admin` change /// (`transfer_admin_role` during setup), then the nominee calls /// `accept_admin_transfer`. + /// + /// # Errors + /// + /// * `AdminRequired` - Always. fn renounce_admin(e: &Env) { panic_with_error!(e, VaultError::AdminRequired); } @@ -443,12 +1104,50 @@ impl AccessControl for AsyncVault { #[contractimpl(contracttrait)] impl Pausable for AsyncVault { + /// Pauses the vault: no deposit request, no pricing, and no timelocked + /// change on the kit lands; pending changes wait longer by the time + /// paused. Claims, cancellations and redemption requests stay open. + /// + /// # Arguments + /// + /// * `caller` - A `guardian` holder. + /// + /// # Errors + /// + /// * `AccessControlError::Unauthorized` - `caller` lacks `guardian`. + /// * `PausableError::EnforcedPause` - The vault is already paused. + /// + /// # Events + /// + /// * `Paused` + /// + /// # Authorization + /// + /// `caller` signs. #[only_role(caller, "guardian")] fn pause(e: &Env, caller: Address) { pausable::pause(e); strata_timelock::pause::start(e); } + /// Lifts the vault's pause. + /// + /// # Arguments + /// + /// * `caller` - The admin. + /// + /// # Errors + /// + /// * `AdminRequired` - `caller` is not the admin. + /// * `PausableError::ExpectedPause` - The vault is not paused. + /// + /// # Events + /// + /// * `Unpaused` + /// + /// # Authorization + /// + /// `caller` signs. fn unpause(e: &Env, caller: Address) { guards::caller_is_admin(e, &caller); caller.require_auth(); @@ -459,6 +1158,9 @@ impl Pausable for AsyncVault { #[contractimpl(contracttrait)] impl ClockSource for AsyncVault { + /// The clock the kit's timelocks run on: the upgrade delay and the notice + /// in seconds, the seconds spent paused, and whether the vault is paused + /// or winding down. fn timelock_clock(e: &Env) -> Clock { governance::clock(e) } From 85948256869b623aa729cfc23bf650c9f8bd5d30 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:09:07 -0300 Subject: [PATCH 105/147] docs: publish the error code ranges The kit spec lists each contract's error block and the codes in use, and says how the entrypoint docs name units, errors and signers. AGENTS.md points new error enums at error_enum!. --- AGENTS.md | 5 ++++- docs/kit-spec.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 8f2e454a..568913a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -123,7 +123,10 @@ contracts. The addresses emitted by the deploy script are authoritative. are (`custodian`, `vault`, the allowlisted `account`). OpenZeppelin trait methods keep their own names (`operator`, `user_address`). - **Named errors.** Contract code fails with a named error variant, never with - `unwrap`, `expect` or a bare `panic!`. Tests may unwrap. + `unwrap`, `expect` or a bare `panic!`. Tests may unwrap. Each contract + declares its error enum through `strata_bindings::error_enum!`, which fails + the build if a code leaves the contract's block (`docs/kit-spec.md`, section + 7). - **No boolean switches.** A new entrypoint does one thing. Two behaviours get two functions, like `freeze_partial_tokens` and `unfreeze_partial_tokens`, not one function that takes a `bool`. diff --git a/docs/kit-spec.md b/docs/kit-spec.md index 5856c667..9d1f47fb 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -494,3 +494,49 @@ governance, which is a trust assumption. It also reads the token's compliance contract (OpenZeppelin `TokenBinder`), and calls the oracle's `state` and `freshness_duration` (`OracleFeed`). A replacement for any of these must answer those calls, or the vault's setup cannot finish. + +--- + +## 7. Error Codes and Entrypoint Docs + +Each kit contract owns a block of error codes. A call that fails inside +another contract returns that contract's code: a deposit claim the identity +verifier refuses returns `304`, not a vault code. The blocks never overlap, so +a code always names the contract that raised it. + +| Contract | Error enum | Block | Codes in use | +|---|---|---|---| +| `nav_oracle` | `OracleError` | 3000–3999 | 3001–3021, 3025–3026 | +| `async_vault` | `VaultError` | 6000–6999 | 6000–6001, 6005, 6007, 6009, 6012, 6014, 6029–6035, 6037–6075 | +| `share_token` | `ShareTokenError` | 7000–7999 | 7000–7013, 7015–7016 | +| `compliance` | `ComplianceContractError` | 8000–8999 | 8001–8011 | + +The blocks are defined once, in `crates/strata-bindings` (`ORACLE_ERRORS`, +`VAULT_ERRORS`, `TOKEN_ERRORS`, `COMPLIANCE_ERRORS`). Each contract declares +its enum through `strata_bindings::error_enum!`, and the build fails if a code +leaves its contract's block or two blocks overlap. A code is never reused: a +removed variant leaves its number retired. The testnet identity verifier has +no enum of its own; it refuses with OpenZeppelin's +`RWAError::IdentityVerificationFailed` (304). OpenZeppelin's libraries keep +their codes outside the kit's blocks: below 3000 in the modules the kit uses, +and from 4000 to 5999 in its governance crate. + +Every entrypoint the kit defines or overrides carries a doc in its contract's +spec, so generated clients and block explorers show it. The doc is a one-line +summary, then `# Arguments`, `# Errors`, `# Events` and `# Authorization`; a +section that would be empty is left out, so a view has no `# Events` and no +`# Authorization`. Functions that OpenZeppelin's traits add without an +override keep OpenZeppelin's docs. + +- Amounts are in the asset's base units and shares in the share token's base + units. Prices are in WAD: asset base units per share base unit, times 10^18. + Times and durations are in seconds; `live_until_ledger` is a ledger number. +- An error named without its enum is the contract's own. Any other error + carries its enum, as in `AccessControlError::Unauthorized`. +- `NotInitialized` and `AccountingMismatch` are not listed per entrypoint. They + mean the contract's own state is broken, and any call that reads the broken + entry can return them. +- A call into another contract can fail with that contract's error. The docs + name the ones a caller should plan for, such as the asset's own error when + the payer cannot pay. A replacement identity verifier or compliance module + may refuse with codes of its own. From 85b82adf189d1583ec97266a02443ec53c480ecd Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:53:27 -0300 Subject: [PATCH 106/147] test: check docs fit the contract spec The contract spec keeps the first 1,024 bytes of a doc and drops the rest without a warning, so an over-long doc loses its end, where the `# Authorization` section sits. The test joins every function, enum and variant doc of the five contracts the way soroban-sdk-macros does and fails on any doc over the limit. --- crates/strata-bindings/tests/spec_docs.rs | 77 +++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 crates/strata-bindings/tests/spec_docs.rs diff --git a/crates/strata-bindings/tests/spec_docs.rs b/crates/strata-bindings/tests/spec_docs.rs new file mode 100644 index 00000000..8436edb6 --- /dev/null +++ b/crates/strata-bindings/tests/spec_docs.rs @@ -0,0 +1,77 @@ +//! The contract spec keeps the first 1,024 bytes of each doc and drops the rest +//! without a warning, so an over-long doc loses its end, where `# Authorization` +//! sits. + +use std::fs; +use std::path::Path; + +const SPEC_DOC_LIMIT: usize = 1024; + +/// Every entrypoint and error enum of the five contracts lives in these files. +const CONTRACT_SOURCES: [&str; 7] = [ + "contracts/async-vault/src/lib.rs", + "contracts/async-vault/src/error.rs", + "contracts/share-token/src/contract.rs", + "contracts/share-token/src/error.rs", + "contracts/nav-oracle/src/lib.rs", + "contracts/compliance/src/lib.rs", + "contracts/identity-verifier/src/lib.rs", +]; + +/// The doc of every function, enum and enum variant in `source`, joined the +/// way `soroban-sdk-macros` stores it: each line trimmed, then joined by `\n`. +fn item_docs(source: &str) -> Vec<(String, String)> { + let mut docs = Vec::new(); + let mut block: Vec<&str> = Vec::new(); + for line in source.lines().map(str::trim) { + if let Some(text) = line.strip_prefix("///") { + block.push(text.trim()); + continue; + } + if block.is_empty() || line.is_empty() || line.starts_with("#[") { + continue; + } + if let Some(item) = item_name(line) { + docs.push((item, block.join("\n"))); + } + block.clear(); + } + docs +} + +fn item_name(line: &str) -> Option { + if let Some((_, rest)) = line.split_once("fn ") { + return rest.split('(').next().map(|name| format!("fn {name}")); + } + if let Some(rest) = line.strip_prefix("pub enum ") { + return rest + .split_whitespace() + .next() + .map(|name| format!("enum {name}")); + } + let (name, code) = line.split_once(" = ")?; + let is_variant = name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') + && code.trim_end_matches(',').parse::().is_ok(); + is_variant.then(|| format!("variant {name}")) +} + +#[test] +fn every_contract_doc_fits_the_spec() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let mut too_long = Vec::new(); + for path in CONTRACT_SOURCES { + let source = fs::read_to_string(root.join(path)).unwrap(); + let docs = item_docs(&source); + assert!(!docs.is_empty(), "{path}: no documented item found"); + for (item, doc) in docs { + if doc.len() > SPEC_DOC_LIMIT { + too_long.push(format!("{path}: {item} has {} bytes", doc.len())); + } + } + } + assert!( + too_long.is_empty(), + "the contract spec keeps {SPEC_DOC_LIMIT} bytes of a doc:\n{}", + too_long.join("\n") + ); +} From 527620dd14ba02b4871dad113f4ef3e00846b1c7 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:53:59 -0300 Subject: [PATCH 107/147] docs: correct the cancel and payout docs A valid report accepted after the close refuses a cancellation even while the notice runs, so PriceAvailable says that instead of "can be priced now". The constructor reads compliance only for the distinctness check and never reads attester. Payouts name the asset's own error when the holder cannot receive, legal_transfer lists TokensUnfrozen, and the ReserveCommittedToExits and WindDownActive docs and the kit spec's note on shared OpenZeppelin codes say what the code does. --- contracts/async-vault/src/error.rs | 9 +++++---- contracts/async-vault/src/lib.rs | 26 ++++++++++++++++---------- contracts/share-token/src/contract.rs | 3 ++- docs/kit-spec.md | 4 +++- 4 files changed, 26 insertions(+), 16 deletions(-) diff --git a/contracts/async-vault/src/error.rs b/contracts/async-vault/src/error.rs index 43f91984..c20c3903 100644 --- a/contracts/async-vault/src/error.rs +++ b/contracts/async-vault/src/error.rs @@ -50,8 +50,9 @@ strata_bindings::error_enum! { /// The vault does not hold enough to pay this claim yet. The liability /// stands and the claim succeeds once the reserve covers it. ClaimNotCovered = 6037, - /// The amount would deploy assets already owed to holders whose exit has - /// been priced but not yet claimed. + /// The amount would deploy assets that are not free: escrow depositors + /// can still cancel, or cash owed to holders whose exit has been priced + /// but not yet claimed. ReserveCommittedToExits = 6005, /// No custodian has been set, so capital has nowhere to go. CustodianNotSet = 6012, @@ -79,8 +80,8 @@ strata_bindings::error_enum! { /// could outlive would strand their exit with no way to undo it. NoticeTooLong = 6045, /// The vault is winding down. New requests, epoch closes, pricing, - /// transfers to the custodian and governance changes are closed for the - /// rest of its life. + /// transfers to the custodian, wind-down proposals and timelocked + /// changes are closed for the rest of its life. WindDownActive = 6046, /// No wind-down proposal stands. WindDownNotProposed = 6047, diff --git a/contracts/async-vault/src/lib.rs b/contracts/async-vault/src/lib.rs index 6c148082..71045251 100644 --- a/contracts/async-vault/src/lib.rs +++ b/contracts/async-vault/src/lib.rs @@ -79,8 +79,8 @@ impl AsyncVault { /// * `share_token` - The share token. /// * `oracle` - The feed epochs are priced from. /// * `roles` - `governance` becomes the admin; `manager`, `treasury` and - /// `guardian` get their roles. `compliance` and `attester` are only - /// checked for distinctness. + /// `guardian` get their roles. `compliance` is read only by the + /// distinctness check; `attester` is not read. /// /// # Errors /// @@ -438,6 +438,7 @@ impl AsyncVault { /// * `DistributionNotStarted` - No round has been finalised. /// * `Frozen` - `holder` is frozen. /// * `NoEntitlement` - Nothing to surrender and nothing owed. + /// * The asset's own error when `holder` cannot receive it. /// /// # Events /// @@ -763,8 +764,8 @@ impl AsyncVault { } /// Mints a priced deposit's shares and returns them, in share base units. - /// Zero means none: a deposit worth less than one share was refunded, or a - /// delisted holder's shares joined an exit (`DepositConvertedToExit`). + /// Zero means none: a deposit worth less than one share base unit was + /// refunded, or a delisted holder's shares joined an exit. /// /// # Arguments /// @@ -780,13 +781,14 @@ impl AsyncVault { /// * `Frozen` - `holder` is frozen. /// * `ClaimNotCovered` - The free reserve cannot cover a refund yet. /// * `RWAError::MintNotCompliant` - The compliance rules refuse the mint. - /// * `RWAError::IdentityVerificationFailed` - A conversion while the vault - /// is off the allowlist. + /// * `RWAError::IdentityVerificationFailed` - The vault is delisted, for a + /// conversion. + /// * The asset's own error when `holder` cannot receive a refund. /// /// # Events /// - /// * `DepositClaimed`, `DepositConvertedToExit`, or `DepositCancelled` - /// for a refund. + /// * `DepositClaimed`, `DepositConvertedToExit` or, for a refund, + /// `DepositCancelled`. /// * `EpochReconciled` once the epoch's last request settles. /// /// # Authorization @@ -809,9 +811,11 @@ impl AsyncVault { /// /// * `EpochNotFound` - No such epoch. /// * `AlreadyPriced` - The epoch is priced; claim instead. - /// * `PriceAvailable` - The epoch can be priced now. + /// * `PriceAvailable` - A report accepted after the close is valid, so the + /// price is known, even while the notice runs. /// * `RequestNotFound` - `holder` has no deposit in the epoch. /// * `Frozen` - `holder` is frozen. + /// * The asset's own error when `holder` cannot receive it. /// /// # Events /// @@ -838,7 +842,8 @@ impl AsyncVault { /// /// * `EpochNotFound` - No such epoch. /// * `AlreadyPriced` - The epoch is priced; claim instead. - /// * `PriceAvailable` - The epoch can be priced now. + /// * `PriceAvailable` - A report accepted after the close is valid, so the + /// price is known, even while the notice runs. /// * `RequestNotFound` - `holder` has no redemption in the epoch. /// * `NotAllowlisted` - `holder` is delisted. /// * `Frozen` - `holder` is frozen. @@ -914,6 +919,7 @@ impl AsyncVault { /// * `Frozen` - `holder` is frozen. /// * `ClaimNotCovered` - The liquid reserve cannot pay it yet. /// * `HolderNotEligible` - Shares priced at nothing cannot come back. + /// * The asset's own error when `holder` cannot receive it. /// /// # Events /// diff --git a/contracts/share-token/src/contract.rs b/contracts/share-token/src/contract.rs index a8f4e952..f3d8dd15 100644 --- a/contracts/share-token/src/contract.rs +++ b/contracts/share-token/src/contract.rs @@ -231,7 +231,8 @@ impl ShareToken { /// /// # Events /// - /// * `Transfer`, then `LegalTransfer`. + /// * `Transfer`, then `LegalTransfer`; `TokensUnfrozen` first when frozen + /// shares move. /// /// # Authorization /// diff --git a/docs/kit-spec.md b/docs/kit-spec.md index 9d1f47fb..1d286016 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -502,7 +502,9 @@ must answer those calls, or the vault's setup cannot finish. Each kit contract owns a block of error codes. A call that fails inside another contract returns that contract's code: a deposit claim the identity verifier refuses returns `304`, not a vault code. The blocks never overlap, so -a code always names the contract that raised it. +a code inside them always names the kit contract that raised it. OpenZeppelin's +codes are shared: every contract built on its libraries returns the same code +for the same failure, so `304` says what failed, not where. | Contract | Error enum | Block | Codes in use | |---|---|---|---| From 3180dd5beae2afe76b528b8b3225fc2e5e8340b2 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 05:51:47 -0300 Subject: [PATCH 108/147] docs: open each contract with its design notes --- contracts/async-vault/src/lib.rs | 68 ++++++++++++++++++++++++++ contracts/compliance/src/lib.rs | 23 +++++++++ contracts/identity-verifier/src/lib.rs | 23 +++++++++ contracts/nav-oracle/src/lib.rs | 44 +++++++++++++++++ contracts/share-token/src/lib.rs | 38 ++++++++++++++ 5 files changed, 196 insertions(+) diff --git a/contracts/async-vault/src/lib.rs b/contracts/async-vault/src/lib.rs index 71045251..362e460d 100644 --- a/contracts/async-vault/src/lib.rs +++ b/contracts/async-vault/src/lib.rs @@ -1,3 +1,71 @@ +//! # Design +//! +//! Deposits and redemptions are requests. Each joins the open epoch; the +//! epoch is sealed and priced at the oracle's report, and each holder claims +//! the result. +//! +//! - **The manager seals, anyone prices.** Sealing an epoch needs the +//! `manager` role. Pricing it is open to anyone once its notice has run and +//! a valid report accepted after the seal exists, so the price is the +//! oracle's, not the caller's. The cost: a manager that stops sealing, or an +//! attester that stops reporting, stalls every request until it is +//! replaced, and after setup a replacement waits for the timelock. +//! - **Pricing records totals; claims mint and burn.** Pricing costs the same +//! whatever the number of requests: it fixes the price and the epoch's +//! totals, and each claim then mints or burns its own shares. The cost: the +//! token's supply is not the economic supply until every claim is made, and +//! since each claim rounds down, the epoch's remainder stays in +//! `pending_mint_shares` and `committed` until its last request settles. A +//! request that can never settle keeps that remainder there. +//! - **A readable price closes cancellation.** Cancellation closes once a +//! valid report accepted after the seal exists, even while the notice runs, +//! and reopens whenever the vault could not price the epoch now: the feed +//! goes stale, the NAV is paused or the vault is paused. Nobody declines a +//! price they have seen, and no escrow waits on a stalled feed. The cost: a +//! report that lapses lets requests leave a sealed epoch late, so +//! `freshness_duration` should outlast the notice. +//! - **Priced before paid.** A redemption is priced whether or not the reserve +//! holds the cash, and its claim fails with `ClaimNotCovered` until the +//! liquid reserve covers it. Covered claims pay in any order, and nothing +//! goes to the custodian while anything is uncovered. The cost: a priced +//! exit can wait on treasury, and a later, smaller claim can be paid before +//! an earlier, larger one. +//! - **A refund below one share is not reserved.** A deposit worth less than +//! one share base unit mints nothing and is refunded at claim, from the free +//! reserve. Pricing sees only the epoch's totals, and setting these refunds +//! aside would mean visiting every request, for less than one share base +//! unit each. The cost: treasury can deploy that cash, or a wind-down round +//! credit it, before the claim, which then fails with `ClaimNotCovered` +//! until the free reserve covers it again. +//! - **Checked totals.** Every running total of assets or shares changes +//! through checked arithmetic and fails by name: `AmountTooLarge` when it +//! would overflow, `AccountingMismatch` when it would go below zero. None +//! wraps or clamps. The cost: if the books ever disagree, the call that finds +//! it fails instead of carrying on with a clamped figure. +//! - **A freeze holds every exit.** A frozen address cannot request a deposit, +//! and its claims, cancellations, redemption requests and wind-down claims +//! fail with `Frozen` until the freeze lifts. Compliance can delay a payout +//! this way, never redirect it. The cost: even a covered cash claim waits for +//! the unfreeze. +//! - **A delisted holder leaves in cash.** A holder delisted after pricing gets +//! no shares. Outside a wind-down the priced shares are minted into the +//! vault's escrow and join the holder's redemption in the open epoch; in a +//! wind-down they are credited to the holder's entitlement. No share reaches +//! an unlisted address, and the holder keeps a way out. The cost: the holder +//! waits another epoch and takes its price, and the conversion waits while +//! the vault's own address is delisted. +//! - **Setup, then the timelock.** Admin changes are instant during setup, +//! which takes no deposit and ends only once `finish_setup` finds every +//! wiring check true; it never reopens. After it, the changes that can move +//! value or shorten a wait go through the timelock. The cost: a mistake +//! found after setup takes a full delay, at least seven days, to fix. +//! - **Wind-down claims run the compliance hooks.** A wind-down claim that +//! surrenders shares burns them through the token, which runs the compliance +//! contract's `destroyed` modules. The cost: a module that starts failing +//! during a wind-down blocks those claims, and it cannot be removed then, +//! because removing a module waits for the timelock and no timelocked change +//! lands during a wind-down. + #![no_std] mod deposit; diff --git a/contracts/compliance/src/lib.rs b/contracts/compliance/src/lib.rs index 63b19938..c5c04a4d 100644 --- a/contracts/compliance/src/lib.rs +++ b/contracts/compliance/src/lib.rs @@ -1,5 +1,28 @@ +//! # Design +//! //! Testnet-only stub — in production this is an external modular-compliance //! module (ERC-3643), not part of this product. Do not deploy to mainnet. +//! +//! - **No rule of its own.** With no module registered, every check the token +//! runs passes; the allowlist lives in the identity verifier, which the token +//! asks separately. The cost: any rule beyond the allowlist needs a module or +//! another compliance contract. +//! - **Changes wait for the vault's timelock.** After setup, adding or removing +//! a module, binding or unbinding a token and handing over the admin wait for +//! the vault's delay; a paused vault holds them and a wind-down refuses them. +//! The cost: a faulty module takes a full delay to remove, and during a +//! wind-down it cannot be removed at all, so a module that fails on a burn +//! blocks every claim that burns shares. +//! - **An unbound token fails.** The hooks the token calls after every mint, +//! transfer and burn refuse a token that is not bound. The cost: unbinding +//! the share token stops all three, the vault's claims included. +//! - **The operator is not read.** OpenZeppelin's module and binding calls take +//! an `operator`; the stub ignores it and asks the admin to sign. The cost: +//! the admin cannot delegate those calls to an operator. +//! - **No upgrade.** The stub has no upgrade path. The cost: a fix means a new +//! deployment and a token `Compliance` change, which waits for the vault's +//! timelock after setup. + #![no_std] mod governance; diff --git a/contracts/identity-verifier/src/lib.rs b/contracts/identity-verifier/src/lib.rs index 77e1e0ac..5e069f92 100644 --- a/contracts/identity-verifier/src/lib.rs +++ b/contracts/identity-verifier/src/lib.rs @@ -1,5 +1,28 @@ +//! # Design +//! //! Testnet-only stub — in production this is an external ERC-3643 identity //! registry (a trusted issuer's), not part of this product. Do not deploy to mainnet. +//! +//! - **A yes or no per address.** An account is allowed or not, and one never +//! set is refused. There are no claims, topics or issuers: +//! `set_claim_topics_and_issuers` does nothing. The cost: no expiry and no +//! per-jurisdiction rule; a deployment that needs them brings its own +//! registry. +//! - **Instant edits.** The admin lists and delists at once, during setup and +//! after it, with no timelock. The cost: a stolen admin key can list or +//! delist any account at once; a delisted holder still leaves through the +//! vault's cash path. +//! - **A fixed admin, no upgrade.** There is no admin hand-over and no upgrade +//! path. The cost: rotating the admin key or fixing the code means a new +//! verifier and a token `IdentityVerifier` change, which waits for the +//! vault's timelock after setup. +//! - **No recovery account.** `recovery_target` always answers `None`, so the +//! token's `recover_balance` always fails. The cost: a lost wallet's shares +//! move with `legal_transfer` instead, signed by compliance and the admin. +//! - **OpenZeppelin's error code.** A refusal is +//! `RWAError::IdentityVerificationFailed` (304); the stub has no error enum. +//! The cost: that code says what failed, not which contract refused. + #![no_std] use soroban_sdk::{ diff --git a/contracts/nav-oracle/src/lib.rs b/contracts/nav-oracle/src/lib.rs index fc6c0983..acd0ccfc 100644 --- a/contracts/nav-oracle/src/lib.rs +++ b/contracts/nav-oracle/src/lib.rs @@ -1,3 +1,47 @@ +//! # Design +//! +//! The feed the vault prices epochs from. An attester reports the share price, +//! and the oracle keeps the latest report that passes the limits the admin +//! sets. +//! +//! - **The ledger dates a report.** The oracle stores the time it accepts a +//! report and ignores the reporter's own timestamp. The report expires at the +//! earlier of the reporter's `expires_at` and that time plus +//! `freshness_duration`, so a reporter can shorten a report's life but never +//! extend it. The cost: the vault's rule that a price must come after an +//! epoch's seal compares acceptance times, not valuation dates. +//! - **No report while the NAV is paused.** The guardian or the admin pauses +//! the NAV, and only the admin lifts the pause, so reporting resumes by a +//! deliberate act. The cost: while the pause lasts the vault prices nothing, +//! and sealed epochs reopen to cancellation. +//! - **Tightening waits too.** After setup every config change waits for the +//! vault's timelock; only a shorter `freshness_duration` is instant. A +//! tighter fall cap can hold the price above a real loss, and a narrower band +//! can stop pricing, so tightening moves value too. The cost: an urgent +//! change to the limits takes the full delay; the NAV pause is the instant +//! stop. +//! - **The band applies on read.** A stored report outside the current band +//! reads as stale. The cost: narrowing the band can stop pricing the moment +//! the change lands, until a report inside the new band arrives. +//! - **Both moves are capped.** A report may move from the last one by at most +//! `max_up_bps` up and `max_down_bps` down, and both caps are required. A +//! fall cap of 10,000 bps lets a loss of any size land at once, leaving +//! `min_answer` as the only floor. The cost of a lower fall cap: a real loss +//! takes several reports, at least a cooldown apart, and redemptions priced +//! meanwhile are paid above value. +//! - **Revoking is instant, granting waits.** Taking the `attester` or +//! `guardian` role away moves no value and stays instant; after setup a grant +//! waits for the timelock. The cost: replacing a compromised attester keeps +//! the NAV paused for the whole delay. +//! - **Clearing needs a paused NAV.** `clear_latest` removes the stored report +//! only while the NAV is paused, so the admin clears and the attester's next +//! report is a separate act. The cost: that report has no earlier price to +//! cap its move; only the band bounds it. +//! - **One report per cooldown.** The cooldown is at least one second and at +//! most `freshness_duration`: never two reports in one ledger, and the next +//! report is always allowed before the freshness window ends. The cost: an +//! epoch sealed just after a report waits up to a cooldown for the next one. + #![no_std] mod extend; diff --git a/contracts/share-token/src/lib.rs b/contracts/share-token/src/lib.rs index a9f55cdd..24e78722 100644 --- a/contracts/share-token/src/lib.rs +++ b/contracts/share-token/src/lib.rs @@ -1,3 +1,41 @@ +//! # Design +//! +//! The vault's share token: OpenZeppelin's RWA token, which asks the identity +//! verifier about every receiver and the compliance contract about every move. +//! The kit adds its roles, its guards and the vault's timelock. +//! +//! - **No clock of its own.** After setup, upgrades, grants, revoking +//! `manager`, admin hand-overs and compliance or verifier swaps wait for the +//! vault's timelock. Revoking `compliance` and the transfer pause stay +//! instant. The cost: a paused vault holds every token change, and none +//! lands during a wind-down. +//! - **The vault is the only manager.** `mint`, `burn` and `forced_transfer` +//! need `manager`, which the vault must hold alone for its setup to finish. +//! `forced_transfer` skips the pause, freezes, the identity check and the +//! compliance rules' check, so the vault checks the receiver itself before it +//! returns shares. The cost: a `manager` can move any holder's shares without +//! their signature, so after setup granting or revoking it waits for the +//! timelock. +//! - **Two signers move a holder's shares.** `legal_transfer` and +//! `recover_balance` need a `compliance` holder and the admin to sign as two +//! different accounts, and the shares land only on an allowlisted account. +//! The cost: a legal order needs both signers. A legal transfer of zero +//! shares is accepted: it moves nothing but publishes its order reference. +//! - **The vault's address is off limits.** Once the token is bound to the +//! vault, its address cannot be frozen, in full or in part, and no legal +//! transfer or recovery moves shares into or out of it: its balance is other +//! holders' escrow. The cost: an order cannot reach shares escrowed in the +//! vault; compliance freezes the holder instead, which holds that holder's +//! exits. +//! - **The pause halts trading only.** The transfer pause is the admin's, not +//! the guardian's. It stops `transfer` and `transfer_from`; mints, burns, +//! escrow moves and legal transfers go on. The cost: it is not an emergency +//! stop for the vault. +//! - **A compliance swap is not checked.** Swapping the compliance contract +//! does not check that the new contract lists this token. The cost: if it +//! does not, every mint, transfer and burn fails until it binds the token, +//! and the vault's `health()` reports `compliance_bound` false. + #![no_std] mod contract; mod error; From e34e5b980faf705cddbcc8ed88d9914ed11cfeaf Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 05:51:57 -0300 Subject: [PATCH 109/147] docs: open the shared crates with design notes --- crates/strata-bindings/src/lib.rs | 20 ++++++++++++++++++ crates/strata-storage/src/lib.rs | 22 ++++++++++++++++++++ crates/strata-timelock/src/lib.rs | 34 +++++++++++++++++++++++++++++-- 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/crates/strata-bindings/src/lib.rs b/crates/strata-bindings/src/lib.rs index 98f582c1..706fac33 100644 --- a/crates/strata-bindings/src/lib.rs +++ b/crates/strata-bindings/src/lib.rs @@ -1,3 +1,23 @@ +//! # Design +//! +//! The interfaces the kit's contracts share, and each contract's block of +//! error codes. +//! +//! - **The compiler checks the feed.** The vault prices from `OracleFeed`, +//! which the kit's oracle and any feed an adopter brings both implement. The +//! cost: a feed must answer the trait's four calls as written; any other +//! shape needs an adapter. +//! - **Bound contracts report their binding.** A contract that runs on a +//! vault's timelock answers `VaultBinding`, and the vault's `finish_setup` +//! reads it. The cost: a compliance contract that does not answer it is +//! accepted as an external module, so its own governance is a trust +//! assumption. +//! - **A block of codes per contract.** Each kit contract owns a block of error +//! codes, and `error_enum!` fails the build if a code leaves its block or two +//! blocks overlap, so a code inside the blocks names the contract that raised +//! it. The cost: a code is never reused, so a removed variant leaves its +//! number retired. + #![no_std] mod errors; diff --git a/crates/strata-storage/src/lib.rs b/crates/strata-storage/src/lib.rs index 6ad5f337..0b11996a 100644 --- a/crates/strata-storage/src/lib.rs +++ b/crates/strata-storage/src/lib.rs @@ -1,3 +1,25 @@ +//! # Design +//! +//! Storage helpers that keep the kit's own entries alive. Every read and every +//! write through them asks the network to renew the entry it touches. +//! +//! - **Thirty days, renewed a day short.** Each entry, the contract instance +//! included, goes back to 30 days once it falls a day short of that. The +//! cost: an entry in daily use renews about once a day, for about a day of +//! rent, and one nobody reads archives when its lifetime runs out. +//! - **Extended in the call that creates it.** A write asks for the renewal +//! too, so a new entry lives at least its target from the call that creates +//! it. The cost: that call pays for the target, or for the network's minimum +//! when that is longer. +//! - **The same lengths on every network.** They are fixed in code, and each +//! network applies its own minimum lifetime on top, so the Wasm tested on +//! testnet is the Wasm that ships. The cost: no network gets lengths of its +//! own, and changing them takes an upgrade. +//! - **No cap per call.** An extension always reaches the full target. A cap +//! would let rarely read entries archive sooner, and a restore on mainnet +//! pays at least 120 days of rent. The cost: the first call after a quiet +//! period pays to renew everything it touches. + #![no_std] use core::fmt::Debug; diff --git a/crates/strata-timelock/src/lib.rs b/crates/strata-timelock/src/lib.rs index eb3d3139..9f117357 100644 --- a/crates/strata-timelock/src/lib.rs +++ b/crates/strata-timelock/src/lib.rs @@ -1,6 +1,36 @@ +//! # Design +//! //! A timelock for privileged changes. One contract owns the clock: its delay, -//! its exit window, its pause time and whether it winds down. That contract and -//! every contract bound to it queue their changes against the same clock. +//! its notice (the clock's `exit_window`), its pause time and whether it winds +//! down. That contract and every contract bound to it queue their changes +//! against the same clock. +//! +//! - **One clock, the vault's.** The share token, the oracle and the kit's +//! compliance contract bind to the vault once, during setup, and read its +//! clock on every proposal and every apply. One pause and one wind-down then +//! hold every waiting change in the kit. The cost: while the vault is paused +//! no contract in the kit can apply a change, and a contract bound to the +//! wrong vault stays bound. +//! - **A setup window first.** Changes are instant until setup finishes, and +//! nothing is queued before it; setup closes once and never reopens. The +//! cost: a mistake found after setup takes a full delay to fix. +//! - **One queue per contract.** Every kind of change waits in the same queue, +//! at most `MAX_PENDING` at a time, and an id is never handed out twice. The +//! cost: a full queue refuses proposals until a change lands or is cancelled. +//! - **The delay is an announcement period.** A change keeps the delay it was +//! proposed with, its eta moves back by every second the vault spends paused, +//! and nothing lands while the vault is paused. The delay promises notice, +//! not an exit: leaving before a change lands also needs a sealed epoch, a +//! price and cash. The cost: a long pause delays every fix. +//! - **Never shorter than the notice.** A change whose delay is shorter than +//! the notice when it applies never lands. The cost: raising the notice can +//! strand changes already waiting; they must be proposed again. +//! - **Nothing lands in a wind-down.** Once a wind-down starts nothing is +//! proposed or applied, and changes already waiting never land. The cost: +//! nothing can be fixed during a wind-down, a failing compliance module +//! included. +//! - **Seven to ninety days.** `MIN_DELAY` and `MAX_DELAY` bound the delay the +//! vault sets. The cost: the fastest fix after setup takes a week. #![no_std] From 77b236e716a4a53ae0149da34ce17739e5f1e0fc Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 05:55:58 -0300 Subject: [PATCH 110/147] docs: correct the attester and manager docs --- contracts/async-vault/src/lib.rs | 7 +--- contracts/async-vault/src/roles.rs | 2 +- docs/AUTHORIZATION_MATRIX.md | 4 +-- docs/kit-spec.md | 16 +++++++-- docs/strata-product-and-architecture.md | 43 +++++++++++++------------ 5 files changed, 40 insertions(+), 32 deletions(-) diff --git a/contracts/async-vault/src/lib.rs b/contracts/async-vault/src/lib.rs index 362e460d..534b9acc 100644 --- a/contracts/async-vault/src/lib.rs +++ b/contracts/async-vault/src/lib.rs @@ -169,12 +169,7 @@ impl AsyncVault { oracle: Address, roles: VaultRoles, ) { - // The vault kit white-label topology specifies five distinct authorities: - // governance, manager, treasury, guardian, compliance, and attester. - // Although compliance is enforced on the identity verifier and attestation - // on the NAV oracle, the vault checks pairwise distinctness at construction - // time to prevent role misconfiguration across the kit ecosystem. Neither - // compliance nor attester is granted a direct role on the vault contract itself. + // `roles` holds the five authorities and the manager; the attester is not read. guards::roles_distinct(e, &roles); access_control::set_admin(e, &roles.governance); diff --git a/contracts/async-vault/src/roles.rs b/contracts/async-vault/src/roles.rs index dbedcab8..6f9c4302 100644 --- a/contracts/async-vault/src/roles.rs +++ b/contracts/async-vault/src/roles.rs @@ -1,6 +1,6 @@ use soroban_sdk::{contracttype, symbol_short, Address, Symbol}; -/// Closes an epoch and sets its share price. Held by the Manager contract. +/// Seals the open epoch. Not the share token's `manager`, which the vault holds. pub const MANAGER_ROLE: Symbol = symbol_short!("manager"); /// Moves capital to the custodian and funds the vault back. diff --git a/docs/AUTHORIZATION_MATRIX.md b/docs/AUTHORIZATION_MATRIX.md index b1174021..a74c0ae6 100644 --- a/docs/AUTHORIZATION_MATRIX.md +++ b/docs/AUTHORIZATION_MATRIX.md @@ -13,9 +13,9 @@ This document provides the complete authorization and role model specification f - A role entrypoint checks the role of the `caller` or `operator` it names, then asks that account to sign. `legal_transfer` and `recover_balance` also ask the admin, a different account, to sign the same call. - The testnet stubs are the exception. The compliance contract's `bind_token`, `unbind_token`, `add_module_to` and `remove_module_from`, and the identity verifier's `set_claim_topics_and_issuers`, keep the `operator` argument their trait fixes but do not read it. `#[only_admin]` asks the stored admin to sign, so each still asks for one signature. Production replaces both stubs with external contracts (§5.4, §5.5). 3. **Exit guarantee, with one hold**: A covered redemption claim pays out cash regardless of pause, staleness or allowlist status. A delisted investor exits through the cash path and cannot re-enter share circulation. A frozen investor is held: the vault refuses their requests, cancellations and claims, and pays them only after the freeze lifts. -4. **Five Separate Authorities**: +4. **Five Separate Authorities, plus the Vault's `manager` Role**: - **Governance (Admin)**: Root authority; upgrades, timelock parameters, custodian settings, unpause, oracle parameters, emergency NAV pause reset. As the share token's admin, it co-signs compliance's `legal_transfer` and `recover_balance`. - - **Manager (Vault)**: Closes epochs. + - **Manager (vault role, not an authority)**: Closes epochs. - **Treasury**: Deploys free reserve assets to custodian. - **Guardian**: Emergency responder; pauses vault entries, pauses the oracle's NAV. - **Attester (Oracle)**: Submits off-chain valuation reports within strict deviation and rate bounds. diff --git a/docs/kit-spec.md b/docs/kit-spec.md index 1d286016..76f08fd3 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -199,11 +199,23 @@ Strata distributes control across five native Stellar multisig authorities and p | **Governance** | High-quorum deliberative multisig | Set parameters (`notice`, `custodian`, `deposit_cap`, `wind_down_delay`), propose/apply upgrades, propose/cancel wind-down, unpause the vault, pause/unpause the share token, transfer admin, finish each contract's one-time setup, co-sign compliance's `legal_transfer` and `recover_balance` as the share token's admin. After setup, these wait for the timelock: role grants, revoking the manager, admin hand-over, the custodian, lowering the notice or the wind-down delay, upgrades, the token's compliance and verifier swaps, the compliance contract's module and token-binding changes, and oracle config changes other than a shorter freshness window. Revoking the treasury or the guardian stays instant, because taking a power away moves no value. | **Cannot pause the vault directly**; cannot move funds; cannot initiate custodian transfers; cannot attest prices; cannot bypass timelocks. | | **Guardian** | Low-threshold fast-reaction multisig | `pause` (vault); `pause_nav` (oracle emergency price freeze). | **Cannot unpause**; cannot move funds; cannot block redemption requests; cannot block payable claims or cancellations. | | **Treasury** | Operational capital multisig | `deploy_to_custodian` (moves free reserve); `fund` (returns assets from custodian). | **Cannot deploy while anything is uncovered**; cannot touch escrowed subscription assets; cannot alter parameters or pause. | -| **Attester** | High-security attestation multisig | `attest` (submits NAV report with proof reference). | **Cannot price outside deviation band**; cannot violate cooldown; cannot operate on the vault directly. | +| **Attester** | High-security attestation multisig | `attest` (submits a NAV report: the price and its expiry). | **Cannot price outside deviation band**; cannot violate cooldown; cannot operate on the vault directly. | | **Compliance** | Legal / KYC operator multisig | Manage allowlist (`allow`), topic claims; token interventions (`freeze`; `legal_transfer` and `recovery` co-signed by governance). | **Cannot touch vault reserve**; cannot move a holder's shares without governance's signature in the same call; cannot move shares out of or into the vault's address, or freeze it once the token knows its vault (an earlier freeze makes the vault's `finish_setup` refuse); cannot pause vault operations; cannot bypass exit-only cash redemptions for an unfrozen holder — freezing holds a covered claim until it lifts, rather than paying or redirecting it. | -| **Manager** | Operational keeper account / vault | `close_epoch` (vault); `mint`, `burn`, `forced_transfer` (share token). | **Cannot choose prices**; cannot prevent epoch fulfillment; cannot alter timelocks. | +| **Manager (vault role)** | Operational keeper account; a role, not one of the five authorities | `close_epoch`: seals the open epoch, nothing else. | **Cannot choose prices**; cannot mint, burn or move shares; cannot stop a sealed epoch from being priced, though not sealing stalls every request; cannot alter timelocks. After setup, granting or revoking it waits for the timelock. | +| **Manager (share-token role)** | The vault contract, alone: the vault's `finish_setup` refuses while another account holds it | `mint`, `burn`, `forced_transfer` on the share token: the vault mints and burns at claim and moves escrowed shares. | Cannot freeze, recover or make a legal transfer. After setup, granting or revoking it waits for the vault's timelock. See [`AUTHORIZATION_MATRIX.md`](./AUTHORIZATION_MATRIX.md) §2. | | **Permissionless** | Any caller / keeper / investor | `fulfill_epoch`, `request_deposit`, `cancel_deposit`, `claim_deposit`, `request_redeem`, `cancel_redeem`, `claim_redeem`, `activate_wind_down`, `finalize_wind_down_round`, `claim_wind_down`. | Operations are strictly constrained by contract state, timing, and solvency guards. | +### Replacing a Compromised Attester + +Pause first, so no report lands while the roles change, and lift the pause last: + +1. Pause the NAV: the oracle's guardian calls `pause_nav`, or its admin calls `set_nav_paused(true)`. The oracle refuses every report and the vault prices nothing; sealed epochs reopen to cancellation. +2. Revoke the compromised attester: `revoke_role(attester, "attester", admin)` on the oracle. It is instant, during setup and after it. +3. Grant a replacement. During the oracle's setup, `grant_role` is instant. After it, propose a `GrantRole` change and apply it once the vault's delay has run, plus any time the vault spends paused. The NAV stays paused that whole time, at least seven days. +4. If the stored report is wrong, call `clear_latest` (admin; only while the NAV is paused). The next report then has no earlier price to cap its move: only `min_answer` and `max_answer` bound it. +5. Lift the NAV pause: `set_nav_paused(false)` (admin). +6. The new attester posts a report. An epoch is priced once a report accepted after its seal is valid. + --- ### Comprehensive Entrypoint Permission Matrix diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index 6fabcb42..6d8b50fb 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -76,7 +76,7 @@ or legal solution, and not a vault for on-chain RWA tokens. | Actor | On-chain authority | Does | |---|---|---| | Investor (LP) | None (allowlisted address) | Requests deposits and redemptions, claims shares or cash, cancels pending requests, views position | -| Valuation reporter | attestation | Attests the share price with a proof reference, under on-chain guardrails | +| Valuation reporter | attestation | Attests the share price under on-chain guardrails | | Treasury ops | treasury | Moves free reserve between the vault and the custodian | | Epoch sealer | manager (vault role, not an authority) | Seals the open epoch (`close_epoch`), nothing else; a vault role with its own key at deploy, meant to become permissionless (§8.4) | | Compliance | compliance | Maintains the allowlist; token interventions (freeze; legal-order transfer and recovery, each co-signed by governance) | @@ -222,7 +222,7 @@ flowchart TB LP -->|"requests · claims"| V ADM -->|"admin operations"| AUTH - OR ---|"attested value + proof ref ↑"| VS + OR ---|"attested value ↑"| VS IVC ---|"verified addresses ↑"| KYC SAC ---|"reserve in / out"| TR TR -->|"deploy_to_custodian"| CU @@ -280,7 +280,7 @@ under the [Admin panel](#admin-panel) table in §8.8. Both interfaces are backend-free: they read contract state and build transactions that the authority multisigs sign. The chain never sees KYC data or valuation methodology, only their outputs: an allowlisted address, an attested -number with a proof reference. +number. ### 8.3 Components and authorities @@ -361,9 +361,10 @@ sequenceDiagram ### 8.5 Valuation and accounting -The NAV is a permissioned attestation of the share price, published with a proof -reference. The reporter computes it off-chain under a published methodology. The -contract checks bounds, cooldown and the deviation cap, then stores the price +The NAV is a permissioned attestation of the share price. The reporter computes +it off-chain under a published methodology. A report carries the price and the +time until which the reporter vouches for it; it carries no proof reference yet. +The contract checks bounds, cooldown and the deviation cap, then stores the price and exposes it with the liquidity figures it owns: ```text @@ -375,17 +376,16 @@ uncovered = max(committed - liquid_reserve, 0) `wind_down_owed` is what the wind-down rounds have credited to holders and they have not claimed yet. It is zero outside a wind-down. -**Attestation guardrails:** the reporter is a multisig, never a single key. Each -attestation carries the share price and a proof reference; attestations are -ordered by their acceptance time on the ledger. The price must stay within -configured bounds, a minimum cooldown bounds frequency, and the deviation cap is -directional: the upward bound is mandatory and non-zero, the downward bound is -required too, and setting it to 100% lets a loss of any size land in one -attestation. A report's own expiry can shorten its life but never extend it past -the freshness window, and no report is accepted while the NAV is paused: -unpausing it is a deliberate act before reporting resumes. An epoch is never -priced against a valuation accepted before it closed, and the oracle's cooldown -therefore bounds how soon a closed epoch can be priced. +**Attestation guardrails:** the reporter is a multisig, never a single key. +Attestations are ordered by their acceptance time on the ledger. The price must +stay within configured bounds, a minimum cooldown bounds frequency, and the +deviation cap is directional: the upward bound is mandatory and non-zero, the +downward bound is required too, and setting it to 100% lets a loss of any size +land in one attestation. A report's own expiry can shorten its life but never +extend it past the freshness window, and no report is accepted while the NAV is +paused: unpausing it is a deliberate act before reporting resumes. An epoch is +never priced against a valuation accepted before it closed, and the oracle's +cooldown therefore bounds how soon a closed epoch can be priced. **Freshness and pause:** each attestation opens a validity window; when it lapses the feed is stale and new requests stop being priced. The guardian can @@ -406,9 +406,10 @@ pricing, share and cash claims, mint, burn and forced transfer are unaffected. It is a trading restriction, not an emergency stop for the vault, and only governance lifts it. -**Integrator surface:** the oracle exposes the attested share price directly, as -the latest report with its proof reference and acceptance time (`nav_per_share`). -The SEP-40 feed named in section 4 is an adapter over that surface scheduled for +**Integrator surface:** the oracle exposes the attested share price directly: +the latest report with its acceptance time and expiry (`latest`), the price +alone (`nav_per_share`) and the acceptance time alone (`attested_at`). The +SEP-40 feed named in section 4 is an adapter over that surface scheduled for Tranche 3 and is not implemented yet; integrators read the oracle's own interface today. @@ -605,7 +606,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 | +| 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. A compromised attester is replaced in this order: pause the NAV, revoke the attester (instant), grant a replacement (through the timelock after setup, so the NAV stays paused that long), clear the stored report if it is wrong, then lift the pause ([`kit-spec.md`](./kit-spec.md) §3) | | 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 vault's own custodian, role grants, `manager` revocations, admin handovers and lowering of the notice or the wind-down delay wait on that clock once its one-time setup closes, and it takes no deposit before then; revoking its treasury or guardian stays instant. Setup closes only once the token and the oracle run on the vault's clock with their own setup closed, the vault is the token's only minter, no shares exist yet (shares minted earlier would redeem against depositors' money), the vault passes the identity check, its address is not frozen on the token, the token is bound to its compliance contract, the asset has the token's decimals, the asset, the token, the oracle and the vault are four different addresses, the oracle answers the feed interface, and a custodian other than the vault is set; the kit's own compliance contract must also run on the vault's clock with its setup closed. 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. On the kit's compliance contract, adding or removing a module, binding or unbinding a token and handing over the admin wait on the same clock once its setup closes. | | Mis-wired deployment | A vault goes live without every binding wired | `health()` reports each wiring step; `finish_setup` refuses until all hold, and the vault takes no deposit before that | | 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 | From 39b3cddf4800873393cca0fb799de8d285949eeb Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 05:57:18 -0300 Subject: [PATCH 111/147] docs: call the timelock delay an announcement --- crates/strata-timelock/src/clock.rs | 3 ++- crates/strata-timelock/src/delay.rs | 4 ++-- docs/strata-product-and-architecture.md | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/strata-timelock/src/clock.rs b/crates/strata-timelock/src/clock.rs index e9471570..020ff893 100644 --- a/crates/strata-timelock/src/clock.rs +++ b/crates/strata-timelock/src/clock.rs @@ -10,7 +10,8 @@ use crate::setup; pub struct Clock { /// Seconds a change proposed now waits. pub delay: u64, - /// The longest an exit takes at the source. A change never waits less. + /// The source's notice: how long a closed epoch waits before it can be + /// priced. A change never waits less. pub exit_window: u64, /// Seconds the source has spent paused, a running pause included. pub paused_secs: u64, diff --git a/crates/strata-timelock/src/delay.rs b/crates/strata-timelock/src/delay.rs index ca560fa0..12772b11 100644 --- a/crates/strata-timelock/src/delay.rs +++ b/crates/strata-timelock/src/delay.rs @@ -1,6 +1,6 @@ pub const DAY_DURATION: u64 = 24 * 60 * 60; -/// Short enough to fix a defect in a week, long enough for anyone who -/// dislikes a change to leave before it lands. +/// Short enough to fix a defect in a week, long enough to warn holders before +/// a change lands. pub const MIN_DELAY: u64 = 7 * DAY_DURATION; /// No delay can wall a contract off from future changes. pub const MAX_DELAY: u64 = 90 * DAY_DURATION; diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index 6d8b50fb..af8ccb70 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -607,7 +607,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. A compromised attester is replaced in this order: pause the NAV, revoke the attester (instant), grant a replacement (through the timelock after setup, so the NAV stays paused that long), clear the stored report if it is wrong, then lift the pause ([`kit-spec.md`](./kit-spec.md) §3) | -| 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 vault's own custodian, role grants, `manager` revocations, admin handovers and lowering of the notice or the wind-down delay wait on that clock once its one-time setup closes, and it takes no deposit before then; revoking its treasury or guardian stays instant. Setup closes only once the token and the oracle run on the vault's clock with their own setup closed, the vault is the token's only minter, no shares exist yet (shares minted earlier would redeem against depositors' money), the vault passes the identity check, its address is not frozen on the token, the token is bound to its compliance contract, the asset has the token's decimals, the asset, the token, the oracle and the vault are four different addresses, the oracle answers the feed interface, and a custodian other than the vault is set; the kit's own compliance contract must also run on the vault's clock with its setup closed. 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. On the kit's compliance contract, adding or removing a module, binding or unbinding a token and handing over the admin wait on the same clock once its setup closes. | +| Governance keys | Malicious upgrade | Timelock on every code change and on the delay itself: at least seven days, never shorter than the standing notice, at most 90 days. The delay is an announcement period. A pause freezes the timelock clock and pushes the ETA back by the time paused, so the whole delay runs unpaused, and applying while paused is refused. The delay does not guarantee an exit before a change lands: an exit also needs a sealed epoch, a valid price accepted after the seal, and cash in the reserve to pay the claim. Nothing is proposed or applied while the vault winds down. The vault's own custodian, role grants, `manager` revocations, admin handovers and lowering of the notice or the wind-down delay wait on that clock once its one-time setup closes, and it takes no deposit before then; revoking its treasury or guardian stays instant. Setup closes only once the token and the oracle run on the vault's clock with their own setup closed, the vault is the token's only minter, no shares exist yet (shares minted earlier would redeem against depositors' money), the vault passes the identity check, its address is not frozen on the token, the token is bound to its compliance contract, the asset has the token's decimals, the asset, the token, the oracle and the vault are four different addresses, the oracle answers the feed interface, and a custodian other than the vault is set; the kit's own compliance contract must also run on the vault's clock with its setup closed. 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. On the kit's compliance contract, adding or removing a module, binding or unbinding a token and handing over the admin wait on the same clock once its setup closes. | | Mis-wired deployment | A vault goes live without every binding wired | `health()` reports each wiring step; `finish_setup` refuses until all hold, and the vault takes no deposit before that | | 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 | From bc7915b23fba392f1758017e97f35e8d4b6ba535 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 05:59:32 -0300 Subject: [PATCH 112/147] docs: state what refunds and capital figures do --- contracts/async-vault/src/roles.rs | 2 +- docs/COMPLIANCE_MATRIX.md | 2 +- docs/kit-spec.md | 15 +++++++++++---- docs/strata-product-and-architecture.md | 21 ++++++++++++++++----- 4 files changed, 29 insertions(+), 11 deletions(-) diff --git a/contracts/async-vault/src/roles.rs b/contracts/async-vault/src/roles.rs index 6f9c4302..3e095562 100644 --- a/contracts/async-vault/src/roles.rs +++ b/contracts/async-vault/src/roles.rs @@ -3,7 +3,7 @@ use soroban_sdk::{contracttype, symbol_short, Address, Symbol}; /// Seals the open epoch. Not the share token's `manager`, which the vault holds. pub const MANAGER_ROLE: Symbol = symbol_short!("manager"); -/// Moves capital to the custodian and funds the vault back. +/// Sends free reserve to the custodian. Anyone may fund the vault back. pub const TREASURY_ROLE: Symbol = symbol_short!("treasury"); pub const GUARDIAN_ROLE: Symbol = symbol_short!("guardian"); diff --git a/docs/COMPLIANCE_MATRIX.md b/docs/COMPLIANCE_MATRIX.md index 9e01af2b..3a318040 100644 --- a/docs/COMPLIANCE_MATRIX.md +++ b/docs/COMPLIANCE_MATRIX.md @@ -21,7 +21,7 @@ Every investor account in the vault ecosystem exists in one of three primary com 3. **Frozen (`Frozen`)**: - The account is subject to sanctions, legal freezing, or security hold (`is_frozen(account) == true` via `ShareToken::set_address_frozen`). - The share token refuses a `transfer` from or to a frozen address. Minting does not check freezes, and `forced_transfer` ignores them. - - What the vault owes a frozen investor is **held**: not paid, not returned, not moved. Priced cash stays in `committed` and a wind-down slice stays in `wind_down_owed`, where no other holder can take it; only an unfreeze releases it. A refund for a deposit priced at zero shares is the exception: it is not tracked as owed, so it waits in the free reserve, where a wind-down round can credit it to other holders (see the limits in the design doc's [§8.7](./strata-product-and-architecture.md#87-treasury-and-custodian)). A legal-order transfer (`legal_transfer`), which compliance and governance sign together, can move shares in the investor's own wallet, but not value the vault holds for them: deposit escrow, priced but unminted shares, shares escrowed in a redemption, priced cash, or a wind-down slice. A partial freeze limits exits to the free balance. + - What the vault owes a frozen investor is **held**: not paid, not returned, not moved. Priced cash stays in `committed` and a wind-down slice stays in `wind_down_owed`, where no other holder can take it; only an unfreeze releases it. A refund for a deposit priced at zero shares is the exception: it is not tracked as owed, so it waits in the free reserve, where treasury can deploy it or a wind-down round can credit it to other holders (see the limits in the design doc's [§8.7](./strata-product-and-architecture.md#87-treasury-and-custodian)). A legal-order transfer (`legal_transfer`), which compliance and governance sign together, can move shares in the investor's own wallet, but not value the vault holds for them: deposit escrow, priced but unminted shares, shares escrowed in a redemption, priced cash, or a wind-down slice. A partial freeze limits exits to the free balance. This follows how asset-freeze and blocking rules generally treat frozen property: held, not released, not returned to the owner and not confiscated, with the entitlement kept and recorded. It is not legal advice; counsel must confirm it for each jurisdiction. diff --git a/docs/kit-spec.md b/docs/kit-spec.md index 76f08fd3..956cb1bd 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -77,6 +77,7 @@ The contracts enforce a set of mathematical and state-machine invariants across $$(a \cdot \text{SCALE}) - (s \cdot \text{NAV}) < \text{NAV}$$ - **Settlement**: pricing records what each epoch promised (`shares_promised`, `assets_promised`, see `get_epoch_settlement`). Claims floor one by one, so together they pay at most that. When the last request of a priced epoch settles, the remainder leaves `pending_mint_shares` and `committed` and the vault publishes `EpochReconciled { epoch, share_dust, cash_dust }`. The cash stays in the reserve, owed to nobody, and reaches every holder through the NAV. A running total that would go below zero fails with `AccountingMismatch` (6070) instead of clamping. - **A request that can never settle** keeps its epoch open, and the two cases leave different amounts behind. A zero-asset redemption whose holder may not take the shares back (`HolderNotEligible`, `a_request_that_cannot_settle_keeps_its_epoch_open`) leaves only the epoch's ordinary rounding remainder in `committed` — fewer units than the epoch had redemption requests — because that request was never owed any cash. A priced deposit whose mint a compliance module refuses (`a_refused_mint_keeps_a_priced_deposit_epoch_open`) is not rounding dust: the mint reverts, so that deposit's whole share entitlement stays in `pending_mint_shares`, on top of the epoch's own remainder, until the mint is allowed. Either way it is still owed to nobody. +- **A deposit worth less than one share base unit** mints nothing and is refunded at claim, from the free reserve (`a_deposit_below_one_share_is_refunded_at_claim`). Pricing moves the epoch's whole deposit into the reserve and records no refund, so `deploy_to_custodian` or a wind-down round can spend that cash first; the claim then fails with `ClaimNotCovered` until the free reserve covers it again (`a_dust_refund_never_takes_committed_money`, `a_zero_share_refund_is_refused_once_a_round_has_taken_the_reserve`). This is accepted: pricing sees only the epoch's totals, so reserving these refunds would mean visiting every request, and each is worth less than one share base unit. **Settlement and upgrades.** The settlement record counts a request only from the moment it exists. A vault upgraded in place to this version while any request is open would under-count its open epochs: claims in epochs priced before the upgrade would revert, and a mixed epoch could reconcile early. This version must therefore be deployed fresh, or reached by upgrade only once no request is open anywhere in the vault. @@ -158,7 +159,7 @@ The `async_vault` constructor strictly verifies pairwise separation across autho |---|---|---|---| | **Standing Notice** | `set_notice(secs: u64)`; after setup, lowering goes through `propose_change(Notice(secs))` → `apply_change(id)` | Governance | Notice wait before pricing. Default: `0`. Must satisfy $\text{secs} \le \text{upgrade\_delay}$ and $\text{secs} \le 30 \text{ days}$ (`MAX_NOTICE_SECS`), checked again when a `Notice` change applies. Raising is instant. | | **Custodian Address** | `set_custodian(custodian: Address)` during setup; afterwards `propose_change(Custodian(address))` → `apply_change(id)` | Governance | Recipient address for off-chain capital deployment. Required before `finish_setup` and `deploy_to_custodian`. Never the vault itself (`InvalidCustodian`), checked when it is set, proposed and applied. The deploy sets it before setup closes. | -| **Deposit Cap** | `set_deposit_cap(cap: Option)` | Governance | Maximum capacity for deposited capital. `None` = unbounded. Refuses negative cap. Instant both ways, also after setup. | +| **Deposit Cap** | `set_deposit_cap(cap: Option)` | Governance | Maximum capacity for deposited capital (see **Supply and Capital Figures**). `None` = unbounded. Refuses negative cap. Instant both ways, also after setup. | | **Wind-Down Delay** | `set_wind_down_delay(secs: u64)`; after setup, lowering goes through `propose_change(WindDownDelay(secs))` → `apply_change(id)` | Governance | Minimum notice before wind-down activation. Default and minimum 7 days (`MIN_WIND_DOWN_DELAY`), maximum 90 days (`MAX_WIND_DOWN_DELAY`). Raising is instant. | | **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. Applying a `Config` change sets every field, freshness included; to keep a cut made after the proposal, cancel the queued change and propose again. | @@ -186,6 +187,12 @@ The `async_vault` constructor strictly verifies pairwise separation across autho `freshness_covers_notice` false means a report can expire before the notice ends. A sealed epoch's cancellation window can then reopen on its own, so set `freshness_duration` above the notice. `feed_valid` false right after a deploy is normal until the first attestation. +### Supply and Capital Figures + +- `total_economic_supply()` counts share base units held outside the vault plus those priced for deposits and not yet minted: the token's `total_supply()`, less the vault's own share balance, plus `pending_mint_shares()`. It leaves out every share in the vault's escrow, including those of a redemption that is not priced yet, which the next price still applies to and a cancellation can return (`economic_supply_excludes_shares_locked_in_redemption_escrow`). The first wind-down round's supply snapshot counts those shares, so the two figures differ while redemptions wait to be priced. +- Deposited capital, which the deposit cap limits, is the asset the vault holds plus `net_deployed()`, less `committed()`. It counts asset units at cost: a gain or a loss at the custodian never moves it, even once funded back. +- `net_deployed()` is what `deploy_to_custodian` sent less what `fund` brought back; it goes negative once more came back than went out. The custodian repays through `fund`. A plain transfer to the vault raises the vault's balance but leaves `net_deployed` as it was, so from then on the same money counts twice in deposited capital and the cap refuses deposits sooner. + --- ## 3. Role & Authority Matrix @@ -198,12 +205,12 @@ Strata distributes control across five native Stellar multisig authorities and p |---|---|---|---| | **Governance** | High-quorum deliberative multisig | Set parameters (`notice`, `custodian`, `deposit_cap`, `wind_down_delay`), propose/apply upgrades, propose/cancel wind-down, unpause the vault, pause/unpause the share token, transfer admin, finish each contract's one-time setup, co-sign compliance's `legal_transfer` and `recover_balance` as the share token's admin. After setup, these wait for the timelock: role grants, revoking the manager, admin hand-over, the custodian, lowering the notice or the wind-down delay, upgrades, the token's compliance and verifier swaps, the compliance contract's module and token-binding changes, and oracle config changes other than a shorter freshness window. Revoking the treasury or the guardian stays instant, because taking a power away moves no value. | **Cannot pause the vault directly**; cannot move funds; cannot initiate custodian transfers; cannot attest prices; cannot bypass timelocks. | | **Guardian** | Low-threshold fast-reaction multisig | `pause` (vault); `pause_nav` (oracle emergency price freeze). | **Cannot unpause**; cannot move funds; cannot block redemption requests; cannot block payable claims or cancellations. | -| **Treasury** | Operational capital multisig | `deploy_to_custodian` (moves free reserve); `fund` (returns assets from custodian). | **Cannot deploy while anything is uncovered**; cannot touch escrowed subscription assets; cannot alter parameters or pause. | +| **Treasury** | Operational capital multisig | `deploy_to_custodian` (moves free reserve). Capital comes back through `fund`, which anyone may call. | **Cannot deploy while anything is uncovered**; cannot touch escrowed subscription assets; cannot alter parameters or pause. | | **Attester** | High-security attestation multisig | `attest` (submits a NAV report: the price and its expiry). | **Cannot price outside deviation band**; cannot violate cooldown; cannot operate on the vault directly. | | **Compliance** | Legal / KYC operator multisig | Manage allowlist (`allow`), topic claims; token interventions (`freeze`; `legal_transfer` and `recovery` co-signed by governance). | **Cannot touch vault reserve**; cannot move a holder's shares without governance's signature in the same call; cannot move shares out of or into the vault's address, or freeze it once the token knows its vault (an earlier freeze makes the vault's `finish_setup` refuse); cannot pause vault operations; cannot bypass exit-only cash redemptions for an unfrozen holder — freezing holds a covered claim until it lifts, rather than paying or redirecting it. | | **Manager (vault role)** | Operational keeper account; a role, not one of the five authorities | `close_epoch`: seals the open epoch, nothing else. | **Cannot choose prices**; cannot mint, burn or move shares; cannot stop a sealed epoch from being priced, though not sealing stalls every request; cannot alter timelocks. After setup, granting or revoking it waits for the timelock. | | **Manager (share-token role)** | The vault contract, alone: the vault's `finish_setup` refuses while another account holds it | `mint`, `burn`, `forced_transfer` on the share token: the vault mints and burns at claim and moves escrowed shares. | Cannot freeze, recover or make a legal transfer. After setup, granting or revoking it waits for the vault's timelock. See [`AUTHORIZATION_MATRIX.md`](./AUTHORIZATION_MATRIX.md) §2. | -| **Permissionless** | Any caller / keeper / investor | `fulfill_epoch`, `request_deposit`, `cancel_deposit`, `claim_deposit`, `request_redeem`, `cancel_redeem`, `claim_redeem`, `activate_wind_down`, `finalize_wind_down_round`, `claim_wind_down`. | Operations are strictly constrained by contract state, timing, and solvency guards. | +| **Permissionless** | Any caller / keeper / investor | `fulfill_epoch`, `request_deposit`, `cancel_deposit`, `claim_deposit`, `request_redeem`, `cancel_redeem`, `claim_redeem`, `fund`, `activate_wind_down`, `finalize_wind_down_round`, `claim_wind_down`. | Operations are strictly constrained by contract state, timing, and solvency guards. | ### Replacing a Compromised Attester @@ -244,7 +251,7 @@ Pause first, so no report lands while the roles change, and lift the pause last: | **`AsyncVault`** | `finalize_wind_down_round` | Permissionless | None | No | | **`AsyncVault`** | `claim_wind_down` | Shareholder (Caller) | `holder.require_auth()` | No | | **`AsyncVault`** | `deploy_to_custodian` | Treasury | `#[only_role(caller, "treasury")]` | No (open during pause) | -| **`AsyncVault`** | `fund` | Treasury | None (caller transfers funds) | No | +| **`AsyncVault`** | `fund` | Anyone: the custodian, treasury or a backer | `caller.require_auth()`; the caller pays | No | | **`AsyncVault`** | `propose_change` | Governance | `#[only_admin]`; after setup | No | | **`AsyncVault`** | `cancel_change` | Governance | `#[only_admin]` | No | | **`AsyncVault`** | `apply_change` | Governance | `#[only_admin]` | Refused if paused or winding down | diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index af8ccb70..6e39d863 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -316,7 +316,7 @@ batch boundary, though not the price it receives. - Request: moves the deposit asset into escrow; at most one active request per holder. The vault first asks the share token whether the address is frozen and asks the token's identity verifier, and refuses a frozen address or one that could not receive the shares, before any cash moves; the token checks again when it mints at claim time. - Pricing: the escrow leaves the cancellable bucket, the share quantity is set at the epoch's price, and the economic supply liability is recorded. To avoid unbounded loops that exceed Soroban transaction resource limits, physical share minting occurs lazily when each investor executes `claim_deposit`. - Cancellation: atomic, and returns the escrowed asset in full. Open while the epoch is open. Once sealed, a readable price closes it, even while the notice still runs: an investor who can see the price cannot decline it. If the vault can no longer price the epoch — the report expires, the NAV is paused, or the vault is paused — it opens again, so escrow is never stuck behind a stalled feed. The epoch is then priced at the latest valid report at fulfil time. Cancellation also remains open during an active wind-down, allowing pending depositors to recover their escrowed assets when pricing is closed. A frozen depositor's cancellation is held until the freeze is lifted, in a wind-down too. -- Share claim: verifies the receiver and delivers the shares via `mint`. A receiver off the allowlist gets no shares: they join the holder's redemption in the open epoch and are paid in cash at that epoch's price; in a wind-down they join the holder's wind-down entitlement. A frozen receiver is held until the freeze is lifted. No nominal refund exists after pricing. +- Share claim: verifies the receiver and delivers the shares via `mint`. A receiver off the allowlist gets no shares: they join the holder's redemption in the open epoch and are paid in cash at that epoch's price; in a wind-down they join the holder's wind-down entitlement. A frozen receiver is held until the freeze is lifted. The only refund after pricing is for a deposit worth less than one share base unit, which mints nothing: its asset comes back at claim, from the free reserve. Pricing does not set that refund aside, because it sees only the epoch's totals and reserving it would mean visiting every request for less than one share base unit each. Treasury can deploy that cash, or a wind-down round credit it, before the claim, which then waits until the free reserve covers it again. #### Redemption @@ -376,6 +376,13 @@ uncovered = max(committed - liquid_reserve, 0) `wind_down_owed` is what the wind-down rounds have credited to holders and they have not claimed yet. It is zero outside a wind-down. +The vault also reports `total_economic_supply`: shares held outside the vault +plus shares priced for deposits and not yet minted. It leaves out the shares +escrowed by a redemption that is not priced yet, although the next price still +applies to them. The deposit cap limits deposited capital: what the vault holds +plus `net_deployed` (sent to the custodian less funded back), less `committed`, +in asset units at cost, so a gain or a loss at the custodian never moves it. + **Attestation guardrails:** the reporter is a multisig, never a single key. Attestations are ordered by their acceptance time on the ledger. The price must stay within configured bounds, a minimum cooldown bounds frequency, and the @@ -437,8 +444,11 @@ interface today. - The custodian is a genesis-configured slot; only governance can rotate it. Transfers to the custodian move free reserve only, only to that address, and only while nothing is uncovered. -- Transfers from the custodian are always open and credit only assets actually - received. +- Transfers from the custodian are always open: `fund`, which anyone may call, + credits only the assets it receives and lowers `net_deployed` by them. The + custodian repays through `fund`. A plain transfer to the vault also lands in + the reserve but leaves `net_deployed` as it was, so from then on the same + money counts twice toward the deposit cap. - The exposed figures (share price, liquid reserve, committed, uncovered) make reserve coverage legible to investors and integrators. - Closing a vault is a dedicated, announced state. Governance proposes a @@ -456,10 +466,11 @@ interface today. joins the next round's pot. - A wind-down distributes what is on chain. The vault cannot compel the custodian to return capital; recovering the rest is off-chain work, and nothing here is a - guarantee that it will be recovered. Two further limits are on the record: a + guarantee that it will be recovered. Three further limits are on the record: a holder who never surrenders their shares is never paid, because no entrypoint may iterate over holders; a refund owed to a deposit priced at zero shares - is not tracked as a cash liability, so a round can credit the assets it needed; + is not tracked as a cash liability, so a round can credit the assets it needed + (§8.4); and symmetrically, a redemption priced at zero assets pays nothing, and once the wind-down is active its claim burns the escrowed shares instead of returning them. From 72c15a59cef1b3522aeeb0a505371eca152b448d Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 06:05:26 -0300 Subject: [PATCH 113/147] docs: tighten the kit spec wording --- docs/kit-spec.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/kit-spec.md b/docs/kit-spec.md index 956cb1bd..c4caa665 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -189,7 +189,7 @@ The `async_vault` constructor strictly verifies pairwise separation across autho ### Supply and Capital Figures -- `total_economic_supply()` counts share base units held outside the vault plus those priced for deposits and not yet minted: the token's `total_supply()`, less the vault's own share balance, plus `pending_mint_shares()`. It leaves out every share in the vault's escrow, including those of a redemption that is not priced yet, which the next price still applies to and a cancellation can return (`economic_supply_excludes_shares_locked_in_redemption_escrow`). The first wind-down round's supply snapshot counts those shares, so the two figures differ while redemptions wait to be priced. +- `total_economic_supply()` counts share base units held outside the vault plus those priced for deposits and not yet minted: the token's `total_supply()`, less the vault's own share balance, plus `pending_mint_shares()`. It leaves out every share in the vault's escrow. That includes the shares of a redemption that is not priced yet: the next price still applies to them, and a cancellation can return them (`economic_supply_excludes_shares_locked_in_redemption_escrow`). The first wind-down round's supply snapshot counts those shares, so the two figures differ while redemptions wait to be priced. - Deposited capital, which the deposit cap limits, is the asset the vault holds plus `net_deployed()`, less `committed()`. It counts asset units at cost: a gain or a loss at the custodian never moves it, even once funded back. - `net_deployed()` is what `deploy_to_custodian` sent less what `fund` brought back; it goes negative once more came back than went out. The custodian repays through `fund`. A plain transfer to the vault raises the vault's balance but leaves `net_deployed` as it was, so from then on the same money counts twice in deposited capital and the cap refuses deposits sooner. @@ -221,7 +221,7 @@ Pause first, so no report lands while the roles change, and lift the pause last: 3. Grant a replacement. During the oracle's setup, `grant_role` is instant. After it, propose a `GrantRole` change and apply it once the vault's delay has run, plus any time the vault spends paused. The NAV stays paused that whole time, at least seven days. 4. If the stored report is wrong, call `clear_latest` (admin; only while the NAV is paused). The next report then has no earlier price to cap its move: only `min_answer` and `max_answer` bound it. 5. Lift the NAV pause: `set_nav_paused(false)` (admin). -6. The new attester posts a report. An epoch is priced once a report accepted after its seal is valid. +6. The new attester posts a report. Pricing resumes: an epoch can be priced once a report accepted after its seal is valid. --- From 679f592e437590cf7cd45e9ce7b5b7b62abd9639 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 07:01:09 -0300 Subject: [PATCH 114/147] docs: tighten the design notes --- contracts/async-vault/src/lib.rs | 95 +++++++++----------------- contracts/compliance/src/lib.rs | 26 +++---- contracts/identity-verifier/src/lib.rs | 30 ++++---- contracts/nav-oracle/src/lib.rs | 57 ++++++---------- contracts/share-token/src/lib.rs | 55 ++++++--------- crates/strata-bindings/src/lib.rs | 24 +++---- crates/strata-storage/src/lib.rs | 28 ++++---- crates/strata-timelock/src/lib.rs | 42 +++++------- 8 files changed, 138 insertions(+), 219 deletions(-) diff --git a/contracts/async-vault/src/lib.rs b/contracts/async-vault/src/lib.rs index 534b9acc..6b296b35 100644 --- a/contracts/async-vault/src/lib.rs +++ b/contracts/async-vault/src/lib.rs @@ -1,70 +1,39 @@ //! # Design //! -//! Deposits and redemptions are requests. Each joins the open epoch; the -//! epoch is sealed and priced at the oracle's report, and each holder claims -//! the result. +//! Deposits and redemptions are requests. Each joins the open epoch; the epoch +//! is sealed and priced at the oracle's report, and each holder claims the +//! result. //! -//! - **The manager seals, anyone prices.** Sealing an epoch needs the -//! `manager` role. Pricing it is open to anyone once its notice has run and -//! a valid report accepted after the seal exists, so the price is the -//! oracle's, not the caller's. The cost: a manager that stops sealing, or an -//! attester that stops reporting, stalls every request until it is -//! replaced, and after setup a replacement waits for the timelock. +//! - **The manager seals, anyone prices.** The price is the oracle's, not the +//! caller's. The cost: a silent manager or attester stalls every request, and +//! after setup a replacement waits for the timelock. //! - **Pricing records totals; claims mint and burn.** Pricing costs the same -//! whatever the number of requests: it fixes the price and the epoch's -//! totals, and each claim then mints or burns its own shares. The cost: the -//! token's supply is not the economic supply until every claim is made, and -//! since each claim rounds down, the epoch's remainder stays in -//! `pending_mint_shares` and `committed` until its last request settles. A -//! request that can never settle keeps that remainder there. -//! - **A readable price closes cancellation.** Cancellation closes once a -//! valid report accepted after the seal exists, even while the notice runs, -//! and reopens whenever the vault could not price the epoch now: the feed -//! goes stale, the NAV is paused or the vault is paused. Nobody declines a -//! price they have seen, and no escrow waits on a stalled feed. The cost: a -//! report that lapses lets requests leave a sealed epoch late, so -//! `freshness_duration` should outlast the notice. -//! - **Priced before paid.** A redemption is priced whether or not the reserve -//! holds the cash, and its claim fails with `ClaimNotCovered` until the -//! liquid reserve covers it. Covered claims pay in any order, and nothing -//! goes to the custodian while anything is uncovered. The cost: a priced -//! exit can wait on treasury, and a later, smaller claim can be paid before -//! an earlier, larger one. -//! - **A refund below one share is not reserved.** A deposit worth less than -//! one share base unit mints nothing and is refunded at claim, from the free -//! reserve. Pricing sees only the epoch's totals, and setting these refunds -//! aside would mean visiting every request, for less than one share base -//! unit each. The cost: treasury can deploy that cash, or a wind-down round -//! credit it, before the claim, which then fails with `ClaimNotCovered` -//! until the free reserve covers it again. -//! - **Checked totals.** Every running total of assets or shares changes -//! through checked arithmetic and fails by name: `AmountTooLarge` when it -//! would overflow, `AccountingMismatch` when it would go below zero. None -//! wraps or clamps. The cost: if the books ever disagree, the call that finds -//! it fails instead of carrying on with a clamped figure. -//! - **A freeze holds every exit.** A frozen address cannot request a deposit, -//! and its claims, cancellations, redemption requests and wind-down claims -//! fail with `Frozen` until the freeze lifts. Compliance can delay a payout -//! this way, never redirect it. The cost: even a covered cash claim waits for -//! the unfreeze. -//! - **A delisted holder leaves in cash.** A holder delisted after pricing gets -//! no shares. Outside a wind-down the priced shares are minted into the -//! vault's escrow and join the holder's redemption in the open epoch; in a -//! wind-down they are credited to the holder's entitlement. No share reaches -//! an unlisted address, and the holder keeps a way out. The cost: the holder -//! waits another epoch and takes its price, and the conversion waits while -//! the vault's own address is delisted. -//! - **Setup, then the timelock.** Admin changes are instant during setup, -//! which takes no deposit and ends only once `finish_setup` finds every -//! wiring check true; it never reopens. After it, the changes that can move -//! value or shorten a wait go through the timelock. The cost: a mistake -//! found after setup takes a full delay, at least seven days, to fix. -//! - **Wind-down claims run the compliance hooks.** A wind-down claim that -//! surrenders shares burns them through the token, which runs the compliance -//! contract's `destroyed` modules. The cost: a module that starts failing -//! during a wind-down blocks those claims, and it cannot be removed then, -//! because removing a module waits for the timelock and no timelocked change -//! lands during a wind-down. +//! for any number of requests. The cost: an epoch's rounding remainder stays +//! in `committed` and `pending_mint_shares` until its last request settles. +//! - **A readable price closes cancellation,** during the notice too, until the +//! feed goes stale or the NAV or the vault is paused. The cost: a report that +//! lapses lets requests leave a sealed epoch late. +//! - **Priced before paid.** A redemption is priced whether or not the cash is +//! there; its claim waits for the liquid reserve, and nothing goes to the +//! custodian meanwhile. The cost: an exit can wait on treasury. +//! - **A refund below one share is not reserved.** Pricing sees only totals, so +//! the refund comes from the free reserve at claim. The cost: treasury or a +//! wind-down round can spend it first, and the claim waits. +//! - **Checked totals.** An asset or share total fails with `AmountTooLarge` on +//! overflow, and with `AccountingMismatch` below zero unless signed, as +//! `net_deployed` is. The cost: books that disagree fail the call. +//! - **A freeze holds every exit.** A frozen holder's requests, claims and +//! cancellations fail with `Frozen` until it lifts: compliance can delay a +//! payout, never redirect it. The cost: even a covered claim waits. +//! - **A delisted holder leaves in cash.** Shares priced for them join their +//! redemption in the open epoch, or their wind-down entitlement. The cost: +//! another epoch at its price, and a wait while the vault itself is delisted. +//! - **Setup, then the timelock.** Until `finish_setup` checks the wiring, +//! admin changes are instant and deposits refused; after it, changes that +//! move value or cut a wait are timelocked. The cost: fixes take a week. +//! - **Wind-down claims run the compliance hooks:** surrendered shares burn +//! through the `destroyed` modules. The cost: one failing in a wind-down +//! blocks those claims, and no change can remove it then. #![no_std] diff --git a/contracts/compliance/src/lib.rs b/contracts/compliance/src/lib.rs index c5c04a4d..a56add4e 100644 --- a/contracts/compliance/src/lib.rs +++ b/contracts/compliance/src/lib.rs @@ -3,22 +3,18 @@ //! Testnet-only stub — in production this is an external modular-compliance //! module (ERC-3643), not part of this product. Do not deploy to mainnet. //! -//! - **No rule of its own.** With no module registered, every check the token -//! runs passes; the allowlist lives in the identity verifier, which the token -//! asks separately. The cost: any rule beyond the allowlist needs a module or -//! another compliance contract. -//! - **Changes wait for the vault's timelock.** After setup, adding or removing -//! a module, binding or unbinding a token and handing over the admin wait for -//! the vault's delay; a paused vault holds them and a wind-down refuses them. -//! The cost: a faulty module takes a full delay to remove, and during a -//! wind-down it cannot be removed at all, so a module that fails on a burn -//! blocks every claim that burns shares. -//! - **An unbound token fails.** The hooks the token calls after every mint, -//! transfer and burn refuse a token that is not bound. The cost: unbinding -//! the share token stops all three, the vault's claims included. +//! - **No rule of its own.** With no module registered every check passes; the +//! allowlist lives in the identity verifier. The cost: any other rule needs a +//! module or another compliance contract. +//! - **Changes wait for the vault's timelock.** After setup, module, binding +//! and admin changes wait, and a wind-down refuses them. The cost: a faulty +//! module takes a full delay to remove, and stays in a wind-down. +//! - **An unbound token fails.** The hooks the token calls after each mint, +//! transfer and burn refuse it when it is not bound. The cost: unbinding it +//! stops all three, the vault's claims included. //! - **The operator is not read.** OpenZeppelin's module and binding calls take -//! an `operator`; the stub ignores it and asks the admin to sign. The cost: -//! the admin cannot delegate those calls to an operator. +//! an `operator`; the stub asks the admin to sign instead. The cost: the +//! admin cannot delegate those calls. //! - **No upgrade.** The stub has no upgrade path. The cost: a fix means a new //! deployment and a token `Compliance` change, which waits for the vault's //! timelock after setup. diff --git a/contracts/identity-verifier/src/lib.rs b/contracts/identity-verifier/src/lib.rs index 5e069f92..f535b542 100644 --- a/contracts/identity-verifier/src/lib.rs +++ b/contracts/identity-verifier/src/lib.rs @@ -3,25 +3,21 @@ //! Testnet-only stub — in production this is an external ERC-3643 identity //! registry (a trusted issuer's), not part of this product. Do not deploy to mainnet. //! -//! - **A yes or no per address.** An account is allowed or not, and one never -//! set is refused. There are no claims, topics or issuers: -//! `set_claim_topics_and_issuers` does nothing. The cost: no expiry and no -//! per-jurisdiction rule; a deployment that needs them brings its own -//! registry. -//! - **Instant edits.** The admin lists and delists at once, during setup and -//! after it, with no timelock. The cost: a stolen admin key can list or -//! delist any account at once; a delisted holder still leaves through the -//! vault's cash path. -//! - **A fixed admin, no upgrade.** There is no admin hand-over and no upgrade -//! path. The cost: rotating the admin key or fixing the code means a new -//! verifier and a token `IdentityVerifier` change, which waits for the -//! vault's timelock after setup. -//! - **No recovery account.** `recovery_target` always answers `None`, so the -//! token's `recover_balance` always fails. The cost: a lost wallet's shares -//! move with `legal_transfer` instead, signed by compliance and the admin. +//! - **A yes or no per address.** No claims, topics or issuers, and an address +//! never set is refused. The cost: no expiry and no per-jurisdiction rule; a +//! deployment that needs them brings its own registry. +//! - **Instant edits.** The admin lists and delists at once, with no timelock. +//! The cost: a stolen admin key can list or delist any account at once; a +//! delisted holder still leaves in cash. +//! - **A fixed admin, no upgrade.** The admin account's signers can change in +//! place, but the account cannot. The cost: replacing it, or the code, needs +//! a new verifier and a token change that waits for the timelock. +//! - **No recovery account.** `recovery_target` is always `None`, so +//! `recover_balance` always fails. The cost: a lost wallet's shares move by +//! `legal_transfer`, signed by `compliance` and the token's admin. //! - **OpenZeppelin's error code.** A refusal is //! `RWAError::IdentityVerificationFailed` (304); the stub has no error enum. -//! The cost: that code says what failed, not which contract refused. +//! The cost: the code says what failed, not which contract refused. #![no_std] diff --git a/contracts/nav-oracle/src/lib.rs b/contracts/nav-oracle/src/lib.rs index acd0ccfc..bb130e1d 100644 --- a/contracts/nav-oracle/src/lib.rs +++ b/contracts/nav-oracle/src/lib.rs @@ -4,43 +4,30 @@ //! and the oracle keeps the latest report that passes the limits the admin //! sets. //! -//! - **The ledger dates a report.** The oracle stores the time it accepts a -//! report and ignores the reporter's own timestamp. The report expires at the -//! earlier of the reporter's `expires_at` and that time plus -//! `freshness_duration`, so a reporter can shorten a report's life but never -//! extend it. The cost: the vault's rule that a price must come after an -//! epoch's seal compares acceptance times, not valuation dates. +//! - **The ledger dates a report.** It expires at the earlier of `expires_at` +//! and its acceptance plus `freshness_duration`. The cost: the vault compares +//! acceptance times, not valuation dates. //! - **No report while the NAV is paused.** The guardian or the admin pauses -//! the NAV, and only the admin lifts the pause, so reporting resumes by a -//! deliberate act. The cost: while the pause lasts the vault prices nothing, +//! it; only the admin lifts it. The cost: the vault prices nothing meanwhile, //! and sealed epochs reopen to cancellation. -//! - **Tightening waits too.** After setup every config change waits for the -//! vault's timelock; only a shorter `freshness_duration` is instant. A -//! tighter fall cap can hold the price above a real loss, and a narrower band -//! can stop pricing, so tightening moves value too. The cost: an urgent -//! change to the limits takes the full delay; the NAV pause is the instant -//! stop. -//! - **The band applies on read.** A stored report outside the current band -//! reads as stale. The cost: narrowing the band can stop pricing the moment -//! the change lands, until a report inside the new band arrives. -//! - **Both moves are capped.** A report may move from the last one by at most -//! `max_up_bps` up and `max_down_bps` down, and both caps are required. A -//! fall cap of 10,000 bps lets a loss of any size land at once, leaving -//! `min_answer` as the only floor. The cost of a lower fall cap: a real loss -//! takes several reports, at least a cooldown apart, and redemptions priced -//! meanwhile are paid above value. -//! - **Revoking is instant, granting waits.** Taking the `attester` or -//! `guardian` role away moves no value and stays instant; after setup a grant -//! waits for the timelock. The cost: replacing a compromised attester keeps -//! the NAV paused for the whole delay. -//! - **Clearing needs a paused NAV.** `clear_latest` removes the stored report -//! only while the NAV is paused, so the admin clears and the attester's next -//! report is a separate act. The cost: that report has no earlier price to -//! cap its move; only the band bounds it. -//! - **One report per cooldown.** The cooldown is at least one second and at -//! most `freshness_duration`: never two reports in one ledger, and the next -//! report is always allowed before the freshness window ends. The cost: an -//! epoch sealed just after a report waits up to a cooldown for the next one. +//! - **Tightening waits too.** After setup only a shorter `freshness_duration` +//! is instant: a tighter cap or band moves value too. The cost: an urgent +//! limit change waits the full delay. +//! - **The band applies on read.** A stored report outside the band reads as +//! stale. The cost: narrowing the band away from it stops pricing, and new +//! reports far from it fail the cap until `clear_latest`. +//! - **Both moves are capped.** Both caps are required; a 10,000 bps fall cap +//! lets any loss land at once. The cost of a lower one: a loss takes several +//! reports, and exits priced meanwhile are paid above value. +//! - **Revoking is instant, granting waits.** After setup a new attester or +//! guardian waits for the timelock. The cost: replacing a compromised +//! attester keeps the NAV paused for the whole delay. +//! - **Clearing needs a paused NAV.** `clear_latest` runs only while the NAV is +//! paused, so the next report is a separate act. The cost: that report has no +//! earlier price to cap it; only the band bounds it. +//! - **One report per cooldown.** The cooldown runs from one second to +//! `freshness_duration`, so no two reports share a ledger. The cost: an epoch +//! sealed just after a report waits up to a cooldown for the next. #![no_std] diff --git a/contracts/share-token/src/lib.rs b/contracts/share-token/src/lib.rs index 24e78722..567750d3 100644 --- a/contracts/share-token/src/lib.rs +++ b/contracts/share-token/src/lib.rs @@ -1,40 +1,27 @@ //! # Design //! -//! The vault's share token: OpenZeppelin's RWA token, which asks the identity -//! verifier about every receiver and the compliance contract about every move. -//! The kit adds its roles, its guards and the vault's timelock. +//! The vault's share token: OpenZeppelin's RWA token, which checks identity and +//! the compliance rules on every `transfer` and mint; forced transfers skip +//! both. The kit adds its roles, its guards and the vault's timelock. //! -//! - **No clock of its own.** After setup, upgrades, grants, revoking -//! `manager`, admin hand-overs and compliance or verifier swaps wait for the -//! vault's timelock. Revoking `compliance` and the transfer pause stay -//! instant. The cost: a paused vault holds every token change, and none -//! lands during a wind-down. -//! - **The vault is the only manager.** `mint`, `burn` and `forced_transfer` -//! need `manager`, which the vault must hold alone for its setup to finish. -//! `forced_transfer` skips the pause, freezes, the identity check and the -//! compliance rules' check, so the vault checks the receiver itself before it -//! returns shares. The cost: a `manager` can move any holder's shares without -//! their signature, so after setup granting or revoking it waits for the -//! timelock. -//! - **Two signers move a holder's shares.** `legal_transfer` and -//! `recover_balance` need a `compliance` holder and the admin to sign as two -//! different accounts, and the shares land only on an allowlisted account. -//! The cost: a legal order needs both signers. A legal transfer of zero -//! shares is accepted: it moves nothing but publishes its order reference. -//! - **The vault's address is off limits.** Once the token is bound to the -//! vault, its address cannot be frozen, in full or in part, and no legal -//! transfer or recovery moves shares into or out of it: its balance is other -//! holders' escrow. The cost: an order cannot reach shares escrowed in the -//! vault; compliance freezes the holder instead, which holds that holder's -//! exits. -//! - **The pause halts trading only.** The transfer pause is the admin's, not -//! the guardian's. It stops `transfer` and `transfer_from`; mints, burns, -//! escrow moves and legal transfers go on. The cost: it is not an emergency -//! stop for the vault. -//! - **A compliance swap is not checked.** Swapping the compliance contract -//! does not check that the new contract lists this token. The cost: if it -//! does not, every mint, transfer and burn fails until it binds the token, -//! and the vault's `health()` reports `compliance_bound` false. +//! - **No clock of its own.** After setup, upgrades, grants, `manager` revokes, +//! hand-overs and swaps wait for the vault's timelock. The cost: a vault +//! pause holds them; a wind-down refuses them. +//! - **Only the vault is manager.** It mints, burns and moves anyone's shares; +//! a forced move skips the token's checks, so the vault checks receivers. The +//! cost: granting it waits; revoking too, as claims need it. +//! - **Two signers move a holder's shares.** A legal transfer or recovery needs +//! `compliance` and the admin, as two accounts. The cost: an order needs +//! both. A zero-share order passes and is published. +//! - **The vault's address is off limits:** once bound, it is never frozen and +//! no order moves shares in or out, since it holds others' escrow. The cost: +//! orders cannot reach escrow; a frozen holder's exits wait. +//! - **The pause halts trading only.** The admin's transfer pause stops +//! `transfer` and `transfer_from`; mints, burns, escrow moves and legal +//! transfers go on. The cost: it is no emergency stop for the vault. +//! - **A compliance swap is not checked.** The new contract need not list this +//! token. The cost: if it does not, every mint, transfer and burn fails until +//! it binds the token, and the vault's `health()` shows it. #![no_std] mod contract; diff --git a/crates/strata-bindings/src/lib.rs b/crates/strata-bindings/src/lib.rs index 706fac33..a7d70730 100644 --- a/crates/strata-bindings/src/lib.rs +++ b/crates/strata-bindings/src/lib.rs @@ -1,22 +1,16 @@ //! # Design //! -//! The interfaces the kit's contracts share, and each contract's block of -//! error codes. +//! The interfaces the kit's contracts share, and their error code blocks. //! //! - **The compiler checks the feed.** The vault prices from `OracleFeed`, -//! which the kit's oracle and any feed an adopter brings both implement. The -//! cost: a feed must answer the trait's four calls as written; any other -//! shape needs an adapter. -//! - **Bound contracts report their binding.** A contract that runs on a -//! vault's timelock answers `VaultBinding`, and the vault's `finish_setup` -//! reads it. The cost: a compliance contract that does not answer it is -//! accepted as an external module, so its own governance is a trust -//! assumption. -//! - **A block of codes per contract.** Each kit contract owns a block of error -//! codes, and `error_enum!` fails the build if a code leaves its block or two -//! blocks overlap, so a code inside the blocks names the contract that raised -//! it. The cost: a code is never reused, so a removed variant leaves its -//! number retired. +//! which the kit's oracle and any adopter's feed implement. The cost: a feed +//! must answer its four calls as written, or use an adapter. +//! - **Bound contracts report their binding.** The vault's `finish_setup` reads +//! `VaultBinding`. The cost: a compliance contract that does not answer it is +//! accepted, on trust, as external. +//! - **Fixed blocks of error codes.** The vault, token, oracle and compliance +//! contract each own one; `error_enum!` fails the build outside it, +//! `errors.rs` on overlaps. The cost: removed codes stay retired. #![no_std] diff --git a/crates/strata-storage/src/lib.rs b/crates/strata-storage/src/lib.rs index 0b11996a..9c79aec1 100644 --- a/crates/strata-storage/src/lib.rs +++ b/crates/strata-storage/src/lib.rs @@ -3,22 +3,18 @@ //! Storage helpers that keep the kit's own entries alive. Every read and every //! write through them asks the network to renew the entry it touches. //! -//! - **Thirty days, renewed a day short.** Each entry, the contract instance -//! included, goes back to 30 days once it falls a day short of that. The -//! cost: an entry in daily use renews about once a day, for about a day of -//! rent, and one nobody reads archives when its lifetime runs out. -//! - **Extended in the call that creates it.** A write asks for the renewal -//! too, so a new entry lives at least its target from the call that creates -//! it. The cost: that call pays for the target, or for the network's minimum -//! when that is longer. -//! - **The same lengths on every network.** They are fixed in code, and each -//! network applies its own minimum lifetime on top, so the Wasm tested on -//! testnet is the Wasm that ships. The cost: no network gets lengths of its -//! own, and changing them takes an upgrade. -//! - **No cap per call.** An extension always reaches the full target. A cap -//! would let rarely read entries archive sooner, and a restore on mainnet -//! pays at least 120 days of rent. The cost: the first call after a quiet -//! period pays to renew everything it touches. +//! - **Thirty days, renewed a day short.** Each entry, the instance included, +//! goes back to 30 days once it falls a day short. The cost: a busy entry +//! costs about a day of rent per day; an idle one archives. +//! - **Extended in the call that creates it.** A write renews too, so a new +//! entry starts with at least its target. The cost: that call pays for it, or +//! for the network's minimum when that is longer. +//! - **The same lengths on every network,** fixed in code, so the Wasm tested +//! on testnet is the one that ships; each network's minimum lifetime is a +//! floor for new entries. The cost: no lengths tuned per network. +//! - **No cap per call.** A cap would let rarely read entries archive sooner, +//! and a mainnet restore pays at least 120 days of rent. The cost: the first +//! call after a quiet spell renews all it touches. #![no_std] diff --git a/crates/strata-timelock/src/lib.rs b/crates/strata-timelock/src/lib.rs index 9f117357..203ea026 100644 --- a/crates/strata-timelock/src/lib.rs +++ b/crates/strata-timelock/src/lib.rs @@ -5,30 +5,24 @@ //! down. That contract and every contract bound to it queue their changes //! against the same clock. //! -//! - **One clock, the vault's.** The share token, the oracle and the kit's -//! compliance contract bind to the vault once, during setup, and read its -//! clock on every proposal and every apply. One pause and one wind-down then -//! hold every waiting change in the kit. The cost: while the vault is paused -//! no contract in the kit can apply a change, and a contract bound to the -//! wrong vault stays bound. -//! - **A setup window first.** Changes are instant until setup finishes, and -//! nothing is queued before it; setup closes once and never reopens. The -//! cost: a mistake found after setup takes a full delay to fix. -//! - **One queue per contract.** Every kind of change waits in the same queue, -//! at most `MAX_PENDING` at a time, and an id is never handed out twice. The -//! cost: a full queue refuses proposals until a change lands or is cancelled. -//! - **The delay is an announcement period.** A change keeps the delay it was -//! proposed with, its eta moves back by every second the vault spends paused, -//! and nothing lands while the vault is paused. The delay promises notice, -//! not an exit: leaving before a change lands also needs a sealed epoch, a -//! price and cash. The cost: a long pause delays every fix. -//! - **Never shorter than the notice.** A change whose delay is shorter than -//! the notice when it applies never lands. The cost: raising the notice can -//! strand changes already waiting; they must be proposed again. -//! - **Nothing lands in a wind-down.** Once a wind-down starts nothing is -//! proposed or applied, and changes already waiting never land. The cost: -//! nothing can be fixed during a wind-down, a failing compliance module -//! included. +//! - **One clock, the vault's.** The token, the oracle and the compliance +//! contract bind to it once, during setup, and read it on every proposal and +//! apply. The cost: a vault pause holds every change in the kit. +//! - **A setup window first.** Changes are instant until setup ends, nothing +//! queues before it, and it never reopens. The cost: a mistake found after +//! setup takes a full delay to fix. +//! - **One queue per contract.** Every kind of change shares it, at most +//! `MAX_PENDING` at once, and no id is reused. The cost: a full queue refuses +//! proposals until a change lands or is cancelled. +//! - **The delay is an announcement period.** It promises notice, not an exit, +//! which also needs a sealed epoch, a price and cash. Paused time does not +//! count. The cost: a long pause delays every fix. +//! - **Never shorter than the notice.** Each attempt to apply compares the +//! change's delay with the notice then. The cost: a raised notice holds back +//! waiting changes until it comes down or they are proposed again. +//! - **Nothing lands in a wind-down.** Nothing is proposed or applied once it +//! starts. The cost: nothing can be fixed during a wind-down, a failing +//! compliance module included. //! - **Seven to ninety days.** `MIN_DELAY` and `MAX_DELAY` bound the delay the //! vault sets. The cost: the fastest fix after setup takes a week. From 2c3b67da4353f6165d64ed9b8ff290b3f4e13b51 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 07:01:25 -0300 Subject: [PATCH 115/147] test: pin four claims of the design notes --- contracts/async-vault/src/test/compliance.rs | 35 +++++++++++++- contracts/async-vault/src/test/deposit_cap.rs | 37 +++++++++++++++ contracts/async-vault/src/test/wind_down.rs | 47 ++++++++++++++++++- contracts/share-token/src/test.rs | 30 ++++++++++++ 4 files changed, 147 insertions(+), 2 deletions(-) diff --git a/contracts/async-vault/src/test/compliance.rs b/contracts/async-vault/src/test/compliance.rs index ae84f4a0..92c66fc5 100644 --- a/contracts/async-vault/src/test/compliance.rs +++ b/contracts/async-vault/src/test/compliance.rs @@ -1,5 +1,5 @@ use soroban_sdk::{contract, contractimpl}; -use stellar_tokens::rwa::compliance::ComplianceHook; +use stellar_tokens::rwa::compliance::{ComplianceError, ComplianceHook}; use super::*; @@ -113,6 +113,39 @@ fn a_compliance_module_that_refuses_the_mint_blocks_a_priced_claim() { ); } +#[test] +fn an_unbound_compliance_swap_lands_and_stops_every_move() { + let f = setup(); + let holder = f.holder(10); + let investor = f.investor(1_000); + let epoch = f.vault.request_deposit(&investor, &100); + f.vault.request_redeem(&holder, &4); + f.fulfill_epoch(wad(2)); + + let unbound = f.e.register(Compliance, (f.admin.clone(),)); + let id = f + .share + .propose_change(&share_token::TokenChange::Compliance(unbound.clone())); + f.advance(MIN_UPGRADE_DELAY); + f.share.apply_change(&id); + assert_eq!(f.share.compliance(), unbound); + + let token_not_bound = Some(Ok(ComplianceError::TokenNotBound.into())); + assert_eq!( + f.share.try_transfer(&holder, &investor, &1).err(), + token_not_bound + ); + assert_eq!( + f.vault.try_claim_deposit(&investor, &epoch).err(), + token_not_bound + ); + assert_eq!( + f.vault.try_claim_redeem(&holder, &epoch).err(), + token_not_bound + ); + assert!(!f.vault.health().compliance_bound); +} + #[test] fn a_delisted_holder_can_still_queue_an_exit() { let f = setup(); diff --git a/contracts/async-vault/src/test/deposit_cap.rs b/contracts/async-vault/src/test/deposit_cap.rs index e0c321d1..5d551cad 100644 --- a/contracts/async-vault/src/test/deposit_cap.rs +++ b/contracts/async-vault/src/test/deposit_cap.rs @@ -178,3 +178,40 @@ fn zero_cap_blocks_all_deposits() { VaultError::DepositCapExceeded, ); } + +/// 1,000 deposited, priced and sent to the custodian; the cap is then 1,500. +fn capital_deployed_under_a_cap(f: &Fixture) -> Address { + let investor = f.investor(2_000); + f.vault.set_deposit_cap(&Some(1_000)); + let epoch = f.vault.request_deposit(&investor, &1_000); + f.close_epoch(); + f.fulfill_epoch_at(epoch, wad(1)); + f.vault.deploy_to_custodian(&f.treasury, &1_000); + f.vault.set_deposit_cap(&Some(1_500)); + investor +} + +#[test] +fn capital_funded_back_counts_once_toward_the_cap() { + let f = setup(); + let investor = capital_deployed_under_a_cap(&f); + + f.vault.fund(&f.custodian, &500); + + assert_eq!(f.vault.net_deployed(), 500); + f.vault.request_deposit(&investor, &500); +} + +#[test] +fn a_plain_transfer_back_counts_twice_toward_the_cap() { + let f = setup(); + let investor = capital_deployed_under_a_cap(&f); + + TokenClient::new(&f.e, &f.asset).transfer(&f.custodian, &f.vault.address, &500); + + assert_eq!(f.vault.net_deployed(), 1_000); + refused( + f.vault.try_request_deposit(&investor, &1), + VaultError::DepositCapExceeded, + ); +} diff --git a/contracts/async-vault/src/test/wind_down.rs b/contracts/async-vault/src/test/wind_down.rs index fee93d15..b9428aec 100644 --- a/contracts/async-vault/src/test/wind_down.rs +++ b/contracts/async-vault/src/test/wind_down.rs @@ -1,5 +1,5 @@ use proptest::prelude::*; -use soroban_sdk::IntoVal; +use soroban_sdk::{contract, contracterror, contractimpl, panic_with_error, IntoVal}; use super::*; @@ -409,6 +409,51 @@ fn a_holder_who_waits_collects_every_round_at_once() { assert_eq!(all_at_once, first + second); } +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[repr(u32)] +enum ModuleError { + BurnRefused = 1, +} + +#[contract] +struct FailsEveryBurn; + +#[contractimpl] +impl FailsEveryBurn { + pub fn on_destroyed(e: Env, _from: Address, _amount: i128, _token: Address) { + panic_with_error!(&e, ModuleError::BurnRefused); + } +} + +#[test] +fn a_module_failing_on_burn_blocks_wind_down_claims_and_cannot_be_removed() { + let f = setup(); + let holder = f.holder(500); + let module = f.e.register(FailsEveryBurn, ()); + f.add_compliance_module(ComplianceHook::Destroyed, &module); + wind_down_now(&f); + f.vault.finalize_wind_down_round(); + + assert_eq!( + f.vault.try_claim_wind_down(&holder), + Err(Ok(ModuleError::BurnRefused.into())) + ); + assert_eq!(f.shares(&holder), 500); + assert_eq!(f.balance(&holder), 0); + + let rules = ComplianceClient::new(&f.e, &f.share.compliance()); + assert_eq!( + rules.try_propose_change(&ComplianceChange::RemoveModule( + ComplianceHook::Destroyed, + module, + )), + Err(Ok( + ::compliance::ComplianceContractError::WindDownActive.into() + )) + ); +} + #[test] fn claiming_burns_the_shares_and_pays_once() { let f = setup(); diff --git a/contracts/share-token/src/test.rs b/contracts/share-token/src/test.rs index 52137224..22ef207e 100644 --- a/contracts/share-token/src/test.rs +++ b/contracts/share-token/src/test.rs @@ -707,6 +707,36 @@ mod legal_transfer { assert_eq!(f.token.balance(&destination), 250); } + #[test] + fn a_zero_share_legal_transfer_passes_and_is_published() { + let e = Env::default(); + let (f, holder, destination, _) = live(&e); + + f.token.legal_transfer( + &holder, + &destination, + &0, + &order(&e), + &f.compliance_authority, + ); + + let events = e.events().all().filter_by_contract(&f.token.address); + assert_eq!( + events.events().last(), + Some( + &LegalTransfer { + from: holder.clone(), + to: destination.clone(), + amount: 0, + reference: order(&e), + } + .to_xdr(&e, &f.token.address) + ) + ); + assert_eq!(f.token.balance(&holder), 1_000); + assert_eq!(f.token.balance(&destination), 0); + } + #[test] fn compliance_alone_cannot_move_shares() { let e = Env::default(); From f4ba9a4f25a67fa96e8159c4cf4b4c2a084a2686 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 07:01:40 -0300 Subject: [PATCH 116/147] docs: always clear a compromised attester's report --- docs/kit-spec.md | 2 +- docs/strata-product-and-architecture.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/kit-spec.md b/docs/kit-spec.md index c4caa665..f6371993 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -219,7 +219,7 @@ Pause first, so no report lands while the roles change, and lift the pause last: 1. Pause the NAV: the oracle's guardian calls `pause_nav`, or its admin calls `set_nav_paused(true)`. The oracle refuses every report and the vault prices nothing; sealed epochs reopen to cancellation. 2. Revoke the compromised attester: `revoke_role(attester, "attester", admin)` on the oracle. It is instant, during setup and after it. 3. Grant a replacement. During the oracle's setup, `grant_role` is instant. After it, propose a `GrantRole` change and apply it once the vault's delay has run, plus any time the vault spends paused. The NAV stays paused that whole time, at least seven days. -4. If the stored report is wrong, call `clear_latest` (admin; only while the NAV is paused). The next report then has no earlier price to cap its move: only `min_answer` and `max_answer` bound it. +4. Clear the stored report: `clear_latest` (admin; only while the NAV is paused). Left in place, it is valid again once the pause lifts, and anyone can price with it any epoch sealed before it was accepted, until it expires. Skip this only if you know the report predates the compromise. The next report then has no earlier price to cap its move: only `min_answer` and `max_answer` bound it. 5. Lift the NAV pause: `set_nav_paused(false)` (admin). 6. The new attester posts a report. Pricing resumes: an epoch can be priced once a report accepted after its seal is valid. diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index 6e39d863..d4a4ce5f 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -617,7 +617,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. A compromised attester is replaced in this order: pause the NAV, revoke the attester (instant), grant a replacement (through the timelock after setup, so the NAV stays paused that long), clear the stored report if it is wrong, then lift the pause ([`kit-spec.md`](./kit-spec.md) §3) | +| 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. A compromised attester is replaced in this order: pause the NAV, revoke the attester (instant), grant a replacement (through the timelock after setup, so the NAV stays paused that long), clear the stored report, which would otherwise price epochs again once the pause lifts, then lift the pause ([`kit-spec.md`](./kit-spec.md) §3) | | Governance keys | Malicious upgrade | Timelock on every code change and on the delay itself: at least seven days, never shorter than the standing notice, at most 90 days. The delay is an announcement period. A pause freezes the timelock clock and pushes the ETA back by the time paused, so the whole delay runs unpaused, and applying while paused is refused. The delay does not guarantee an exit before a change lands: an exit also needs a sealed epoch, a valid price accepted after the seal, and cash in the reserve to pay the claim. Nothing is proposed or applied while the vault winds down. The vault's own custodian, role grants, `manager` revocations, admin handovers and lowering of the notice or the wind-down delay wait on that clock once its one-time setup closes, and it takes no deposit before then; revoking its treasury or guardian stays instant. Setup closes only once the token and the oracle run on the vault's clock with their own setup closed, the vault is the token's only minter, no shares exist yet (shares minted earlier would redeem against depositors' money), the vault passes the identity check, its address is not frozen on the token, the token is bound to its compliance contract, the asset has the token's decimals, the asset, the token, the oracle and the vault are four different addresses, the oracle answers the feed interface, and a custodian other than the vault is set; the kit's own compliance contract must also run on the vault's clock with its setup closed. 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. On the kit's compliance contract, adding or removing a module, binding or unbinding a token and handing over the admin wait on the same clock once its setup closes. | | Mis-wired deployment | A vault goes live without every binding wired | `health()` reports each wiring step; `finish_setup` refuses until all hold, and the vault takes no deposit before that | | 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 | From ea0d6611bc9baf3ef64eea7e87d524f2a2a6479c Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 07:02:02 -0300 Subject: [PATCH 117/147] docs: drop stale manager and exit window wording --- contracts/async-vault/src/guards.rs | 3 ++- contracts/async-vault/src/lib.rs | 1 - crates/strata-timelock/src/refusal.rs | 2 +- docs/kit-spec.md | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/contracts/async-vault/src/guards.rs b/contracts/async-vault/src/guards.rs index 2eab6ec7..5d8c6294 100644 --- a/contracts/async-vault/src/guards.rs +++ b/contracts/async-vault/src/guards.rs @@ -135,7 +135,8 @@ pub(crate) fn wind_down_delay_in_bounds(e: &Env, secs: u64) { } } -/// An exit takes a full notice, so a delay shorter than one is not a window. +/// A shorter delay than the notice would let a change land before an epoch +/// sealed at its proposal could be priced. pub(crate) fn upgrade_delay_in_bounds(e: &Env, secs: u64) { if secs < MIN_UPGRADE_DELAY { panic_with_error!(e, VaultError::UpgradeDelayTooShort); diff --git a/contracts/async-vault/src/lib.rs b/contracts/async-vault/src/lib.rs index 6b296b35..d423b821 100644 --- a/contracts/async-vault/src/lib.rs +++ b/contracts/async-vault/src/lib.rs @@ -138,7 +138,6 @@ impl AsyncVault { oracle: Address, roles: VaultRoles, ) { - // `roles` holds the five authorities and the manager; the attester is not read. guards::roles_distinct(e, &roles); access_control::set_admin(e, &roles.governance); diff --git a/crates/strata-timelock/src/refusal.rs b/crates/strata-timelock/src/refusal.rs index 2d1ae57b..fa718be7 100644 --- a/crates/strata-timelock/src/refusal.rs +++ b/crates/strata-timelock/src/refusal.rs @@ -8,7 +8,7 @@ pub enum Refusal { Paused, /// The change's delay, plus the time paused since, has not run yet. NotElapsed, - /// The change's delay is shorter than the current exit window. + /// The change's delay is shorter than the source's notice now. BelowExitWindow, /// No pending change has this id. NotFound, diff --git a/docs/kit-spec.md b/docs/kit-spec.md index f6371993..37e2a13e 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -140,7 +140,7 @@ Deploying the Strata Vault Kit involves five contracts configured via `environme | **`async_vault`** | `roles.governance` | `Address` | Governance multisig authority | `async_vault.constructor_args` | | **`async_vault`** | `roles.treasury` | `Address` | Treasury multisig authority | `async_vault.constructor_args` | | **`async_vault`** | `roles.guardian` | `Address` | Guardian multisig authority | `async_vault.constructor_args` | -| **`async_vault`** | `roles.manager` | `Address` | Manager authority (closes epochs) | `async_vault.constructor_args` | +| **`async_vault`** | `roles.manager` | `Address` | Account that gets the vault's `manager` role (closes epochs); a role, not one of the five authorities | `async_vault.constructor_args` | | **`async_vault`** | `roles.compliance` | `Address` | Compliance multisig authority | `async_vault.constructor_args` | | **`async_vault`** | `roles.attester` | `Address` | Attestation authority (mirrors oracle reporter) | `async_vault.constructor_args` | @@ -197,7 +197,7 @@ The `async_vault` constructor strictly verifies pairwise separation across autho ## 3. Role & Authority Matrix -Strata distributes control across five native Stellar multisig authorities and permissionless public entrypoints. Every privileged function is mapped to exactly one authority, except the share token's `legal_transfer` and `recover_balance`, which need compliance and governance in the same call. Each call asks each account it needs for one signature. An admin entrypoint the kit defines takes no caller argument: the stored admin signs it. +Strata distributes control across five native Stellar multisig authorities, two `manager` roles and permissionless public entrypoints. Every privileged function needs exactly one of these authorities or roles, except the share token's `legal_transfer` and `recover_balance`, which need compliance and governance in the same call. Each call asks each account it needs for one signature. An admin entrypoint the kit defines takes no caller argument: the stored admin signs it. ### Authority Capabilities & Anti-Capabilities From f07f5f6584fa3d8b573a41b50f53aa984eac59c7 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:05:03 -0300 Subject: [PATCH 118/147] docs: correct role, exit and feed claims --- docs/AUTHORIZATION_MATRIX.md | 8 ++++---- docs/COMPLIANCE_MATRIX.md | 2 +- docs/kit-spec.md | 10 +++++----- environments.toml | 6 +++++- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/docs/AUTHORIZATION_MATRIX.md b/docs/AUTHORIZATION_MATRIX.md index a74c0ae6..05721e18 100644 --- a/docs/AUTHORIZATION_MATRIX.md +++ b/docs/AUTHORIZATION_MATRIX.md @@ -50,10 +50,10 @@ In `scripts/harness/deploy.ts` and automated deployment scripts, the separation ## 3. Vault Constructor Authority Handling `AsyncVault::__constructor` accepts `VaultRoles`, which includes `governance`, `manager`, `treasury`, `guardian`, `compliance`, and `attester`. -- **Why `compliance` and `attester` are kept in the constructor**: - The vault kit enforces an end-to-end separation of duties. At construction, the vault validates pairwise distinctness: +- **What the constructor does with `compliance` and `attester`**: + It checks four pairs and refuses an address that appears in both seats (`RolesNotDistinct`): `treasury != guardian`, `treasury != governance`, `compliance != governance`, and `compliance != treasury`. - Although compliance is enforced on `IdentityVerifier` / `Compliance` and attestation on `NavOracle`, the vault records and validates the full 5-authority topology at instantiation time to prevent cross-contamination of duties. Neither `compliance` nor `attester` is granted a direct role on the vault itself. + No other pair is compared: governance and guardian may be one account, and the manager is unconstrained. The vault stores neither `compliance` nor `attester` and grants neither a role. `compliance` is read only by that check; `attester` is neither stored nor compared, so nothing ties it to the oracle's attester. Compliance acts on the identity verifier and the share token, and attestation on the oracle. --- @@ -71,7 +71,7 @@ The following entrypoints are open to any caller on purpose: | **`AsyncVault`** | `request_deposit` | `holder.require_auth()` | Investor entrypoint. Deposits escrowed cash; guarded by `when_not_paused`, not winding down, and refused with `SetupOpen` until the vault's setup finishes, so every deposit is covered by the timelock. | | **`AsyncVault`** | `claim_deposit` | `holder.require_auth()` | Investor entrypoint. Mints the shares once the epoch is priced; the token checks the receiver on `mint`. A delisted holder's shares become an exit instead (`DepositConvertedToExit`): they join the holder's redemption in the open epoch, or the holder's wind-down entitlement during a wind-down. A frozen holder's claim is held (`Frozen`). | | **`AsyncVault`** | `cancel_deposit` | `holder.require_auth()` | Investor exit while the epoch cannot be priced. Refunds the whole escrowed deposit in one step, delisted or not. A frozen holder is held (`Frozen`). | -| **`AsyncVault`** | `request_redeem` | `holder.require_auth()` | Investor exit. Always open, even during pause. | +| **`AsyncVault`** | `request_redeem` | `holder.require_auth()` | Investor exit. Open during a pause and to a delisted holder; refused during a wind-down (`WindDownActive`). | | **`AsyncVault`** | `claim_redeem` | `holder.require_auth()` | Investor exit. Pays settlement asset once covered. | | **`AsyncVault`** | `cancel_redeem` | `holder.require_auth()` | Investor exit while the epoch cannot be priced. Outside a wind-down the vault checks the holder itself and returns the escrowed shares with the manager `forced_transfer`; it refuses a delisted holder (`NotAllowlisted`) and a move the compliance rules refuse (`HolderNotEligible`). In an active wind-down the escrow is surrendered into the holder's wind-down entitlement instead (`RedemptionSurrendered`). A frozen holder is held (`Frozen`) either way. | diff --git a/docs/COMPLIANCE_MATRIX.md b/docs/COMPLIANCE_MATRIX.md index 3a318040..ffa5d39a 100644 --- a/docs/COMPLIANCE_MATRIX.md +++ b/docs/COMPLIANCE_MATRIX.md @@ -99,7 +99,7 @@ The share token is OpenZeppelin's RWA token. The allowlist and freeze checks sit When the vault returns escrowed shares with `forced_transfer`, it makes the receiver checks itself first: the freeze, `verify_identity` and `can_transfer`. It does not check the token pause. -The kit's compliance contract registers no modules, so `can_transfer` and `can_create` always allow today. Until rule modules are added, the allowlist and freeze rules are exactly the token checks in the table above. Adding or removing a module waits for the vault's timelock once its setup is finished, so a rule change never lands sooner than an exit completes. +The kit's compliance contract registers no modules, so `can_transfer` and `can_create` always allow today. Until rule modules are added, the allowlist and freeze rules are exactly the token checks in the table above. Adding or removing a module waits for the vault's timelock once its setup is finished, so holders get at least the delay as notice of a rule change. An exit still needs a sealed epoch, a price and cash, so the delay does not promise that one completes first. --- diff --git a/docs/kit-spec.md b/docs/kit-spec.md index 37e2a13e..022e67f2 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -142,10 +142,10 @@ Deploying the Strata Vault Kit involves five contracts configured via `environme | **`async_vault`** | `roles.guardian` | `Address` | Guardian multisig authority | `async_vault.constructor_args` | | **`async_vault`** | `roles.manager` | `Address` | Account that gets the vault's `manager` role (closes epochs); a role, not one of the five authorities | `async_vault.constructor_args` | | **`async_vault`** | `roles.compliance` | `Address` | Compliance multisig authority | `async_vault.constructor_args` | -| **`async_vault`** | `roles.attester` | `Address` | Attestation authority (mirrors oracle reporter) | `async_vault.constructor_args` | +| **`async_vault`** | `roles.attester` | `Address` | Attestation authority. Not read: nothing checks that it matches the oracle's attester | `async_vault.constructor_args` | -#### Pairwise Role Separation Enforced at Construction -The `async_vault` constructor strictly verifies pairwise separation across authorities: +#### Role Separation Checked at Construction +The `async_vault` constructor refuses these four pairs of equal addresses and compares no other: - `treasury != guardian` - `treasury != governance` - `compliance != governance` @@ -182,7 +182,7 @@ The `async_vault` constructor strictly verifies pairwise separation across autho | `addresses_distinct` | The asset, the share token, the oracle and the vault are four different addresses. | | `custodian_set` | A custodian is set, and it is not the vault itself. | | `feed_answers` | The oracle answers `state()` and `freshness_duration()`. A new feed with no report passes. `oracle_bound` cannot tell a feed from another contract bound to this vault, such as the compliance contract. | -| `feed_valid` | The oracle reports `Valid`: a report exists, it has not expired and the NAV is not paused. | +| `feed_valid` | The oracle reports `Valid`: a report exists, it has not expired, it is in band and the NAV is not paused. | | `freshness_covers_notice` | The oracle's `freshness_duration` is at least the vault's notice. | `freshness_covers_notice` false means a report can expire before the notice ends. A sealed epoch's cancellation window can then reopen on its own, so set `freshness_duration` above the notice. `feed_valid` false right after a deploy is normal until the first attestation. @@ -232,7 +232,7 @@ Pause first, so no report lands while the roles change, and lift the pause last: | **`AsyncVault`** | `request_deposit` | Investor (Caller) | `holder.require_auth()`; refused until setup finishes (`SetupOpen`) | Yes (halted by pause) | | **`AsyncVault`** | `cancel_deposit` | Investor (Caller) | `holder.require_auth()` | No (open during pause) | | **`AsyncVault`** | `claim_deposit` | Investor (Caller) | `holder.require_auth()` | No (open during pause) | -| **`AsyncVault`** | `request_redeem` | Investor (Caller) | `holder.require_auth()` | No (always open) | +| **`AsyncVault`** | `request_redeem` | Investor (Caller) | `holder.require_auth()` | No (open during pause; refused in a wind-down) | | **`AsyncVault`** | `cancel_redeem` | Investor (Caller) | `holder.require_auth()` | No (open during pause) | | **`AsyncVault`** | `claim_redeem` | Investor (Caller) | `holder.require_auth()` | No (always open) | | **`AsyncVault`** | `close_epoch` | Manager | `#[only_role(caller, "manager")]` | No | diff --git a/environments.toml b/environments.toml index 9a8d1e4d..44aa96ac 100644 --- a/environments.toml +++ b/environments.toml @@ -57,7 +57,11 @@ constructor_args = '--asset share_token --share_token share_token --oracle nav_o # Setup cannot finish from this file either. The vault's `finish_setup` checks # the token, oracle and compliance bindings, the manager role and the # allowlist, which only the harness deploy wires, and that the token has no -# shares outstanding. A deploy made only from this file leaves the compliance +# shares outstanding. It also checks that the asset has the token's decimals, +# that the asset, the token, the oracle and the vault are four different +# addresses (here the asset is the share token, so this one fails too), that +# the oracle answers as a feed, and that a custodian other than the vault is +# set. A deploy made only from this file leaves the compliance # contract's, the token's, the oracle's and the vault's setup open, so its # vault refuses deposits with `SetupOpen`. The harness deploy closes all four. From 546fecefe0bf7889aab4cdb6eb896a5d9d67bf6a Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:05:04 -0300 Subject: [PATCH 119/147] docs: narrow the token's compliance claims --- contracts/share-token/src/contract.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/share-token/src/contract.rs b/contracts/share-token/src/contract.rs index f3d8dd15..5b10552a 100644 --- a/contracts/share-token/src/contract.rs +++ b/contracts/share-token/src/contract.rs @@ -27,7 +27,7 @@ pub struct ShareToken; #[contractimpl] impl ShareToken { /// Creates the token with 7 decimals, its admin, its compliance authority - /// and the two contracts every move is checked against. + /// and the two contracts that check each `transfer` and mint. /// /// # Arguments /// @@ -208,7 +208,7 @@ impl ShareToken { } /// Moves shares under a legal order, at once. The pause, freezes and the - /// compliance rules do not apply. + /// compliance rules' check do not apply. /// /// # Arguments /// From d74f5ce06e13030bd120b9c4dd36bcf7aca65df3 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:05:05 -0300 Subject: [PATCH 120/147] docs: list the spec doc test and its limit --- AGENTS.md | 5 ++++- docs/TESTING.md | 11 +++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 568913a3..44cc096f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -139,7 +139,10 @@ contracts. The addresses emitted by the deploy script are authoritative. one-line summary, then `# Arguments` with units (asset base units, share base units, WAD prices, seconds or ledgers), `# Errors`, `# Events` and `# Authorization`. Every error variant's doc says when it happens. Keep them - short: they are stored in the Wasm as part of the contract spec. + short: they are stored in the Wasm as part of the contract spec, which keeps + 1,024 bytes of each doc and drops the rest without a warning. + `every_contract_doc_fits_the_spec` (`crates/strata-bindings/tests/spec_docs.rs`) + fails when one is longer. - **Design notes.** Each contract's `lib.rs` opens with a `//! # Design` section: five to ten bullets, each a choice and its trade-off, in plain words and with no issue numbers or decision ids. diff --git a/docs/TESTING.md b/docs/TESTING.md index 8a23b76d..b96c9255 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -145,6 +145,17 @@ fixture wires them as the deploy does and finishes the vault's setup; | 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` | +## Contract spec docs and error codes + +The contract spec keeps the first 1,024 bytes of each doc and drops the rest +without a warning. Each contract's error codes stay inside the block +`crates/strata-bindings` gives it. + +| Behaviour | Tests | Key test names | +| --- | --- | --- | +| Every entrypoint and error variant doc of the five contracts fits in 1,024 bytes | [`crates/strata-bindings/tests/spec_docs.rs`](../crates/strata-bindings/tests/spec_docs.rs) | `every_contract_doc_fits_the_spec` | +| An error enum declared through `error_enum!` compiles only while every code stays in its contract's block | [`crates/strata-bindings/src/errors.rs`](../crates/strata-bindings/src/errors.rs) (doctests) | the in-range example compiles; the out-of-range one fails with `E0080` | + ## Wind-down on the network `scripts/harness/wind-down.test.ts` deploys a throwaway vault set on the local From 66b60fc6106de310f0f0c09d5efe84dcfc3b6b26 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:05:06 -0300 Subject: [PATCH 121/147] refactor: name two guards for what they check --- contracts/async-vault/src/governance.rs | 4 ++-- contracts/async-vault/src/guards.rs | 11 ++++++----- contracts/async-vault/src/test/guards.rs | 14 ++++++++------ 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/contracts/async-vault/src/governance.rs b/contracts/async-vault/src/governance.rs index f0904ced..1accb496 100644 --- a/contracts/async-vault/src/governance.rs +++ b/contracts/async-vault/src/governance.rs @@ -217,7 +217,7 @@ pub(crate) fn set_notice(e: &Env, secs: u64) { pub(crate) fn propose(e: &Env, change: VaultChange) -> u32 { guards::not_winding_down(e); guards::setup_finished(e); - guards::change_in_bounds(e, &change); + guards::valid_change(e, &change); guards::not_a_second_code_change(e, &change); let id = queue::propose::(e, &change).unwrap_or_else(|r| refuse(e, r)); ChangeProposed { @@ -238,7 +238,7 @@ pub(crate) fn apply(e: &Env, id: u32) { let change: VaultChange = queue::take_ready::(e, id).unwrap_or_else(|r| refuse(e, r)); // The notice or the delay may have moved since the proposal; this check decides. - guards::change_in_bounds(e, &change); + guards::valid_change(e, &change); let admin = access_control::get_admin(e) .unwrap_or_else(|| panic_with_error!(e, VaultError::AdminRequired)); match &change { diff --git a/contracts/async-vault/src/guards.rs b/contracts/async-vault/src/guards.rs index 5d8c6294..577eb4c9 100644 --- a/contracts/async-vault/src/guards.rs +++ b/contracts/async-vault/src/guards.rs @@ -156,20 +156,21 @@ pub(crate) fn custodian_not_the_vault(e: &Env, custodian: &Address) { } /// A feed that cannot answer would stall every epoch after the swap. -pub(crate) fn feed_bound_here(e: &Env, feed: &Address) { +pub(crate) fn feed_bound_here_and_answering(e: &Env, feed: &Address) { if !governance::bound_here(e, feed) || !governance::answers_as_feed(e, feed) { panic_with_error!(e, VaultError::FeedNotBound); } } -/// The bounds a change must meet when it is proposed and again when it -/// applies. -pub(crate) fn change_in_bounds(e: &Env, change: &VaultChange) { +/// What a change must meet when it is proposed and again when it applies: a +/// delay or notice within its bounds, a feed bound here that answers, or a +/// custodian other than the vault. +pub(crate) fn valid_change(e: &Env, change: &VaultChange) { match change { VaultChange::UpgradeDelay(secs) => upgrade_delay_in_bounds(e, *secs), VaultChange::Notice(secs) => notice_in_bounds(e, *secs), VaultChange::WindDownDelay(secs) => wind_down_delay_in_bounds(e, *secs), - VaultChange::Oracle(feed) => feed_bound_here(e, feed), + VaultChange::Oracle(feed) => feed_bound_here_and_answering(e, feed), VaultChange::Custodian(custodian) => custodian_not_the_vault(e, custodian), VaultChange::Upgrade(_) | VaultChange::GrantRole(..) diff --git a/contracts/async-vault/src/test/guards.rs b/contracts/async-vault/src/test/guards.rs index f3b8226d..b484fd16 100644 --- a/contracts/async-vault/src/test/guards.rs +++ b/contracts/async-vault/src/test/guards.rs @@ -281,9 +281,11 @@ fn custodian_not_the_vault_refuses_the_vault_itself() { } #[test] -fn feed_bound_here_refuses_an_unbound_feed_or_a_contract_that_is_not_a_feed() { +fn feed_bound_here_and_answering_refuses_an_unbound_feed_or_a_contract_that_is_not_a_feed() { let f = setup(); - holds(&f, || guards::feed_bound_here(&f.e, &f.oracle.address)); + holds(&f, || { + guards::feed_bound_here_and_answering(&f.e, &f.oracle.address) + }); let unbound = f.e.register( NavOracleContract, @@ -297,13 +299,13 @@ fn feed_bound_here_refuses_an_unbound_feed_or_a_contract_that_is_not_a_feed() { // The compliance contract is bound to this vault but answers no feed view. for not_a_feed_here in [unbound, f.share.compliance()] { refuses(&f, VaultError::FeedNotBound, || { - guards::feed_bound_here(&f.e, ¬_a_feed_here) + guards::feed_bound_here_and_answering(&f.e, ¬_a_feed_here) }); } } #[test] -fn change_in_bounds_checks_the_bounds_of_each_change() { +fn valid_change_checks_each_kind_of_change() { let f = setup(); let stranger = Address::generate(&f.e); for change in [ @@ -317,7 +319,7 @@ fn change_in_bounds_checks_the_bounds_of_each_change() { VaultChange::Admin(stranger.clone()), VaultChange::Oracle(f.oracle.address.clone()), ] { - holds(&f, || guards::change_in_bounds(&f.e, &change)); + holds(&f, || guards::valid_change(&f.e, &change)); } for (change, error) in [ @@ -346,7 +348,7 @@ fn change_in_bounds_checks_the_bounds_of_each_change() { VaultError::InvalidCustodian, ), ] { - refuses(&f, error, || guards::change_in_bounds(&f.e, &change)); + refuses(&f, error, || guards::valid_change(&f.e, &change)); } } From 9f5650789bc24271651cc7221016652f06a1c3ab Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:08:09 -0300 Subject: [PATCH 122/147] docs: fix the upgrade test name in TESTING.md --- docs/TESTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/TESTING.md b/docs/TESTING.md index ca832e7b..477899c5 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -90,4 +90,4 @@ contract and oracle, plus a Stellar Asset Contract for the deposit asset. | The vault mints and burns shares through its role on the share token | [`contracts/async-vault/src/test/constructor.rs`](../contracts/async-vault/src/test/constructor.rs), [`contracts/async-vault/src/test/redeem.rs`](../contracts/async-vault/src/test/redeem.rs) | `the_vault_holds_the_manager_role_on_the_share_token`, `claim_redeem_pays_assets_and_burns_the_shares` | | The share token checks the identity verifier 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) | `a_non_allowlisted_investor_cannot_claim_shares`, `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_proposal_replaces_the_code`, `a_migration_refuses_a_second_run` | +| 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` | From 793c81a95f6520be8c85bcc5cc0413e4b49e518b Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:12:22 -0300 Subject: [PATCH 123/147] fix: correct the ttl policy and the extend order npm run extend now restores and extends each Wasm before it reads the pending_changes views, since that read simulates a call that fails while the code is archived. The TTL policy now says that OpenZeppelin extends only an existing entry it reads, gives each network's minimum lifetime, drops the 30-day cap on what a caller pays, says why extend_ttl_with_limits is not used, and covers restoring data entries and Wasm, then simulating again. --- docs/kit-spec.md | 43 +++++++++++++++++++++++++++++---------- scripts/harness/extend.ts | 7 ++++--- 2 files changed, 36 insertions(+), 14 deletions(-) diff --git a/docs/kit-spec.md b/docs/kit-spec.md index 1180e993..58713eaa 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -259,17 +259,35 @@ instance and Wasm. **What OpenZeppelin extends.** `stellar-tokens` 0.7.2 stores the share token's balances and frozen amounts, and the compliance contract's modules and bound -tokens, and extends each to 30 days when it is read or written. `stellar-access` -0.7.2 stores every contract's roles and extends them to 90 days the same way. -Allowances are temporary entries that live until their `live_until_ledger`. -OpenZeppelin never extends an instance; the kit does, as above. The token's -total supply and metadata live in its instance. +tokens. `stellar-access` 0.7.2 stores every contract's roles. OpenZeppelin +extends an existing entry when it reads it: balances, frozen amounts, modules +and bound tokens to 30 days, roles to 90. It does not extend an entry when it +writes one, so a new entry starts at the network's minimum lifetime (see +**Network settings**) and waits for a later caller to read it. Allowances are +temporary entries that live until their `live_until_ledger`. OpenZeppelin +never extends an instance; the kit does, as above. The token's total supply +and metadata live in its instance. + +**Network settings.** Each network sets how long a new persistent entry lives, +and a restored entry comes back with the same lifetime: 120,960 ledgers (about +7 days) on testnet, 2,073,600 ledgers (about 120 days) on mainnet. Validators +can change these values by vote, so the kit does not hardcode them; check +[Stellar Lab's network limits](https://lab.stellar.org/network-limits) for +current values. **Who pays.** The account that sends a transaction pays the rent for every -extension that transaction triggers. One call extends each entry it touches to -at most 30 days, so no caller pays for more than 30 days of any entry. - -**Idle deployments.** A deployment nobody calls for 30 days archives. Run +extension that transaction triggers. A call pays only to bring an entry back +to its target once it has fallen to its threshold: about a day of rent when +the entry is read daily, up to a whole target (30 days, or 90 for a role) after +a long quiet period. Creating or restoring an entry pays for at least the +network's minimum lifetime, 120 days on mainnet. The kit does not cap each +extension with `extend_ttl_with_limits`: a cap would let rarely used entries +archive sooner, and each of those restores pays at least 120 days of rent on +mainnet. + +**Idle deployments.** An entry nobody reads archives when its lifetime runs +out: 30 days after it was last extended (90 for a role), or the network's +minimum lifetime after it was created, whichever is later. Run `STELLAR_ACCOUNT= npm run extend -w scripts/harness` after every deploy and before a long quiet period. It restores anything archived and extends every instance and its Wasm by 30 days. It reads the contracts @@ -280,8 +298,11 @@ Extending needs no authority, so any funded account can pay. **Restoring.** Since Protocol 23 an invocation can restore archived entries itself, so a call to an archived contract usually goes through once simulation adds the restore. Restoring many entries in one transaction can still hit -per-transaction limits; `stellar contract restore --id ` restores -one instance at a time. +per-transaction limits. Restore them first, in batches: each +`RestoreFootprintOp` transaction takes as many entries as the limits allow. +`stellar contract restore` sends one: `--id ` restores an instance, +`--id --key-xdr ` a data entry, and `--wasm-hash ` a +Wasm. After a restore, simulate the call again before sending it. **Production modules.** The compliance and identity contracts in this repository are testnet stubs. A production compliance module or identity diff --git a/scripts/harness/extend.ts b/scripts/harness/extend.ts index b8e27b73..32448458 100644 --- a/scripts/harness/extend.ts +++ b/scripts/harness/extend.ts @@ -118,6 +118,10 @@ async function main(): Promise { const wasmHash = await keepAlive(rpc, { id }, `${name} instance`, latest) if (wasmHash !== undefined) code.add(wasmHash) } + for (const wasmHash of code) { + await keepAlive(rpc, { wasmHash }, `code ${wasmHash.slice(0, 8)}`, latest) + } + // Reading a queue simulates a call, so the instance and its Wasm come first. for (const [name, id] of contracts) { if (!CONTRACTS_WITH_QUEUES.has(name)) continue for (const changeId of await openChangeIds(rpc, id)) { @@ -129,9 +133,6 @@ async function main(): Promise { ) } } - for (const wasmHash of code) { - await keepAlive(rpc, { wasmHash }, `code ${wasmHash.slice(0, 8)}`, latest) - } } main().catch((error: unknown) => { From 9516c958bb51de7ef965e80addc06cd19700405c Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:19:04 -0300 Subject: [PATCH 124/147] fix: extend the token instance on balance reads balance and total_supply now extend the share token's instance before they read, like every call that moves shares. A keeper's wind-down round, or a claim that burns nothing, only reads the token, so a quiet wind-down could otherwise let the token's instance archive. --- contracts/share-token/src/contract.rs | 29 ++++++++++++++++++++++++++- contracts/share-token/src/test.rs | 13 ++++++++++++ docs/kit-spec.md | 2 +- 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/contracts/share-token/src/contract.rs b/contracts/share-token/src/contract.rs index 09abb995..4090d945 100644 --- a/contracts/share-token/src/contract.rs +++ b/contracts/share-token/src/contract.rs @@ -148,7 +148,8 @@ impl Pausable for ShareToken { } /// OpenZeppelin leaves the instance's lifetime to the token, so every call -/// that moves shares extends it before handing over to the library. +/// that moves shares extends it before handing over to the library. So do +/// `balance` and `total_supply`, which may be the only calls during a wind-down. #[contractimpl(contracttrait)] impl FungibleToken for ShareToken { type ContractType = RWA; @@ -175,6 +176,32 @@ impl FungibleToken for ShareToken { live_until_ledger, ); } + + /// All shares in circulation, in share base units. Reading it extends + /// the token's instance. + /// + /// # Authorization + /// + /// None: anyone may call. + fn total_supply(e: &Env) -> i128 { + strata_storage::bump_instance(e); + ::total_supply(e) + } + + /// The shares `account` holds, in share base units. Reading it extends + /// the token's instance. + /// + /// # Arguments + /// + /// * `account` - The holder. + /// + /// # Authorization + /// + /// None: anyone may call. + fn balance(e: &Env, account: Address) -> i128 { + strata_storage::bump_instance(e); + ::balance(e, &account) + } } #[contractimpl(contracttrait)] diff --git a/contracts/share-token/src/test.rs b/contracts/share-token/src/test.rs index 52137224..8052833e 100644 --- a/contracts/share-token/src/test.rs +++ b/contracts/share-token/src/test.rs @@ -556,6 +556,19 @@ fn a_legal_transfer_restores_the_instance_ttl() { assert_eq!(instance_ttl(&e, &f.token.address), INSTANCE_EXTEND_AMOUNT); } +#[test] +fn reading_a_balance_or_the_supply_restores_the_instance_ttl() { + let e = Env::default(); + let (f, holder, _) = decayed_live_token(&e); + + f.token.balance(&holder); + assert_eq!(instance_ttl(&e, &f.token.address), INSTANCE_EXTEND_AMOUNT); + + age(&e, TWO_DAYS_IN_LEDGERS); + f.token.total_supply(); + assert_eq!(instance_ttl(&e, &f.token.address), INSTANCE_EXTEND_AMOUNT); +} + #[test] fn each_role_call_asks_its_holder_for_one_signature() { let e = Env::default(); diff --git a/docs/kit-spec.md b/docs/kit-spec.md index 5856c667..3a179287 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -328,7 +328,7 @@ how long a new entry lives before any extension (see **Network settings**). |---|---|---|---| | `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 29 days or less remain | | `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, each balance and freeze entry it writes, 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`, `legal_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 | +| `share_token` | Its instance, each balance and freeze entry it writes, 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`, `legal_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`; also the reads `balance` and `total_supply`, which may be the only token calls during a wind-down | Same | | `identity_verifier` | Its instance and each allowlist entry | `allow`, `is_allowed`, `verify_identity` | Same | | `compliance` | Its instance, and each pending governance change it reads or writes | Every hook the token calls (`can_create`, `can_transfer`, `created`, `transferred`, `destroyed`) and its admin calls | Same | | `async_vault`, `share_token`, `nav_oracle` | The four entries a grant writes: the account's role, its member-list slot, the member count and the role list | Each grant (in the constructor, `grant_role` and an applied `GrantRole` change) extends all four. After that, a role check extends only that account's role entry; the slot, the count and the list are extended only by grants and by calls that list role members (such as the vault's `manager()` or the token's `get_role_member_count`) | 90 days (1,555,200 ledgers), once 89 days or less remain | From 2dd3ad4bbcae0bcc79e0f7eb03c3fe2e156de804 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:35:08 -0300 Subject: [PATCH 125/147] docs(token): leave the authorization section out of balance and total_supply Both are read-only views, and a view's doc carries no authorization section, like the oracle's views. --- contracts/share-token/src/contract.rs | 8 -------- 1 file changed, 8 deletions(-) diff --git a/contracts/share-token/src/contract.rs b/contracts/share-token/src/contract.rs index 4090d945..6188531e 100644 --- a/contracts/share-token/src/contract.rs +++ b/contracts/share-token/src/contract.rs @@ -179,10 +179,6 @@ impl FungibleToken for ShareToken { /// All shares in circulation, in share base units. Reading it extends /// the token's instance. - /// - /// # Authorization - /// - /// None: anyone may call. fn total_supply(e: &Env) -> i128 { strata_storage::bump_instance(e); ::total_supply(e) @@ -194,10 +190,6 @@ impl FungibleToken for ShareToken { /// # Arguments /// /// * `account` - The holder. - /// - /// # Authorization - /// - /// None: anyone may call. fn balance(e: &Env, account: Address) -> i128 { strata_storage::bump_instance(e); ::balance(e, &account) From 3d20c032da7f1c1e4ba2e9cbe333849ab2def9fc Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:12:55 -0300 Subject: [PATCH 126/147] docs(vault): say deposited capital counts money in and out, not value The kit spec and the design doc said a gain or a loss at the custodian never moves deposited capital. It does once exits pay it out: a priced redemption takes off what it pays, gain included. After leavers are paid a gain the figure can go negative, and the cap then admits more than its own amount; after a loss it stays above the vault's value, and the cap refuses deposits sooner. Two tests pin both directions. --- contracts/async-vault/src/test/deposit_cap.rs | 41 +++++++++++++++++++ docs/kit-spec.md | 2 +- docs/strata-product-and-architecture.md | 8 +++- 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/contracts/async-vault/src/test/deposit_cap.rs b/contracts/async-vault/src/test/deposit_cap.rs index 5d551cad..7f836314 100644 --- a/contracts/async-vault/src/test/deposit_cap.rs +++ b/contracts/async-vault/src/test/deposit_cap.rs @@ -215,3 +215,44 @@ fn a_plain_transfer_back_counts_twice_toward_the_cap() { VaultError::DepositCapExceeded, ); } + +#[test] +fn a_gain_paid_out_to_leavers_leaves_room_above_the_cap() { + let f = setup(); + let investor = capital_deployed_under_a_cap(&f); + f.vault.claim_deposit(&investor, &1); + StellarAssetClient::new(&f.e, &f.asset).mint(&f.custodian, &1_000); + f.vault.fund(&f.custodian, &2_000); + let exit = f.vault.request_redeem(&investor, &1_000); + f.fulfill_epoch(wad(2)); + f.vault.claim_redeem(&investor, &exit); + + assert_eq!(f.vault.net_deployed(), -1_000); + assert_eq!(f.balance(&f.vault.address), 0); + let newcomer = f.investor(3_000); + f.vault.request_deposit(&newcomer, &2_500); + refused( + f.vault.try_request_deposit(&newcomer, &1), + VaultError::DepositCapExceeded, + ); +} + +#[test] +fn a_loss_at_the_custodian_still_counts_toward_the_cap() { + let f = setup(); + let investor = capital_deployed_under_a_cap(&f); + f.vault.claim_deposit(&investor, &1); + f.vault.fund(&f.custodian, &500); + let exit = f.vault.request_redeem(&investor, &1_000); + f.fulfill_epoch(wad(1) / 2); + f.vault.claim_redeem(&investor, &exit); + + assert_eq!(f.vault.net_deployed(), 500); + assert_eq!(f.balance(&f.vault.address), 0); + let newcomer = f.investor(1_500); + f.vault.request_deposit(&newcomer, &1_000); + refused( + f.vault.try_request_deposit(&newcomer, &1), + VaultError::DepositCapExceeded, + ); +} diff --git a/docs/kit-spec.md b/docs/kit-spec.md index 2c6bcdd3..4e174a3f 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -190,7 +190,7 @@ The `async_vault` constructor refuses these four pairs of equal addresses and co ### Supply and Capital Figures - `total_economic_supply()` counts share base units held outside the vault plus those priced for deposits and not yet minted: the token's `total_supply()`, less the vault's own share balance, plus `pending_mint_shares()`. It leaves out every share in the vault's escrow. That includes the shares of a redemption that is not priced yet: the next price still applies to them, and a cancellation can return them (`economic_supply_excludes_shares_locked_in_redemption_escrow`). The first wind-down round's supply snapshot counts those shares, so the two figures differ while redemptions wait to be priced. -- Deposited capital, which the deposit cap limits, is the asset the vault holds plus `net_deployed()`, less `committed()`. It counts asset units at cost: a gain or a loss at the custodian never moves it, even once funded back. +- Deposited capital, which the deposit cap limits, is the asset the vault holds plus `net_deployed()`, less `committed()`. It counts money in and out, not the vault's value (what the vault and the custodian hold): a deposit request adds what it brings, and a priced redemption takes off what it pays. A gain or a loss at the custodian does not move it, even once funded back. After a gain, the figure is below the vault's value. Once leavers are paid the gain, it can go negative, and the cap then admits more than its own amount (`a_gain_paid_out_to_leavers_leaves_room_above_the_cap`). After a loss, the figure stays above the vault's value, and the cap refuses deposits sooner (`a_loss_at_the_custodian_still_counts_toward_the_cap`). - `net_deployed()` is what `deploy_to_custodian` sent less what `fund` brought back; it goes negative once more came back than went out. The custodian repays through `fund`. A plain transfer to the vault raises the vault's balance but leaves `net_deployed` as it was, so from then on the same money counts twice in deposited capital and the cap refuses deposits sooner. --- diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index d4a4ce5f..d07a8ac3 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -380,8 +380,12 @@ The vault also reports `total_economic_supply`: shares held outside the vault plus shares priced for deposits and not yet minted. It leaves out the shares escrowed by a redemption that is not priced yet, although the next price still applies to them. The deposit cap limits deposited capital: what the vault holds -plus `net_deployed` (sent to the custodian less funded back), less `committed`, -in asset units at cost, so a gain or a loss at the custodian never moves it. +plus `net_deployed` (sent to the custodian less funded back), less `committed`. +It counts money in and out, not the vault's value. A deposit request adds what +it brings, and a priced redemption takes off what it pays. A gain or a loss at +the custodian does not move it. Once leavers are paid a gain, the figure can go +negative, and the cap then admits more than its own amount. After a loss it +stays above the vault's value, and the cap refuses deposits sooner. **Attestation guardrails:** the reporter is a multisig, never a single key. Attestations are ordered by their acceptance time on the ledger. The price must From 1afea905f0fc2efa1f8d83b70fd3dafc6be416ba Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:51:10 -0300 Subject: [PATCH 127/147] fix(compliance): extend new entries when created OpenZeppelin extends a module list or a bound-token entry when it reads it, not when it creates it, so a new one started at the network's minimum lifetime. bind_token, add_module_to and the applied BindToken and AddModule changes now read back what they wrote through OpenZeppelin's own reads, so each new entry reaches its target in the transaction that creates it. --- contracts/compliance/src/extend.rs | 13 ++ contracts/compliance/src/governance.rs | 9 +- contracts/compliance/src/lib.rs | 5 +- contracts/compliance/src/test.rs | 1 + contracts/compliance/src/test/ttl.rs | 157 +++++++++++++++++++++++++ 5 files changed, 182 insertions(+), 3 deletions(-) create mode 100644 contracts/compliance/src/extend.rs create mode 100644 contracts/compliance/src/test/ttl.rs diff --git a/contracts/compliance/src/extend.rs b/contracts/compliance/src/extend.rs new file mode 100644 index 00000000..c12778d7 --- /dev/null +++ b/contracts/compliance/src/extend.rs @@ -0,0 +1,13 @@ +//! OpenZeppelin extends an entry when it reads it, not when it creates it. + +use soroban_sdk::Env; +use stellar_tokens::rwa::{compliance, compliance::ComplianceHook, utils::token_binder}; + +/// Reads the token count and every bucket, the one a binding wrote included. +pub(crate) fn bound_tokens(e: &Env) { + token_binder::linked_tokens(e); +} + +pub(crate) fn modules(e: &Env, hook: &ComplianceHook) { + compliance::storage::get_modules_for_hook(e, hook.clone()); +} diff --git a/contracts/compliance/src/governance.rs b/contracts/compliance/src/governance.rs index b158b049..85e48f70 100644 --- a/contracts/compliance/src/governance.rs +++ b/contracts/compliance/src/governance.rs @@ -6,6 +6,7 @@ use stellar_access::{ use stellar_tokens::rwa::{compliance, compliance::ComplianceHook, utils::token_binder}; use strata_timelock::{queue, setup, Clock, Refusal, TimelockHost}; +use crate::extend; use crate::guards; use crate::{ ChangeApplied, ChangeCancelled, ChangeProposed, Compliance, ComplianceContractError, @@ -103,12 +104,16 @@ pub(crate) fn apply(e: &Env, id: u32) { queue::take_ready::(e, id).unwrap_or_else(|r| refuse(e, r)); match &change { ComplianceChange::AddModule(hook, module) => { - compliance::storage::add_module_to(e, hook.clone(), module.clone()) + compliance::storage::add_module_to(e, hook.clone(), module.clone()); + extend::modules(e, hook); } ComplianceChange::RemoveModule(hook, module) => { compliance::storage::remove_module_from(e, hook.clone(), module.clone()) } - ComplianceChange::BindToken(token) => token_binder::bind_token(e, token), + ComplianceChange::BindToken(token) => { + token_binder::bind_token(e, token); + extend::bound_tokens(e); + } ComplianceChange::UnbindToken(token) => token_binder::unbind_token(e, token), ComplianceChange::Admin(next) => offer_admin(e, next), } diff --git a/contracts/compliance/src/lib.rs b/contracts/compliance/src/lib.rs index a56add4e..36fe8d6c 100644 --- a/contracts/compliance/src/lib.rs +++ b/contracts/compliance/src/lib.rs @@ -21,6 +21,7 @@ #![no_std] +mod extend; mod governance; mod guards; @@ -365,6 +366,7 @@ impl token_binder::TokenBinder for Compliance { guards::direct_change_allowed(e); strata_storage::bump_instance(e); token_binder::bind_token(e, &token); + extend::bound_tokens(e); } /// Unlinks `token`, after which its moves fail the hooks. Instant during @@ -426,7 +428,8 @@ impl compliance::Compliance for Compliance { fn add_module_to(e: &Env, hook: ComplianceHook, module: Address, _operator: Address) { guards::direct_change_allowed(e); strata_storage::bump_instance(e); - compliance::storage::add_module_to(e, hook, module); + compliance::storage::add_module_to(e, hook.clone(), module); + extend::modules(e, &hook); } /// Removes `module` from `hook`. Instant during setup; after it, propose a diff --git a/contracts/compliance/src/test.rs b/contracts/compliance/src/test.rs index 35d5d75d..12ce17b8 100644 --- a/contracts/compliance/src/test.rs +++ b/contracts/compliance/src/test.rs @@ -1,5 +1,6 @@ mod changes; mod guards; +mod ttl; use soroban_sdk::{ testutils::{storage::Instance as _, Address as _, Ledger as _}, diff --git a/contracts/compliance/src/test/ttl.rs b/contracts/compliance/src/test/ttl.rs new file mode 100644 index 00000000..704d73d2 --- /dev/null +++ b/contracts/compliance/src/test/ttl.rs @@ -0,0 +1,157 @@ +//! A new bound-token or module entry gets its full TTL in the call that creates it. + +use soroban_sdk::{ + contract, contractimpl, contracttype, + testutils::{storage::Persistent as _, Address as _, Ledger as _}, + Address, Env, IntoVal, Val, +}; +use stellar_tokens::rwa::{ + compliance::{storage::ComplianceDataKey, ComplianceHook, COMPLIANCE_EXTEND_AMOUNT}, + utils::token_binder::{BUCKET_SIZE, TOKEN_BINDER_EXTEND_AMOUNT}, +}; +use strata_timelock::{Clock, MIN_DELAY}; + +use crate::{Compliance, ComplianceChange, ComplianceClient}; + +/// Testnet's minimum lifetime of a new persistent entry, in ledgers. +const TESTNET_MIN_PERSISTENT_TTL: u32 = 120_960; + +/// Plays the vault: its clock lets a timelocked change land. +#[contract] +struct VaultStub; + +#[contractimpl(contracttrait)] +impl strata_timelock::ClockSource for VaultStub { + fn timelock_clock(_e: &Env) -> Clock { + Clock { + delay: MIN_DELAY, + exit_window: 0, + paused_secs: 0, + paused: false, + winding_down: false, + } + } +} + +/// Mirrors OpenZeppelin's private token-binder key: same variants, same encoding. +#[contracttype] +enum TokenBinderStorageKey { + TokenBucket(u32), + TotalCount, +} + +struct Fixture<'a> { + e: Env, + rules: ComplianceClient<'a>, + admin: Address, +} + +impl Fixture<'_> { + fn persistent_ttl>(&self, key: &K) -> u32 { + self.e.as_contract(&self.rules.address, || { + self.e.storage().persistent().get_ttl(key) + }) + } + + /// Every entry a first binding writes: the token count and the first bucket. + fn bound_token_ttls(&self) -> [u32; 2] { + [ + self.persistent_ttl(&TokenBinderStorageKey::TotalCount), + self.persistent_ttl(&TokenBinderStorageKey::TokenBucket(0)), + ] + } + + fn modules_ttl(&self, hook: ComplianceHook) -> u32 { + self.persistent_ttl(&ComplianceDataKey::HookModules(hook)) + } + + fn land(&self, change: &ComplianceChange) { + let id = self.rules.propose_change(change); + self.e.ledger().with_mut(|l| { + l.timestamp += MIN_DELAY; + l.sequence_number += (MIN_DELAY / 5) as u32; + }); + self.rules.apply_change(&id); + } +} + +/// A compliance contract in setup on a ledger with testnet's minimum lifetime. +fn setup<'a>() -> Fixture<'a> { + let e = Env::default(); + e.mock_all_auths(); + e.ledger() + .set_min_persistent_entry_ttl(TESTNET_MIN_PERSISTENT_TTL); + let admin = Address::generate(&e); + let rules = ComplianceClient::new(&e, &e.register(Compliance, (admin.clone(),))); + Fixture { e, rules, admin } +} + +fn finished<'a>() -> Fixture<'a> { + let f = setup(); + f.rules.bind_vault(&f.e.register(VaultStub, ())); + f.rules.finish_setup(); + f +} + +#[test] +fn a_binding_during_setup_writes_the_bound_tokens_with_the_full_ttl() { + let f = setup(); + + f.rules.bind_token(&Address::generate(&f.e), &f.admin); + + assert_eq!(f.bound_token_ttls(), [TOKEN_BINDER_EXTEND_AMOUNT; 2]); +} + +#[test] +fn a_binding_through_the_timelock_writes_the_bound_tokens_with_the_full_ttl() { + let f = finished(); + + f.land(&ComplianceChange::BindToken(Address::generate(&f.e))); + + assert_eq!(f.bound_token_ttls(), [TOKEN_BINDER_EXTEND_AMOUNT; 2]); +} + +#[test] +fn the_binding_that_opens_a_second_bucket_writes_it_with_the_full_ttl() { + let f = setup(); + + for _ in 0..=BUCKET_SIZE { + f.rules.bind_token(&Address::generate(&f.e), &f.admin); + } + + assert_eq!( + f.persistent_ttl(&TokenBinderStorageKey::TokenBucket(1)), + TOKEN_BINDER_EXTEND_AMOUNT + ); +} + +#[test] +fn a_module_added_during_setup_writes_the_hooks_modules_with_the_full_ttl() { + let f = setup(); + + f.rules.add_module_to( + &ComplianceHook::CanTransfer, + &Address::generate(&f.e), + &f.admin, + ); + + assert_eq!( + f.modules_ttl(ComplianceHook::CanTransfer), + COMPLIANCE_EXTEND_AMOUNT + ); +} + +#[test] +fn a_module_added_through_the_timelock_writes_the_hooks_modules_with_the_full_ttl() { + let f = finished(); + + f.land(&ComplianceChange::AddModule( + ComplianceHook::Created, + Address::generate(&f.e), + )); + + assert_eq!( + f.modules_ttl(ComplianceHook::Created), + COMPLIANCE_EXTEND_AMOUNT + ); +} From d08b3c6fa73445fdc345cf7bd863bc50c5169a28 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:51:37 -0300 Subject: [PATCH 128/147] docs: drop the compliance stub's ttl exception The stub now reads back its modules and bound tokens when it writes them. The kit spec lists those read-backs instead of naming the stub as the one exception, and the testing guide lists the new tests. --- docs/TESTING.md | 18 ++++++++++-------- docs/kit-spec.md | 32 +++++++++++++++----------------- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/docs/TESTING.md b/docs/TESTING.md index b96c9255..a3dfe540 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -55,6 +55,7 @@ contract extends, who pays, and how to restore an archived one. | 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` | | Every call that writes a share balance or a freeze gives a new one its full TTL, under testnet's minimum lifetime; so do a deposit claim and the first redemption, through the vault | [`contracts/share-token/src/test_ttl.rs`](../contracts/share-token/src/test_ttl.rs), [`contracts/async-vault/src/test/ttl.rs`](../contracts/async-vault/src/test/ttl.rs) | `a_mint_writes_the_new_balance_with_the_full_ttl`, `an_escrow_writes_the_receiving_balance_with_the_full_ttl`, `a_recovery_writes_the_new_balance_with_the_full_ttl`, `sending_nothing_from_an_empty_account_writes_its_balance_with_the_full_ttl`, `a_partial_freeze_writes_its_entry_with_the_full_ttl`, `the_first_redemption_writes_the_vaults_share_balance_with_the_full_ttl` | | Every role grant in the vault, the token and the oracle gives the role's entries their full TTL: in the constructor, during setup and through the timelock | [`contracts/share-token/src/test_ttl.rs`](../contracts/share-token/src/test_ttl.rs), [`contracts/async-vault/src/test/ttl.rs`](../contracts/async-vault/src/test/ttl.rs), [`contracts/nav-oracle/src/test_governance.rs`](../contracts/nav-oracle/src/test_governance.rs) | `the_constructor_writes_each_role_with_the_full_ttl`, `a_grant_during_setup_writes_the_role_with_the_full_ttl`, `a_grant_through_the_timelock_writes_the_role_with_the_full_ttl` | +| Binding a token or adding a module gives each new compliance entry its full TTL, under testnet's minimum lifetime: during setup and through the timelock | [`contracts/compliance/src/test/ttl.rs`](../contracts/compliance/src/test/ttl.rs) | `a_binding_during_setup_writes_the_bound_tokens_with_the_full_ttl`, `a_binding_through_the_timelock_writes_the_bound_tokens_with_the_full_ttl`, `the_binding_that_opens_a_second_bucket_writes_it_with_the_full_ttl`, `a_module_added_during_setup_writes_the_hooks_modules_with_the_full_ttl`, `a_module_added_through_the_timelock_writes_the_hooks_modules_with_the_full_ttl` | | Under mainnet's longer minimum a new entry starts at that minimum; a balance is first renewed on day 91, a role on day 31 | [`contracts/share-token/src/test_ttl.rs`](../contracts/share-token/src/test_ttl.rs) | `under_mainnets_minimum_a_new_balance_is_first_renewed_on_day_91`, `under_mainnets_minimum_a_new_role_is_first_renewed_on_day_31` | | 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` | @@ -62,14 +63,15 @@ The test `Env` does not archive an entry whose TTL runs out; it still reads it. So these tests check the TTL numbers the contracts set, not what the network does once an entry is archived. -The share token's balances and freezes, and the roles of the vault, the token -and the oracle, are stored by the OpenZeppelin Stellar library. It extends an -entry when it reads it, not when it creates it, so the contracts read back each -one they create. Without that, a new entry would start at the network's minimum -lifetime. The tests for it set the ledger's minimum to testnet's 120,960 -ledgers; the test `Env` defaults to 4,096. OpenZeppelin never extends the -instance, so the token, the identity verifier and the compliance contract -extend their own on every call that does work. +The share token's balances and freezes, the compliance stub's modules and bound +tokens, and the roles of the vault, the token and the oracle, are stored by the +OpenZeppelin Stellar library. It extends an entry when it reads it, not when it +creates it, so the contracts read back each one they create. Without that, a +new entry would start at the network's minimum lifetime. The tests for it set +the ledger's minimum to testnet's 120,960 ledgers; the test `Env` defaults to +4,096. OpenZeppelin never extends the instance, so the token, the identity +verifier and the compliance contract extend their own on every call that does +work. ## Authorization diff --git a/docs/kit-spec.md b/docs/kit-spec.md index 4e174a3f..c65992fc 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -339,9 +339,8 @@ threshold. The kit's storage helpers ask on every read and every write of the kit's own entries. OpenZeppelin asks when it reads an entry that already exists, never when it writes one, so the kit reads back each entry it creates through OpenZeppelin. The transaction that creates an entry therefore extends -it to its target at once; the compliance stub's modules and bound tokens are -the one exception (see **What OpenZeppelin extends**). The network decides only -how long a new entry lives before any extension (see **Network settings**). +it to its target at once. The network decides only how long a new entry lives +before any extension (see **Network settings**). | Contract | What it extends | When | To how long | |---|---|---|---| @@ -349,7 +348,7 @@ how long a new entry lives before any extension (see **Network settings**). | `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, each balance and freeze entry it writes, 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`, `legal_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`; also the reads `balance` and `total_supply`, which may be the only token calls during a wind-down | Same | | `identity_verifier` | Its instance and each allowlist entry | `allow`, `is_allowed`, `verify_identity` | Same | -| `compliance` | Its instance, and each pending governance change it reads or writes | Every hook the token calls (`can_create`, `can_transfer`, `created`, `transferred`, `destroyed`) and its admin calls | Same | +| `compliance` | Its instance, the modules and bound tokens it writes, and each pending governance change it reads or writes | Every hook the token calls (`can_create`, `can_transfer`, `created`, `transferred`, `destroyed`) and its admin calls | Same | | `async_vault`, `share_token`, `nav_oracle` | The four entries a grant writes: the account's role, its member-list slot, the member count and the role list | Each grant (in the constructor, `grant_role` and an applied `GrantRole` change) extends all four. After that, a role check extends only that account's role entry; the slot, the count and the list are extended only by grants and by calls that list role members (such as the vault's `manager()` or the token's `get_role_member_count`) | 90 days (1,555,200 ledgers), once 89 days or less remain | The kit's own lengths are the constants in `crates/strata-storage` @@ -386,19 +385,18 @@ own reads, in the same call: - the vault, the token and the oracle read back every entry a grant writes (the account's role, its place in the member list, the member count and the list of roles) after each grant in their constructors, in `grant_role`, and in an - applied `GrantRole` change. - -So a new balance, freeze or role entry reaches its target in the transaction -that creates it, and the kit keeps OpenZeppelin's thresholds without copying -them. A revoke creates nothing: it rewrites the member count and moves the last -member into the freed slot with plain writes, and extends none of the entries -it moves. The compliance stub does not read back its modules and bound tokens -when it writes them. The vault's `finish_setup` reads the bound tokens, which -extends them, and every hook the token calls reads its modules; the hooks after -a mint, transfer or burn read the bound tokens too. Allowances are temporary -entries that live until their `live_until_ledger`. OpenZeppelin never extends -an instance; the kit does, as above. The token's total supply and metadata live -in its instance. + applied `GrantRole` change; +- the compliance stub reads back the hook's modules after `add_module_to` and + an applied `AddModule` change, and the bound tokens after `bind_token` and an + applied `BindToken` change. + +So a new balance, freeze, role, module or bound-token entry reaches its target +in the transaction that creates it, and the kit keeps OpenZeppelin's thresholds +without copying them. A revoke creates nothing: it rewrites the member count and +moves the last member into the freed slot with plain writes, and extends none +of the entries it moves. Allowances are temporary entries that live until their +`live_until_ledger`. OpenZeppelin never extends an instance; the kit does, as +above. The token's total supply and metadata live in its instance. **Network settings.** Each network sets how long a new persistent entry lives and how far one extension can reach. Validators can change these settings by From 868e4589380008026258808de49885d18c4af27c Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:13:30 -0300 Subject: [PATCH 129/147] docs: answer the review notes on the approved stack - The NotAllowlisted doc and the compliance matrix say that a verifier that cannot answer counts as a failed check, and a test pins it. - The kit spec says this version is deployed fresh, not upgraded in place. - The design doc gives the epoch remainder rule in the pricing section, and says the share token's upgrades, role grants and admin hand-overs wait for the vault's timelock. --- contracts/async-vault/src/error.rs | 4 +++- contracts/async-vault/src/test/compliance.rs | 19 +++++++++++++++++++ docs/COMPLIANCE_MATRIX.md | 2 +- docs/kit-spec.md | 2 +- docs/strata-product-and-architecture.md | 6 +++++- 5 files changed, 29 insertions(+), 4 deletions(-) diff --git a/contracts/async-vault/src/error.rs b/contracts/async-vault/src/error.rs index c20c3903..8f22f895 100644 --- a/contracts/async-vault/src/error.rs +++ b/contracts/async-vault/src/error.rs @@ -139,7 +139,9 @@ strata_bindings::error_enum! { /// shares outstanding, so setup cannot finish. SetupIncomplete = 6067, /// The holder fails the share token's identity check, so the vault takes no - /// deposit request from them and returns no escrowed shares to them. + /// deposit request from them and returns no escrowed shares to them. A + /// verifier that cannot answer, such as a broken or archived one, counts + /// as a failed check. NotAllowlisted = 6068, /// The wind-down delay is below the shortest the vault accepts. WindDownDelayTooShort = 6069, diff --git a/contracts/async-vault/src/test/compliance.rs b/contracts/async-vault/src/test/compliance.rs index 92c66fc5..d7bb16e6 100644 --- a/contracts/async-vault/src/test/compliance.rs +++ b/contracts/async-vault/src/test/compliance.rs @@ -146,6 +146,25 @@ fn an_unbound_compliance_swap_lands_and_stops_every_move() { assert!(!f.vault.health().compliance_bound); } +#[test] +fn a_verifier_that_cannot_answer_counts_as_a_failed_check() { + let f = setup(); + let investor = f.investor(1_000); + + let silent = Address::generate(&f.e); + let id = f + .share + .propose_change(&share_token::TokenChange::IdentityVerifier(silent.clone())); + f.advance(MIN_UPGRADE_DELAY); + f.share.apply_change(&id); + assert_eq!(f.share.identity_verifier(), silent); + + refused( + f.vault.try_request_deposit(&investor, &100), + VaultError::NotAllowlisted, + ); +} + #[test] fn a_delisted_holder_can_still_queue_an_exit() { let f = setup(); diff --git a/docs/COMPLIANCE_MATRIX.md b/docs/COMPLIANCE_MATRIX.md index ffa5d39a..3131cd90 100644 --- a/docs/COMPLIANCE_MATRIX.md +++ b/docs/COMPLIANCE_MATRIX.md @@ -31,7 +31,7 @@ Every investor account in the vault ecosystem exists in one of three primary com | Operation | Allowlisted | De-listed (Non-Frozen) | Frozen (Address or Partial) | Contract Mechanics & Regulatory Rationale | |---|---|---|---|---| -| **`request_deposit`** | ✅ Allowed | ❌ **Refused** | ❌ **Refused** | Before any cash moves, the vault asks the share token whether the address is frozen and then asks the token's identity verifier. It refuses a frozen address (`Frozen`) and an address the token would not let hold shares (`NotAllowlisted`). A partial freeze is not checked. | +| **`request_deposit`** | ✅ Allowed | ❌ **Refused** | ❌ **Refused** | Before any cash moves, the vault asks the share token whether the address is frozen and then asks the token's identity verifier. It refuses a frozen address (`Frozen`) and an address the token would not let hold shares (`NotAllowlisted`); a verifier that cannot answer counts as a refusal. A partial freeze is not checked. | | **`cancel_deposit`** | ✅ Allowed | ✅ **Allowed** | ❌ **Refused** (`Frozen`); the escrow stays | **Escape Hatch**: Calls `refund_deposit`. Escrowed settlement asset (USDC) is refunded directly to `holder`. The vault checks `is_frozen` on the share token before it refunds anything; once past that, cash refunds bypass share token compliance checks. A partial freeze is not checked. | | **`claim_deposit`** | ✅ Allowed | ↪ **Moved to the exit queue** | ⛔ **Held** | A receiver off the allowlist gets no shares: the vault mints them into its own escrow and adds them to the holder's redemption in the open epoch (`DepositConvertedToExit`), paid in cash at that epoch's price. In a wind-down they join the holder's wind-down entitlement. A compliance module that refuses the mint (`can_create`) still blocks the claim. A receiver whose address is frozen, listed or not, is refused with `Frozen` (6071) and keeps the priced deposit until the freeze is lifted. A partial freeze is not checked. | | **`request_redeem`** | ✅ Allowed | ✅ **Allowed** | ❌ **Refused** (`Frozen`); a partial freeze caps the request to the free balance | **Exit Right**: the vault checks `is_frozen` and `get_frozen_tokens` on the share token before it escrows anything with `RWATokenClient::forced_transfer`. A de-listed, non-frozen holder can still start an exit; a frozen one cannot, and a partial freeze caps the request to the shares `get_frozen_tokens` leaves free. | diff --git a/docs/kit-spec.md b/docs/kit-spec.md index 4e174a3f..1bedb91c 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -79,7 +79,7 @@ The contracts enforce a set of mathematical and state-machine invariants across - **A request that can never settle** keeps its epoch open, and the two cases leave different amounts behind. A zero-asset redemption whose holder may not take the shares back (`HolderNotEligible`, `a_request_that_cannot_settle_keeps_its_epoch_open`) leaves only the epoch's ordinary rounding remainder in `committed` — fewer units than the epoch had redemption requests — because that request was never owed any cash. A priced deposit whose mint a compliance module refuses (`a_refused_mint_keeps_a_priced_deposit_epoch_open`) is not rounding dust: the mint reverts, so that deposit's whole share entitlement stays in `pending_mint_shares`, on top of the epoch's own remainder, until the mint is allowed. Either way it is still owed to nobody. - **A deposit worth less than one share base unit** mints nothing and is refunded at claim, from the free reserve (`a_deposit_below_one_share_is_refunded_at_claim`). Pricing moves the epoch's whole deposit into the reserve and records no refund, so `deploy_to_custodian` or a wind-down round can spend that cash first; the claim then fails with `ClaimNotCovered` until the free reserve covers it again (`a_dust_refund_never_takes_committed_money`, `a_zero_share_refund_is_refused_once_a_round_has_taken_the_reserve`). This is accepted: pricing sees only the epoch's totals, so reserving these refunds would mean visiting every request, and each is worth less than one share base unit. -**Settlement and upgrades.** The settlement record counts a request only from the moment it exists. A vault upgraded in place to this version while any request is open would under-count its open epochs: claims in epochs priced before the upgrade would revert, and a mixed epoch could reconcile early. This version must therefore be deployed fresh, or reached by upgrade only once no request is open anywhere in the vault. +**Settlement and upgrades.** The settlement record counts a request only from the moment it exists. A vault upgraded in place to this version while any request is open would under-count its open epochs: claims in epochs priced before the upgrade would revert, and a mixed epoch could reconcile early. This version is therefore deployed fresh: an earlier vault is not upgraded to it in place. ### Invariant 8: Bounded Governance Timelocks - **Definition**: Upgrades and wind-downs cannot execute without advance notice, and delays cannot be set to invalid boundaries. diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index d07a8ac3..5866d20e 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -287,7 +287,7 @@ number. | 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 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 (set only by the admin; governance by deploy) | Manager role held only by the vault, for mint, burn and escrow; compliance role held by the compliance authority, for freezes, plus legal transfers and recovery co-signed by the token admin; swapping the compliance or identity-verifier contract needs the admin and, after a one-time setup, waits for the vault's timelock | +| **Share token** | OZ SEP-41 + SEP-57 RWA extensions: freeze, forced transfer, recovery, identity and compliance checks on every transfer, independent transfer pause (set only by the admin; governance by deploy) | Manager role held only by the vault, for mint, burn and escrow; compliance role held by the compliance authority, for freezes, plus legal transfers and recovery co-signed by the token admin; swapping the compliance or identity-verifier contract needs the admin and, after a one-time setup, waits for the vault's timelock, as do the token's upgrades, role grants and admin hand-overs | | **Token roles** | Access-control roles on the share token, checked directly via native access control: `manager` mints, burns and escrows; `compliance` freezes, and makes legal transfers and recoveries co-signed by the token admin | 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 | The compliance authority writes the allowlist on the identity verifier. Governance runs the compliance contract as its admin: adding or removing a module and binding or unbinding a token are instant only during its one-time setup, and after it wait for the vault's timelock, cannot land while the vault is paused and never land during a wind-down. Replaceable by OZ's identity verifier and compliance contracts (with RWA Wizard modules); after setup, the token's swap to them waits for the vault's timelock | | **Authorities** | governance (parameters, roles, timelocked upgrades), compliance (allowlist, token interventions), attestation (valuation only), treasury (reserve movements only), guardian (pause; never payable claims) | Each contract grants its own roles; the oracle's, share token's and verifier's admins and the oracle's attester and guardian are constructor parameters independent of the vault's roles, equal to governance, compliance and guardian by deploy only; treasury and guardian distinct, treasury and governance distinct, compliance distinct from governance and treasury; governance and guardian may coincide; the vault's manager role is unconstrained | @@ -306,6 +306,10 @@ it can be priced, and every request in the epoch waits the same. Pricing it read the oracle and fixes one share price for every request it holds. Pricing is permissionless and refuses a feed that is not valid, so a sealed epoch waits rather than settling at a stale price, and no investor can choose their price. +Claims round down one by one, so together they never pay more than pricing +promised. When an epoch's last request settles, the remainder leaves +`pending_mint_shares` and `committed` and stays in the reserve as fund money +(`EpochReconciled` says how much). Sealing is gated on the manager role today. It is meant to become permissionless once a minimum epoch duration bounds it; until then, whoever seals chooses the From 213bbb4af291c06f0aad6eab053f873472d81bd3 Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Thu, 22 Oct 2026 10:46:47 -0300 Subject: [PATCH 130/147] ci: run the Wasm upgrade tests --- .github/workflows/build.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4e8483d6..85f0a814 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -74,6 +74,16 @@ jobs: if ! command -v stellar &> /dev/null; then cargo binstall --locked stellar-cli@27.0.0 fi + # The upgrade tests run built Wasm, so they need the fixture, both + # contracts and their probe builds first (docs/TESTING.md). + - name: Run the Wasm upgrade tests + run: | + 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 + 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 --locked -p async-vault -p nav-oracle --features upgrade_wasm with_wasm - name: Start Stellar local network # No tagged releases upstream, so this pins main as read on 2026-08-14 uses: stellar/quickstart@864f91d804c0061bd11ef8872d3e318581febb55 From a9453f1aed3deb53841a74c2e533e8fa7c3b1cb5 Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Thu, 22 Oct 2026 10:53:55 -0300 Subject: [PATCH 131/147] ci: pin upload-artifact to a commit --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 85f0a814..c58decbb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -158,7 +158,7 @@ jobs: HARNESS_NETWORK: local - name: Keep the harness report when it fails if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: harness-runs path: scripts/harness/runs/ From 8903568a59cd66ecd4cdbe99ea7b961208d8c972 Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Thu, 22 Oct 2026 10:58:05 -0300 Subject: [PATCH 132/147] ci: pin the quickstart image --- .github/workflows/build.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c58decbb..7689bc93 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -89,6 +89,8 @@ jobs: uses: stellar/quickstart@864f91d804c0061bd11ef8872d3e318581febb55 with: enable: "core,rpc" + # Pinned image: "latest" tracks new protocol builds. + tag: v678-b1525.1-latest - name: Add master account as default identity run: | mkdir -p .config/stellar/identity From c11e1485ff5328a6d07df05cdb52c6ce2504dfd3 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:46:07 -0300 Subject: [PATCH 133/147] docs: say what holders can do if the manager or the attester stops Also say the deposit cap is vault-wide, and list the testnet identity verifier's limits as limits of the stub. --- docs/kit-spec.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/kit-spec.md b/docs/kit-spec.md index 1bedb91c..44ada178 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -159,7 +159,7 @@ The `async_vault` constructor refuses these four pairs of equal addresses and co |---|---|---|---| | **Standing Notice** | `set_notice(secs: u64)`; after setup, lowering goes through `propose_change(Notice(secs))` → `apply_change(id)` | Governance | Notice wait before pricing. Default: `0`. Must satisfy $\text{secs} \le \text{upgrade\_delay}$ and $\text{secs} \le 30 \text{ days}$ (`MAX_NOTICE_SECS`), checked again when a `Notice` change applies. Raising is instant. | | **Custodian Address** | `set_custodian(custodian: Address)` during setup; afterwards `propose_change(Custodian(address))` → `apply_change(id)` | Governance | Recipient address for off-chain capital deployment. Required before `finish_setup` and `deploy_to_custodian`. Never the vault itself (`InvalidCustodian`), checked when it is set, proposed and applied. The deploy sets it before setup closes. | -| **Deposit Cap** | `set_deposit_cap(cap: Option)` | Governance | Maximum capacity for deposited capital (see **Supply and Capital Figures**). `None` = unbounded. Refuses negative cap. Instant both ways, also after setup. | +| **Deposit Cap** | `set_deposit_cap(cap: Option)` | Governance | Maximum capacity for deposited capital (see **Supply and Capital Figures**). `None` = unbounded. Refuses negative cap. Instant both ways, also after setup. The cap is vault-wide; there is no cap per investor. | | **Wind-Down Delay** | `set_wind_down_delay(secs: u64)`; after setup, lowering goes through `propose_change(WindDownDelay(secs))` → `apply_change(id)` | Governance | Minimum notice before wind-down activation. Default and minimum 7 days (`MIN_WIND_DOWN_DELAY`), maximum 90 days (`MAX_WIND_DOWN_DELAY`). Raising is instant. | | **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. Applying a `Config` change sets every field, freshness included; to keep a cut made after the proposal, cancel the queued change and propose again. | @@ -223,6 +223,16 @@ Pause first, so no report lands while the roles change, and lift the pause last: 5. Lift the NAV pause: `set_nav_paused(false)` (admin). 6. The new attester posts a report. Pricing resumes: an epoch can be priced once a report accepted after its seal is valid. +### When the Manager or the Attester Stops + +Pricing needs both roles, and nothing on chain replaces them on its own. The kit assumes they keep working; until then, holders keep these ways out: + +- **The manager stops sealing.** Requests in the open epoch stay pending, and each holder can still cancel: a deposit comes back as cash, a redemption as shares. Governance grants a new manager; after setup that waits for the vault's timelock, at least seven days, plus any time the vault spends paused. +- **The attester stops reporting.** An epoch sealed after the last report has no readable price, so its requests stay cancellable, and claims already priced still pay. Governance revokes the attester on the oracle at once and grants a replacement, which waits for the timelock after setup, as in steps 2 and 3 above. +- **Neither comes back.** No holder can exit at a price. Governance can propose a wind-down; once its delay has run, anyone activates it, and its rounds pay every holder pro rata from the cash the vault holds and whatever the custodian returns through `fund`, with no price needed. + +Sealing is meant to become permissionless once a minimum epoch duration bounds it (design doc §8.4). + --- ### Comprehensive Entrypoint Permission Matrix @@ -498,6 +508,10 @@ 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. +The testnet identity verifier sets one address per call (`allow`), emits no +event and offers no paged listing. These are limits of the stub: a production +identity registry replaces it. + Two more traits connect the share token, the oracle and the kit's compliance contract to the vault's timelock. The vault implements `ClockSource` (`crates/strata-timelock`), and the three read its `timelock_clock` to time their own From 0db2b501808630f702f08d403ffdd73d61dcd561 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:27:43 -0300 Subject: [PATCH 134/147] fix(token): check the contract a swap installs After setup, a Compliance or IdentityVerifier change waited for the vault's timelock, but nothing checked the new contract. Rules that did not list the token, or ran on another vault's clock, landed and stopped every mint, transfer and burn. A verifier that refused the vault or did not answer made holders look delisted. propose_change and apply_change now refuse such a swap. New rules must name the token's vault with their setup closed and list the token in linked_tokens (ComplianceNotBound, 7017). A new verifier must accept the vault's address (VerifierRefusesVault, 7018). The calls go through try_ clients, so a contract that is missing or fails counts as a refusal instead of aborting the call. The vault test that pinned an unbound swap landing now expects the refusal. The design notes, the kit spec, the authorization matrix and the test map list the checks. --- contracts/async-vault/src/test/compliance.rs | 45 ++-- contracts/share-token/src/contract.rs | 5 + contracts/share-token/src/error.rs | 10 + contracts/share-token/src/governance.rs | 3 + contracts/share-token/src/guards.rs | 53 +++- contracts/share-token/src/lib.rs | 7 +- contracts/share-token/src/test_governance.rs | 242 +++++++++++++++++-- contracts/share-token/src/test_guards.rs | 78 ++++++ crates/strata-bindings/src/lib.rs | 7 +- docs/AUTHORIZATION_MATRIX.md | 4 +- docs/TESTING.md | 1 + docs/kit-spec.md | 14 +- docs/strata-product-and-architecture.md | 6 +- 13 files changed, 411 insertions(+), 64 deletions(-) diff --git a/contracts/async-vault/src/test/compliance.rs b/contracts/async-vault/src/test/compliance.rs index d7bb16e6..821c0577 100644 --- a/contracts/async-vault/src/test/compliance.rs +++ b/contracts/async-vault/src/test/compliance.rs @@ -1,5 +1,5 @@ use soroban_sdk::{contract, contractimpl}; -use stellar_tokens::rwa::compliance::{ComplianceError, ComplianceHook}; +use stellar_tokens::rwa::{compliance::ComplianceHook, RWA}; use super::*; @@ -114,7 +114,7 @@ fn a_compliance_module_that_refuses_the_mint_blocks_a_priced_claim() { } #[test] -fn an_unbound_compliance_swap_lands_and_stops_every_move() { +fn an_unbound_compliance_swap_is_refused_and_every_move_goes_on() { let f = setup(); let holder = f.holder(10); let investor = f.investor(1_000); @@ -122,28 +122,20 @@ fn an_unbound_compliance_swap_lands_and_stops_every_move() { f.vault.request_redeem(&holder, &4); f.fulfill_epoch(wad(2)); - let unbound = f.e.register(Compliance, (f.admin.clone(),)); - let id = f - .share - .propose_change(&share_token::TokenChange::Compliance(unbound.clone())); - f.advance(MIN_UPGRADE_DELAY); - f.share.apply_change(&id); - assert_eq!(f.share.compliance(), unbound); - - let token_not_bound = Some(Ok(ComplianceError::TokenNotBound.into())); - assert_eq!( - f.share.try_transfer(&holder, &investor, &1).err(), - token_not_bound - ); + let unbound = ComplianceClient::new(&f.e, &f.e.register(Compliance, (f.admin.clone(),))); + unbound.bind_vault(&f.vault.address); + unbound.finish_setup(); assert_eq!( - f.vault.try_claim_deposit(&investor, &epoch).err(), - token_not_bound + f.share + .try_propose_change(&share_token::TokenChange::Compliance(unbound.address)) + .err(), + Some(Ok(share_token::ShareTokenError::ComplianceNotBound.into())) ); - assert_eq!( - f.vault.try_claim_redeem(&holder, &epoch).err(), - token_not_bound - ); - assert!(!f.vault.health().compliance_bound); + + f.share.transfer(&holder, &investor, &1); + assert_eq!(f.vault.claim_deposit(&investor, &epoch), 50); + assert_eq!(f.vault.claim_redeem(&holder, &epoch), 8); + assert!(f.vault.health().compliance_bound); } #[test] @@ -151,12 +143,11 @@ fn a_verifier_that_cannot_answer_counts_as_a_failed_check() { let f = setup(); let investor = f.investor(1_000); + // A swap refuses it; this stands in for a verifier that stops answering later. let silent = Address::generate(&f.e); - let id = f - .share - .propose_change(&share_token::TokenChange::IdentityVerifier(silent.clone())); - f.advance(MIN_UPGRADE_DELAY); - f.share.apply_change(&id); + f.e.as_contract(&f.share.address, || { + RWA::set_identity_verifier(&f.e, &silent) + }); assert_eq!(f.share.identity_verifier(), silent); refused( diff --git a/contracts/share-token/src/contract.rs b/contracts/share-token/src/contract.rs index 054250bf..d952484b 100644 --- a/contracts/share-token/src/contract.rs +++ b/contracts/share-token/src/contract.rs @@ -126,6 +126,9 @@ impl ShareToken { /// /// # Errors /// + /// * `ComplianceNotBound` - The new compliance contract does not list + /// this token, or is not on the vault's clock with its setup closed. + /// * `VerifierRefusesVault` - The new verifier does not accept the vault. /// * `UpgradeAlreadyPending` - An upgrade already waits. /// * `SetupOpen` - Setup has not finished; change it directly. /// * `WindDownActive` - The vault is winding down. @@ -182,6 +185,8 @@ impl ShareToken { /// notice now. /// * `ChangeNotReady` - The delay, plus any time the vault spent paused /// since, has not run. + /// * `ComplianceNotBound`, `VerifierRefusesVault` - The new contract no + /// longer passes the check `propose_change` made. /// * `AccessControlError::RoleNotHeld` - A `RevokeRole` names an account /// without the role. /// * An `Upgrade` whose Wasm is not on the network fails the call. diff --git a/contracts/share-token/src/error.rs b/contracts/share-token/src/error.rs index 276c38b7..4f15b946 100644 --- a/contracts/share-token/src/error.rs +++ b/contracts/share-token/src/error.rs @@ -54,5 +54,15 @@ strata_bindings::error_enum! { /// once it is accepted, cancelled or expired, so no nominee from setup can /// accept after changes start to wait. AdminTransferPending = 7016, + /// The proposed compliance contract does not name this token's vault, + /// its setup is still open, or its `linked_tokens` leaves out this + /// token. Every mint, transfer and burn would fail, or its rules could + /// change without the vault's delay. + ComplianceNotBound = 7017, + /// The proposed identity verifier refuses the vault's address or does + /// not answer `verify_identity`. The vault needs that check to convert + /// a delisted holder's deposit, and a verifier that cannot answer makes + /// every holder look delisted. + VerifierRefusesVault = 7018, } } diff --git a/contracts/share-token/src/governance.rs b/contracts/share-token/src/governance.rs index 970d073a..7bc065f2 100644 --- a/contracts/share-token/src/governance.rs +++ b/contracts/share-token/src/governance.rs @@ -80,6 +80,7 @@ pub(crate) fn finish_setup(e: &Env) { } pub(crate) fn propose(e: &Env, change: TokenChange) -> u32 { + guards::valid_change(e, &change); guards::not_a_second_upgrade(e, &change); let id = queue::propose::(e, &change).unwrap_or_else(|r| refuse(e, r)); let (_, schedule) = @@ -102,6 +103,8 @@ pub(crate) fn cancel(e: &Env, id: u32) { pub(crate) fn apply(e: &Env, id: u32) { let change: TokenChange = queue::take_ready::(e, id).unwrap_or_else(|r| refuse(e, r)); + // The new contract may have changed since the proposal; this check decides. + guards::valid_change(e, &change); let admin = access_control::get_admin(e) .unwrap_or_else(|| panic_with_error!(e, ShareTokenError::AdminRequired)); match &change { diff --git a/contracts/share-token/src/guards.rs b/contracts/share-token/src/guards.rs index fcd26a7f..b8ae6527 100644 --- a/contracts/share-token/src/guards.rs +++ b/contracts/share-token/src/guards.rs @@ -3,7 +3,10 @@ use stellar_access::{ access_control::{self, AccessControlStorageKey}, role_transfer, }; -use stellar_tokens::rwa::{identity_verification::IdentityVerifierClient, RWA}; +use stellar_tokens::rwa::{ + identity_verification::IdentityVerifierClient, utils::token_binder::TokenBinderClient, RWA, +}; +use strata_bindings::VaultBindingClient; use strata_timelock::{queue, setup}; use crate::error::ShareTokenError; @@ -61,6 +64,54 @@ fn upgrade_pending(e: &Env) -> bool { }) } +/// What a change must meet when it is proposed and again when it applies: +/// rules bound here, or a verifier that accepts the vault. +pub(crate) fn valid_change(e: &Env, change: &TokenChange) { + match change { + TokenChange::Compliance(rules) => compliance_bound_here(e, rules), + TokenChange::IdentityVerifier(verifier) => verifier_accepts_vault(e, verifier), + TokenChange::Upgrade(_) + | TokenChange::GrantRole(..) + | TokenChange::RevokeRole(..) + | TokenChange::Admin(_) => {} + } +} + +/// Rules that leave out this token stop every mint, transfer and burn; rules +/// off the vault's clock could change with no delay. +pub(crate) fn compliance_bound_here(e: &Env, rules: &Address) { + if !on_the_vaults_clock(e, rules) || !lists_this_token(e, rules) { + panic_with_error!(e, ShareTokenError::ComplianceNotBound); + } +} + +pub(crate) fn verifier_accepts_vault(e: &Env, verifier: &Address) { + let accepted = setup::source(e).is_some_and(|vault| { + matches!( + IdentityVerifierClient::new(e, verifier).try_verify_identity(&vault), + Ok(Ok(())) + ) + }); + if !accepted { + panic_with_error!(e, ShareTokenError::VerifierRefusesVault); + } +} + +/// `contract` names this token's vault and its own setup has finished. +fn on_the_vaults_clock(e: &Env, contract: &Address) -> bool { + let binding = VaultBindingClient::new(e, contract); + setup::source(e) + .is_some_and(|vault| matches!(binding.try_vault(), Ok(Ok(Some(named))) if named == vault)) + && matches!(binding.try_setup_open(), Ok(Ok(false))) +} + +fn lists_this_token(e: &Env, rules: &Address) -> bool { + matches!( + TokenBinderClient::new(e, rules).try_linked_tokens(), + Ok(Ok(linked)) if linked.contains(e.current_contract_address()) + ) +} + /// Stands in for `#[only_admin]` where an OpenZeppelin trait fixes the caller. pub(crate) fn caller_is_admin(e: &Env, caller: &Address) { if access_control::get_admin(e).as_ref() != Some(caller) { diff --git a/contracts/share-token/src/lib.rs b/contracts/share-token/src/lib.rs index 567750d3..87c41e92 100644 --- a/contracts/share-token/src/lib.rs +++ b/contracts/share-token/src/lib.rs @@ -19,9 +19,10 @@ //! - **The pause halts trading only.** The admin's transfer pause stops //! `transfer` and `transfer_from`; mints, burns, escrow moves and legal //! transfers go on. The cost: it is no emergency stop for the vault. -//! - **A compliance swap is not checked.** The new contract need not list this -//! token. The cost: if it does not, every mint, transfer and burn fails until -//! it binds the token, and the vault's `health()` shows it. +//! - **A swap must fit the vault.** New rules must list this token and run on +//! the vault's clock with their setup closed; a new verifier must accept the +//! vault. Both are checked when proposed and when applied. The cost: after +//! setup, an external module that reports no vault cannot be swapped in. #![no_std] mod contract; diff --git a/contracts/share-token/src/test_governance.rs b/contracts/share-token/src/test_governance.rs index 4e670937..f44367f4 100644 --- a/contracts/share-token/src/test_governance.rs +++ b/contracts/share-token/src/test_governance.rs @@ -6,7 +6,7 @@ use soroban_sdk::{ storage::Instance as _, Address as _, AuthorizedFunction, AuthorizedInvocation, Events as _, Ledger as _, MockAuth, MockAuthInvoke, }, - Address, BytesN, Env, Event as _, IntoVal, String, Symbol, + vec, Address, BytesN, Env, Event as _, IntoVal, String, Symbol, Vec, }; use stellar_access::role_transfer::RoleTransferError; use strata_storage::INSTANCE_EXTEND_AMOUNT; @@ -16,6 +16,8 @@ use crate::{ ChangeApplied, ChangeProposed, ShareToken, ShareTokenClient, ShareTokenError, TokenChange, ADMIN_ACCEPT_LEDGERS, }; +use compliance::{Compliance, ComplianceClient}; +use identity_verifier::{IdentityVerifier, IdentityVerifierClient}; const TWO_DAYS_IN_LEDGERS: u32 = 2 * 17_280; @@ -46,6 +48,74 @@ impl strata_timelock::ClockSource for ClockStub { } } +fn clock_stub<'a>(e: &Env) -> ClockStubClient<'a> { + 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, + }); + vault +} + +/// Compliance rules whose answers a test sets, and may change after a swap to +/// them is proposed. +#[contract] +struct RulesStub; + +#[contractimpl] +impl RulesStub { + pub fn report(e: Env, vault: Option
, setup_open: bool, linked: Vec
) { + let store = e.storage().instance(); + store.set(&symbol_short!("vault"), &vault); + store.set(&symbol_short!("open"), &setup_open); + store.set(&symbol_short!("linked"), &linked); + } + + /// Every view fails from now on. + pub fn silence(e: Env) { + let store = e.storage().instance(); + store.remove(&symbol_short!("vault")); + store.remove(&symbol_short!("open")); + store.remove(&symbol_short!("linked")); + } + + pub fn linked_tokens(e: Env) -> Vec
{ + e.storage() + .instance() + .get(&symbol_short!("linked")) + .unwrap() + } +} + +#[contractimpl(contracttrait)] +impl strata_bindings::VaultBinding for RulesStub { + fn vault(e: &Env) -> Option
{ + e.storage().instance().get(&symbol_short!("vault")).unwrap() + } + + fn setup_open(e: &Env) -> bool { + e.storage().instance().get(&symbol_short!("open")).unwrap() + } +} + +/// A verifier that accepts every account until a test silences it. +#[contract] +struct VerifierStub; + +#[contractimpl] +impl VerifierStub { + pub fn silence(e: Env) { + e.storage().instance().set(&symbol_short!("silent"), &true); + } + + pub fn verify_identity(e: Env, _account: Address) { + assert!(!e.storage().instance().has(&symbol_short!("silent"))); + } +} + struct Fixture<'a> { e: Env, token: ShareTokenClient<'a>, @@ -77,6 +147,33 @@ impl Fixture<'_> { self.token.propose_change(&change) } + fn rules(&self) -> ComplianceClient<'_> { + ComplianceClient::new(&self.e, &self.e.register(Compliance, (self.admin.clone(),))) + } + + /// Rules the token may swap to: they list the token and run on this + /// vault's clock with their setup finished. + fn rules_bound_here(&self) -> Address { + let rules = self.rules(); + rules.bind_token(&self.token.address, &self.admin); + rules.bind_vault(&self.vault.address); + rules.finish_setup(); + rules.address + } + + fn verifier(&self) -> IdentityVerifierClient<'_> { + IdentityVerifierClient::new( + &self.e, + &self.e.register(IdentityVerifier, (self.admin.clone(),)), + ) + } + + fn verifier_accepting_the_vault(&self) -> Address { + let verifier = self.verifier(); + verifier.allow(&self.vault.address, &true); + verifier.address + } + /// Lands an `Admin` change for `next` and returns the ledger of the offer. fn offer_admin(&self, next: &Address) -> u32 { let id = self.propose(TokenChange::Admin(next.clone())); @@ -156,14 +253,7 @@ fn setup<'a>() -> Fixture<'a> { Address::generate(&e), ), ); - 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, - }); + let vault = clock_stub(&e); Fixture { token: ShareTokenClient::new(&e, &token), vault, @@ -435,7 +525,7 @@ fn a_revoke_lands_through_the_timelock() { fn a_vault_pause_moves_the_eta_and_holds_the_change() { let f = setup(); f.finish(); - let id = f.propose(TokenChange::Compliance(Address::generate(&f.e))); + let id = f.propose(TokenChange::GrantRole(manager(), Address::generate(&f.e))); let eta = f.token.pending_changes().get(0).unwrap().eta; f.advance(DAY_DURATION); @@ -459,13 +549,13 @@ fn a_vault_pause_moves_the_eta_and_holds_the_change() { fn nothing_is_proposed_or_applied_while_the_vault_winds_down() { let f = setup(); f.finish(); - let id = f.propose(TokenChange::Compliance(Address::generate(&f.e))); + let id = f.propose(TokenChange::GrantRole(manager(), Address::generate(&f.e))); f.clock(|c| c.winding_down = true); f.advance(MIN_DELAY); refused( f.token - .try_propose_change(&TokenChange::Compliance(Address::generate(&f.e))), + .try_propose_change(&TokenChange::GrantRole(manager(), Address::generate(&f.e))), ShareTokenError::WindDownActive, ); refused( @@ -479,7 +569,7 @@ fn nothing_is_proposed_or_applied_while_the_vault_winds_down() { fn a_change_shorter_than_a_raised_notice_never_lands() { let f = setup(); f.finish(); - let id = f.propose(TokenChange::Compliance(Address::generate(&f.e))); + let id = f.propose(TokenChange::GrantRole(manager(), Address::generate(&f.e))); f.clock(|c| { c.delay = 30 * DAY_DURATION; c.exit_window = 20 * DAY_DURATION; @@ -510,7 +600,7 @@ fn only_the_admin_proposes_cancels_and_applies() { let f = setup(); f.finish(); let stranger = Address::generate(&f.e); - let change = TokenChange::Compliance(Address::generate(&f.e)); + let change = TokenChange::GrantRole(manager(), Address::generate(&f.e)); let id = f.propose(change.clone()); f.advance(MIN_DELAY); @@ -579,8 +669,8 @@ fn the_hand_over_offer_lasts_seven_days_of_ledgers() { fn applied_rule_and_verifier_changes_take_effect() { let f = setup(); f.finish(); - let rules = Address::generate(&f.e); - let verifier = Address::generate(&f.e); + let rules = f.rules_bound_here(); + let verifier = f.verifier_accepting_the_vault(); let first = f.propose(TokenChange::Compliance(rules.clone())); let second = f.propose(TokenChange::IdentityVerifier(verifier.clone())); f.advance(MIN_DELAY); @@ -592,11 +682,117 @@ fn applied_rule_and_verifier_changes_take_effect() { assert_eq!(f.token.identity_verifier(), verifier); } +#[test] +fn rules_off_this_vaults_clock_or_without_the_token_are_never_proposed() { + let f = setup(); + f.finish(); + let unlisted = f.rules(); + unlisted.bind_vault(&f.vault.address); + unlisted.finish_setup(); + let elsewhere = f.rules(); + elsewhere.bind_token(&f.token.address, &f.admin); + elsewhere.bind_vault(&clock_stub(&f.e).address); + elsewhere.finish_setup(); + let in_setup = f.rules(); + in_setup.bind_token(&f.token.address, &f.admin); + in_setup.bind_vault(&f.vault.address); + let not_a_contract = Address::generate(&f.e); + + for rules in [ + unlisted.address, + elsewhere.address, + in_setup.address, + not_a_contract, + ] { + refused( + f.token.try_propose_change(&TokenChange::Compliance(rules)), + ShareTokenError::ComplianceNotBound, + ); + } + assert_eq!(f.token.pending_changes().len(), 0); +} + +/// Proposes a swap to rules that qualify, lets `turn` change their answers, +/// then checks the swap no longer lands. +fn refused_once_the_rules_turn(f: &Fixture, turn: impl FnOnce(&RulesStubClient)) { + let rules = RulesStubClient::new(&f.e, &f.e.register(RulesStub, ())); + rules.report( + &Some(f.vault.address.clone()), + &false, + &vec![&f.e, f.token.address.clone()], + ); + let id = f.propose(TokenChange::Compliance(rules.address.clone())); + turn(&rules); + f.advance(MIN_DELAY); + + refused( + f.token.try_apply_change(&id), + ShareTokenError::ComplianceNotBound, + ); + assert_ne!(f.token.compliance(), rules.address); +} + +#[test] +fn rules_that_stop_qualifying_are_never_installed() { + let f = setup(); + f.finish(); + let here = Some(f.vault.address.clone()); + let listed = vec![&f.e, f.token.address.clone()]; + + refused_once_the_rules_turn(&f, |rules| rules.report(&here, &false, &vec![&f.e])); + refused_once_the_rules_turn(&f, |rules| { + rules.report(&Some(clock_stub(&f.e).address), &false, &listed) + }); + refused_once_the_rules_turn(&f, |rules| rules.report(&here, &true, &listed)); + refused_once_the_rules_turn(&f, |rules| rules.silence()); +} + +#[test] +fn a_verifier_that_refuses_the_vault_or_cannot_answer_is_never_proposed() { + let f = setup(); + f.finish(); + let refusing = f.verifier().address; + let not_a_verifier = f.vault.address.clone(); + let not_a_contract = Address::generate(&f.e); + + for verifier in [refusing, not_a_verifier, not_a_contract] { + refused( + f.token + .try_propose_change(&TokenChange::IdentityVerifier(verifier)), + ShareTokenError::VerifierRefusesVault, + ); + } + assert_eq!(f.token.pending_changes().len(), 0); +} + +#[test] +fn a_verifier_that_stops_accepting_the_vault_is_never_installed() { + let f = setup(); + f.finish(); + let delisting = f.verifier(); + delisting.allow(&f.vault.address, &true); + let silenced = VerifierStubClient::new(&f.e, &f.e.register(VerifierStub, ())); + let first = f.propose(TokenChange::IdentityVerifier(delisting.address.clone())); + let second = f.propose(TokenChange::IdentityVerifier(silenced.address.clone())); + + delisting.allow(&f.vault.address, &false); + silenced.silence(); + f.advance(MIN_DELAY); + + for id in [first, second] { + refused( + f.token.try_apply_change(&id), + ShareTokenError::VerifierRefusesVault, + ); + } + assert_eq!(f.token.pending_changes().len(), 2); +} + #[test] fn proposing_and_applying_publish_the_change() { let f = setup(); f.finish(); - let change = TokenChange::Compliance(Address::generate(&f.e)); + let change = TokenChange::GrantRole(manager(), Address::generate(&f.e)); let eta = f.e.ledger().timestamp() + MIN_DELAY; let id = f.propose(change.clone()); @@ -625,7 +821,7 @@ fn proposing_reads_the_vault_clock_once() { f.finish(); let before = f.vault.reads(); - f.propose(TokenChange::Compliance(Address::generate(&f.e))); + f.propose(TokenChange::GrantRole(manager(), Address::generate(&f.e))); assert_eq!(f.vault.reads(), before + 1); } @@ -635,7 +831,7 @@ 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 = TokenChange::Compliance(Address::generate(&f.e)); + let change = TokenChange::GrantRole(manager(), Address::generate(&f.e)); let eta = f.e.ledger().timestamp() + MIN_DELAY; let id = f.propose(change.clone()); @@ -670,12 +866,12 @@ fn every_admin_call_restores_the_instance_ttl() { f.restores_the_instance(|| f.token.bind_vault(&f.vault.address)); f.restores_the_instance(|| f.token.finish_setup()); - let rules = TokenChange::Compliance(Address::generate(&f.e)); + let grant = TokenChange::GrantRole(manager(), Address::generate(&f.e)); let mut id = 0; - f.restores_the_instance(|| id = f.propose(rules.clone())); + f.restores_the_instance(|| id = f.propose(grant.clone())); f.restores_the_instance(|| f.token.cancel_change(&id)); - let id = f.propose(rules); + let id = f.propose(grant); f.advance(MIN_DELAY); f.restores_the_instance(|| f.token.apply_change(&id)); } @@ -738,7 +934,7 @@ fn each_admin_call_asks_the_admin_for_one_signature() { f.token.finish_setup(); f.signed_once_by(&f.admin, "finish_setup", soroban_sdk::Vec::new(&f.e)); - let change = TokenChange::Compliance(rules); + let change = TokenChange::Compliance(f.rules_bound_here()); let id = f.token.propose_change(&change); f.signed_once_by(&f.admin, "propose_change", (change.clone(),).into_val(&f.e)); f.token.cancel_change(&id); diff --git a/contracts/share-token/src/test_guards.rs b/contracts/share-token/src/test_guards.rs index fa7a6208..acb2a635 100644 --- a/contracts/share-token/src/test_guards.rs +++ b/contracts/share-token/src/test_guards.rs @@ -12,6 +12,7 @@ use strata_timelock::{Clock, MIN_DELAY}; use crate::guards; use crate::{ShareToken, ShareTokenClient, ShareTokenError, TokenChange, MAX_REFERENCE_LEN}; +use compliance::{Compliance, ComplianceClient}; use identity_verifier::{IdentityVerifier, IdentityVerifierClient}; /// Plays the vault: the token binds only to an address that reports a clock. @@ -45,6 +46,18 @@ impl Fixture<'_> { self.token.bind_vault(&self.vault); self.token.finish_setup(); } + + /// Rules on the vault's clock with their setup finished. + fn rules_listing(&self, tokens: &[&Address]) -> Address { + let rules = + ComplianceClient::new(&self.e, &self.e.register(Compliance, (self.admin.clone(),))); + for token in tokens { + rules.bind_token(token, &self.admin); + } + rules.bind_vault(&self.vault); + rules.finish_setup(); + rules.address + } } fn setup<'a>() -> Fixture<'a> { @@ -133,6 +146,71 @@ fn not_a_second_upgrade_refuses_an_upgrade_while_one_waits() { holds(&f, || guards::not_a_second_upgrade(&f.e, &grant)); } +#[test] +fn compliance_bound_here_refuses_rules_without_the_token_or_before_the_vault_is_known() { + let f = setup(); + let listing = f.rules_listing(&[&f.token.address]); + refuses(&f, ShareTokenError::ComplianceNotBound, || { + guards::compliance_bound_here(&f.e, &listing) + }); + + f.finish_setup(); + holds(&f, || guards::compliance_bound_here(&f.e, &listing)); + for rules in [f.rules_listing(&[]), Address::generate(&f.e)] { + refuses(&f, ShareTokenError::ComplianceNotBound, || { + guards::compliance_bound_here(&f.e, &rules) + }); + } +} + +#[test] +fn verifier_accepts_vault_refuses_a_verifier_that_refuses_or_cannot_answer() { + let f = setup(); + f.identity.allow(&f.vault, &true); + refuses(&f, ShareTokenError::VerifierRefusesVault, || { + guards::verifier_accepts_vault(&f.e, &f.identity.address) + }); + + f.finish_setup(); + holds(&f, || { + guards::verifier_accepts_vault(&f.e, &f.identity.address) + }); + for verifier in [f.vault.clone(), Address::generate(&f.e)] { + refuses(&f, ShareTokenError::VerifierRefusesVault, || { + guards::verifier_accepts_vault(&f.e, &verifier) + }); + } + f.identity.allow(&f.vault, &false); + refuses(&f, ShareTokenError::VerifierRefusesVault, || { + guards::verifier_accepts_vault(&f.e, &f.identity.address) + }); +} + +#[test] +fn valid_change_checks_only_rule_and_verifier_swaps() { + let f = setup(); + f.finish_setup(); + f.identity.allow(&f.vault, &true); + let stranger = Address::generate(&f.e); + for change in [ + TokenChange::Upgrade(BytesN::from_array(&f.e, &[1; 32])), + TokenChange::GrantRole(symbol_short!("manager"), stranger.clone()), + TokenChange::RevokeRole(symbol_short!("manager"), stranger.clone()), + TokenChange::Admin(stranger.clone()), + TokenChange::Compliance(f.rules_listing(&[&f.token.address])), + TokenChange::IdentityVerifier(f.identity.address.clone()), + ] { + holds(&f, || guards::valid_change(&f.e, &change)); + } + + refuses(&f, ShareTokenError::ComplianceNotBound, || { + guards::valid_change(&f.e, &TokenChange::Compliance(stranger.clone())) + }); + refuses(&f, ShareTokenError::VerifierRefusesVault, || { + guards::valid_change(&f.e, &TokenChange::IdentityVerifier(stranger.clone())) + }); +} + #[test] fn caller_is_admin_refuses_anyone_but_the_admin() { let f = setup(); diff --git a/crates/strata-bindings/src/lib.rs b/crates/strata-bindings/src/lib.rs index a7d70730..d2feb01d 100644 --- a/crates/strata-bindings/src/lib.rs +++ b/crates/strata-bindings/src/lib.rs @@ -5,9 +5,10 @@ //! - **The compiler checks the feed.** The vault prices from `OracleFeed`, //! which the kit's oracle and any adopter's feed implement. The cost: a feed //! must answer its four calls as written, or use an adapter. -//! - **Bound contracts report their binding.** The vault's `finish_setup` reads -//! `VaultBinding`. The cost: a compliance contract that does not answer it is -//! accepted, on trust, as external. +//! - **Bound contracts report their binding.** The vault's `finish_setup` and +//! the token's compliance swaps read `VaultBinding`. The cost: setup accepts +//! a compliance contract that does not answer it, on trust, as external; a +//! later swap refuses one. //! - **Fixed blocks of error codes.** The vault, token, oracle and compliance //! contract each own one; `error_enum!` fails the build outside it, //! `errors.rs` on overlaps. The cost: removed codes stay retired. diff --git a/docs/AUTHORIZATION_MATRIX.md b/docs/AUTHORIZATION_MATRIX.md index 05721e18..af099a7f 100644 --- a/docs/AUTHORIZATION_MATRIX.md +++ b/docs/AUTHORIZATION_MATRIX.md @@ -113,7 +113,7 @@ Tests: [`controls.rs`](../contracts/async-vault/src/test/controls.rs), [`epochs. ### 5.2 ShareToken (`contracts/share-token`) -The token's `admin` is governance; the guardian holds no role on the token. Until `finish_setup`, admin changes are instant, so the deploy can grant the vault the `manager` role. 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, revoking `manager`, admin hand-over, `set_compliance`, `set_identity_verifier` and upgrades. Revoking `compliance` and `pause`/`unpause` stay instant. The `manager` role is held by the vault and the `compliance` role by the compliance authority. The token's `pause` refuses SEP-41 transfers between holders only. The vault's own escrow moves (`forced_transfer`), mint and burn ignore it, so a token pause stops neither entry nor exit. +The token's `admin` is governance; the guardian holds no role on the token. Until `finish_setup`, admin changes are instant, so the deploy can grant the vault the `manager` role. 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, revoking `manager`, admin hand-over, `set_compliance`, `set_identity_verifier` and upgrades. A compliance or verifier swap is checked when proposed and again when applied: new rules must run on the vault's clock and list the token, and a new verifier must accept the vault. Revoking `compliance` and `pause`/`unpause` stay instant. The `manager` role is held by the vault and the `compliance` role by the compliance authority. The token's `pause` refuses SEP-41 transfers between holders only. The vault's own escrow moves (`forced_transfer`), mint and burn ignore it, so a token pause stops neither entry nor exit. Tests: [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.rs) and [`contracts/share-token/src/test_governance.rs`](../contracts/share-token/src/test_governance.rs). What each privileged call asks to sign: `test_governance::each_admin_call_asks_the_admin_for_one_signature`, `test::each_role_call_asks_its_holder_for_one_signature` and `test::legal_transfer::a_legal_transfer_or_a_recovery_asks_each_signer_once`. @@ -137,6 +137,8 @@ Tests: [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.r | `propose_change` | `#[only_admin]` | After setup; vault not winding down; one upgrade at a time | `test_governance::nothing_is_proposed_during_setup`, `test_governance::only_the_admin_proposes_cancels_and_applies` | | `cancel_change` | `#[only_admin]` | The change must be pending | `test_governance::only_the_admin_proposes_cancels_and_applies` | | `apply_change` | `#[only_admin]` | Delay run on the vault's clock, vault unpaused and not winding down, delay `>=` the vault's notice | `test_governance::a_vault_pause_moves_the_eta_and_holds_the_change`, `test_governance::a_change_shorter_than_a_raised_notice_never_lands` | +| `propose_change` (`Compliance(address)`, `IdentityVerifier(address)`) | `#[only_admin]` | New rules name the token's vault through `vault()` with their setup closed and list the token in `linked_tokens` (`ComplianceNotBound`); a new verifier's `verify_identity` accepts the vault's address (`VerifierRefusesVault`). A contract that does not answer fails | `test_governance::rules_off_this_vaults_clock_or_without_the_token_are_never_proposed`, `test_governance::a_verifier_that_refuses_the_vault_or_cannot_answer_is_never_proposed`, `test::compliance::an_unbound_compliance_swap_is_refused_and_every_move_goes_on` in `contracts/async-vault` | +| `apply_change` (`Compliance(address)`, `IdentityVerifier(address)`) | `#[only_admin]` | Delay run; the new rules or verifier still pass the same check | `test_governance::applied_rule_and_verifier_changes_take_effect`, `test_governance::rules_that_stop_qualifying_are_never_installed`, `test_governance::a_verifier_that_stops_accepting_the_vault_is_never_installed` | | `pending_changes` | Open view | None | None (view) | | `grant_role` | Admin, `caller.require_auth()` | Instant during setup; `UseTimelock` after | `test_governance::grants_and_manager_revokes_wait_after_setup` | | `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`) | diff --git a/docs/TESTING.md b/docs/TESTING.md index a3dfe540..f06c21e3 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -143,6 +143,7 @@ fixture wires them as the deploy does and finishes the vault's setup; | 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` | | The vault finishes setup only once the token and the oracle report it with their own setup closed, it is the token's only `manager`, the token has no shares outstanding, it passes the identity check, its address is not frozen, the token is bound to compliance (which runs on this vault's clock with its setup closed unless it is an external module that does not answer `VaultBinding`), the asset has the token's decimals, the asset, token, oracle and vault have four different addresses, the oracle answers as a feed, and a custodian other than the vault is set. `health()` reports each check, and before setup finishes the vault takes no deposit | [`contracts/async-vault/src/test/setup.rs`](../contracts/async-vault/src/test/setup.rs), [`contracts/async-vault/src/test/health.rs`](../contracts/async-vault/src/test/health.rs) | `every_wiring_step_is_checked_before_setup_finishes`, `each_missing_wiring_step_shows_in_its_own_flag`, `a_token_that_cannot_answer_fails_its_checks_instead_of_trapping`, `a_compliance_contract_from_outside_the_kit_is_accepted`, `a_deposit_before_setup_finishes_is_refused`, `setup_cannot_finish_while_a_hand_over_is_pending` | | The vault is never its own custodian: the direct setter, during setup and after, and a `Custodian` change, when proposed and when applied, refuse it with `InvalidCustodian` | [`contracts/async-vault/src/test/treasury.rs`](../contracts/async-vault/src/test/treasury.rs), [`contracts/async-vault/src/test/governance.rs`](../contracts/async-vault/src/test/governance.rs), [`contracts/async-vault/src/test/guards.rs`](../contracts/async-vault/src/test/guards.rs) | `setting_the_vault_as_its_own_custodian_is_refused`, `a_custodian_change_to_the_vault_is_refused_when_proposed_and_when_applied`, `custodian_not_the_vault_refuses_the_vault_itself` | +| The share token swaps to new compliance rules only if they run on the vault's clock with their setup closed and list the token (`ComplianceNotBound`), and to a new verifier only if it accepts the vault (`VerifierRefusesVault`), checked when proposed and again when applied; a contract that does not answer is refused | [`contracts/share-token/src/test_governance.rs`](../contracts/share-token/src/test_governance.rs), [`contracts/share-token/src/test_guards.rs`](../contracts/share-token/src/test_guards.rs), [`contracts/async-vault/src/test/compliance.rs`](../contracts/async-vault/src/test/compliance.rs) | `rules_off_this_vaults_clock_or_without_the_token_are_never_proposed`, `rules_that_stop_qualifying_are_never_installed`, `a_verifier_that_refuses_the_vault_or_cannot_answer_is_never_proposed`, `a_verifier_that_stops_accepting_the_vault_is_never_installed`, `applied_rule_and_verifier_changes_take_effect`, `an_unbound_compliance_swap_is_refused_and_every_move_goes_on` | | 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; 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 69c78219..bb30b0d8 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -95,6 +95,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. + - A compliance swap needs a contract that names the token's vault through `vault()` with its own setup finished, and lists the token in `linked_tokens` (`ComplianceNotBound`). A verifier swap needs a verifier whose `verify_identity` accepts the vault's address (`VerifierRefusesVault`). Both are checked when the change is proposed and again when it applies, and a contract that does not answer fails the check. - 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. - Once its one-time setup closes, the compliance contract's module changes, token bindings and admin hand-overs wait on the vault's clock under the same rules; a direct call is refused with `UseTimelock`. @@ -295,9 +296,9 @@ Sealing is meant to become permissionless once a minimum epoch duration bounds i | **`ShareToken`** | `set_compliance`, `set_identity_verifier` | Governance | `operator` must be the admin (`AdminRequired`), then `operator.require_auth()`; instant during setup, then `propose_change` | No | | **`ShareToken`** | `bind_vault` | Governance (token admin) | `#[only_admin]`; during setup, once | No | | **`ShareToken`** | `finish_setup` | Governance | `#[only_admin]`; needs a bound vault and no pending admin hand-over | No | -| **`ShareToken`** | `propose_change` | Governance | `#[only_admin]`; after setup | No | +| **`ShareToken`** | `propose_change` | Governance | `#[only_admin]`; after setup; a compliance or verifier swap passes its check (`ComplianceNotBound`, `VerifierRefusesVault`) | No | | **`ShareToken`** | `cancel_change` | Governance | `#[only_admin]` | No | -| **`ShareToken`** | `apply_change` | Governance | `#[only_admin]`; after the vault's delay | Refused while the vault is paused or winding down | +| **`ShareToken`** | `apply_change` | Governance | `#[only_admin]`; after the vault's delay; a compliance or verifier swap passes its check again | Refused while the vault is paused or winding down | | **`ShareToken`** | `pending_changes` | Permissionless | None (view) | No | | **`ShareToken`** | `grant_role`, `transfer_admin_role` | Governance | Instant during setup, then `propose_change` (a zero `live_until_ledger` cancels at once) | No | | **`ShareToken`** | `revoke_role` | Governance | `compliance`: instant. `manager`: instant during setup, then `propose_change` | No | @@ -529,6 +530,13 @@ compliance contract (OpenZeppelin `TokenBinder`), and calls the oracle's `state` and `freshness_duration` (`OracleFeed`). A replacement for any of these must answer those calls, or the vault's setup cannot finish. +After setup, the share token makes the same kind of check before it swaps a +contract, when the swap is proposed and again when it applies. New compliance +rules must answer `VaultBinding` with the token's vault and their setup closed, +and list the token in `linked_tokens`; unlike `finish_setup`, the token accepts +no external module that does not answer. A new identity verifier must accept +the vault's address in `verify_identity`. + --- ## 7. Error Codes and Entrypoint Docs @@ -544,7 +552,7 @@ for the same failure, so `304` says what failed, not where. |---|---|---|---| | `nav_oracle` | `OracleError` | 3000–3999 | 3001–3021, 3025–3026 | | `async_vault` | `VaultError` | 6000–6999 | 6000–6001, 6005, 6007, 6009, 6012, 6014, 6029–6035, 6037–6075 | -| `share_token` | `ShareTokenError` | 7000–7999 | 7000–7013, 7015–7016 | +| `share_token` | `ShareTokenError` | 7000–7999 | 7000–7013, 7015–7018 | | `compliance` | `ComplianceContractError` | 8000–8999 | 8001–8011 | The blocks are defined once, in `crates/strata-bindings` (`ORACLE_ERRORS`, diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index 5866d20e..bc69d8bb 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -287,9 +287,9 @@ number. | 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 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 (set only by the admin; governance by deploy) | Manager role held only by the vault, for mint, burn and escrow; compliance role held by the compliance authority, for freezes, plus legal transfers and recovery co-signed by the token admin; swapping the compliance or identity-verifier contract needs the admin and, after a one-time setup, waits for the vault's timelock, as do the token's upgrades, role grants and admin hand-overs | +| **Share token** | OZ SEP-41 + SEP-57 RWA extensions: freeze, forced transfer, recovery, identity and compliance checks on every transfer, independent transfer pause (set only by the admin; governance by deploy) | Manager role held only by the vault, for mint, burn and escrow; compliance role held by the compliance authority, for freezes, plus legal transfers and recovery co-signed by the token admin; swapping the compliance or identity-verifier contract needs the admin and, after a one-time setup, waits for the vault's timelock, as do the token's upgrades, role grants and admin hand-overs. New rules must run on the vault's clock and list the token, and a new verifier must accept the vault, both when the swap is proposed and when it applies | | **Token roles** | Access-control roles on the share token, checked directly via native access control: `manager` mints, burns and escrows; `compliance` freezes, and makes legal transfers and recoveries co-signed by the token admin | 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 | The compliance authority writes the allowlist on the identity verifier. Governance runs the compliance contract as its admin: adding or removing a module and binding or unbinding a token are instant only during its one-time setup, and after it wait for the vault's timelock, cannot land while the vault is paused and never land during a wind-down. Replaceable by OZ's identity verifier and compliance contracts (with RWA Wizard modules); after setup, the token's swap to them waits for the vault's timelock | +| **Compliance module** | Implements the SEP-57 identity and rules interfaces the share token consults, with the allowlist as its only rule | The compliance authority writes the allowlist on the identity verifier. Governance runs the compliance contract as its admin: adding or removing a module and binding or unbinding a token are instant only during its one-time setup, and after it wait for the vault's timelock, cannot land while the vault is paused and never land during a wind-down. Replaceable by OZ's identity verifier and compliance contracts (with RWA Wizard modules). After setup, the token's swap waits for the vault's timelock, and new rules must report this vault through `VaultBinding` with their setup closed, which OZ's compliance contract does not do on its own | | **Authorities** | governance (parameters, roles, timelocked upgrades), compliance (allowlist, token interventions), attestation (valuation only), treasury (reserve movements only), guardian (pause; never payable claims) | Each contract grants its own roles; the oracle's, share token's and verifier's admins and the oracle's attester and guardian are constructor parameters independent of the vault's roles, equal to governance, compliance and guardian by deploy only; treasury and guardian distinct, treasury and governance distinct, compliance distinct from governance and treasury; governance and guardian may coincide; the vault's manager role is unconstrained | | **Custodian** | Off-chain party holding the real-world structure; a genesis-configured slot rotatable only by governance | Not an on-chain authority | @@ -626,7 +626,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. A compromised attester is replaced in this order: pause the NAV, revoke the attester (instant), grant a replacement (through the timelock after setup, so the NAV stays paused that long), clear the stored report, which would otherwise price epochs again once the pause lifts, then lift the pause ([`kit-spec.md`](./kit-spec.md) §3) | -| Governance keys | Malicious upgrade | Timelock on every code change and on the delay itself: at least seven days, never shorter than the standing notice, at most 90 days. The delay is an announcement period. A pause freezes the timelock clock and pushes the ETA back by the time paused, so the whole delay runs unpaused, and applying while paused is refused. The delay does not guarantee an exit before a change lands: an exit also needs a sealed epoch, a valid price accepted after the seal, and cash in the reserve to pay the claim. Nothing is proposed or applied while the vault winds down. The vault's own custodian, role grants, `manager` revocations, admin handovers and lowering of the notice or the wind-down delay wait on that clock once its one-time setup closes, and it takes no deposit before then; revoking its treasury or guardian stays instant. Setup closes only once the token and the oracle run on the vault's clock with their own setup closed, the vault is the token's only minter, no shares exist yet (shares minted earlier would redeem against depositors' money), the vault passes the identity check, its address is not frozen on the token, the token is bound to its compliance contract, the asset has the token's decimals, the asset, the token, the oracle and the vault are four different addresses, the oracle answers the feed interface, and a custodian other than the vault is set; the kit's own compliance contract must also run on the vault's clock with its setup closed. 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. On the kit's compliance contract, adding or removing a module, binding or unbinding a token and handing over the admin wait on the same clock once its setup closes. | +| Governance keys | Malicious upgrade | Timelock on every code change and on the delay itself: at least seven days, never shorter than the standing notice, at most 90 days. The delay is an announcement period. A pause freezes the timelock clock and pushes the ETA back by the time paused, so the whole delay runs unpaused, and applying while paused is refused. The delay does not guarantee an exit before a change lands: an exit also needs a sealed epoch, a valid price accepted after the seal, and cash in the reserve to pay the claim. Nothing is proposed or applied while the vault winds down. The vault's own custodian, role grants, `manager` revocations, admin handovers and lowering of the notice or the wind-down delay wait on that clock once its one-time setup closes, and it takes no deposit before then; revoking its treasury or guardian stays instant. Setup closes only once the token and the oracle run on the vault's clock with their own setup closed, the vault is the token's only minter, no shares exist yet (shares minted earlier would redeem against depositors' money), the vault passes the identity check, its address is not frozen on the token, the token is bound to its compliance contract, the asset has the token's decimals, the asset, the token, the oracle and the vault are four different addresses, the oracle answers the feed interface, and a custodian other than the vault is set; the kit's own compliance contract must also run on the vault's clock with its setup closed. The share token's upgrades, grants, `manager` revocations, admin handovers and compliance or verifier swaps wait on the same clock once its setup closes; new rules must also run on that clock and list the token, and a new verifier must accept the vault. 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. On the kit's compliance contract, adding or removing a module, binding or unbinding a token and handing over the admin wait on the same clock once its setup closes. | | Mis-wired deployment | A vault goes live without every binding wired | `health()` reports each wiring step; `finish_setup` refuses until all hold, and the vault takes no deposit before that | | 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 | From 740230409c6a59954bbd23dfbc53d9694aaa1e1b Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:04:26 -0300 Subject: [PATCH 135/147] test(harness): propose a valid verifier in the signature guard The guard that checks the compliance account cannot propose a token change named the compliance contract as the new identity verifier. The token now refuses a verifier that does not accept the vault, so the call failed in simulation instead of on the missing signature. Propose the deployed verifier, so the signature is the only reason left. --- scripts/harness/guards.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/harness/guards.test.ts b/scripts/harness/guards.test.ts index f5d283da..02789b82 100644 --- a/scripts/harness/guards.test.ts +++ b/scripts/harness/guards.test.ts @@ -207,7 +207,7 @@ describe("the compliance authority's reach", () => { shareToken(c, compliance).propose_change({ change: { tag: "IdentityVerifier", - values: [c.deployment.contracts.compliance], + values: [c.deployment.contracts.identity_verifier], }, }), ), From a33d7048e5ef3e4fd9a097418e81bf4a0252c4c5 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:47:22 -0300 Subject: [PATCH 136/147] docs: say that claims mint and burn shares, not pricing The pricing paragraph and the two sequence diagrams said pricing mints deposit shares and burns redeemed ones. Pricing records them as owed (`pending_mint_shares`, `pending_burn_shares`), and the token mints and burns only when each claim runs, as the redemption section already says. --- docs/strata-product-and-architecture.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index 5866d20e..f5b0e6a6 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -157,9 +157,11 @@ For the exhaustive access control matrix, dual `manager` role distinction, and e off-chain from the deployed value and the vault's public figures under a documented methodology. The contract validates, stores and exposes it. Every on-chain operation preserves that price by construction: pricing a deposit - releases escrow into the reserve and mints shares in proportion, pricing a - redemption burns shares and fixes the matching liability, and a custodian - transfer moves value between pockets without changing the total. + releases escrow into the reserve and owes shares in proportion, pricing a + redemption fixes the cash owed for its escrowed shares, and a custodian + transfer moves value between pockets without changing the total. Shares are + minted and burned only when each claim runs; the economic supply counts owed + shares from pricing and leaves escrowed shares out from the request. 5. **Payable claims always pay.** Neither the guardian pause, a delisting, nor a stale valuation can block the payment of an already-priced, funded cash @@ -338,7 +340,7 @@ sequenceDiagram I->>V: request_deposit(amount), asset to escrow Note over V: pending, cancellable O->>V: attestation - Note over V: priced: shares minted and held,
cancellation closed + Note over V: priced: shares owed, minted at claim,
cancellation closed I->>V: claim, re-checks allowlist V-->>I: shares delivered ``` @@ -351,7 +353,7 @@ sequenceDiagram participant T as Treasury authority I->>V: request_redeem(shares), shares to escrow O->>V: attestation - Note over V: priced: shares burned,
cash liability fixed + Note over V: priced: cash liability fixed,
escrowed shares burned at claim alt liquid reserve covers the claim I->>V: claim V-->>I: deposit asset paid From 3b04a0776b4721d618a10e0d2e6be92caf285e0b Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:56:13 -0300 Subject: [PATCH 137/147] ci: run a wind-down on a throwaway vault --- .github/workflows/build.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7689bc93..e1fc6893 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -158,6 +158,12 @@ jobs: run: npm run test:guards -w scripts/harness env: HARNESS_NETWORK: local + - name: Build the vault without the wind-down minimum + run: CARGO_TARGET_DIR=target/short-wind-down stellar contract build --package async-vault --features short_wind_down + - name: Run a wind-down on a throwaway vault on the local network + run: npm run test:wind-down -w scripts/harness + env: + HARNESS_NETWORK: local - name: Keep the harness report when it fails if: failure() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 From 39e3f55cf3e52140f7676b52136cb197030bcc4b Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:01:52 -0300 Subject: [PATCH 138/147] ci: fold the wind-down build command --- .github/workflows/build.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e1fc6893..fa832dbb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -159,7 +159,9 @@ jobs: env: HARNESS_NETWORK: local - name: Build the vault without the wind-down minimum - run: CARGO_TARGET_DIR=target/short-wind-down stellar contract build --package async-vault --features short_wind_down + run: + CARGO_TARGET_DIR=target/short-wind-down stellar contract build + --package async-vault --features short_wind_down - name: Run a wind-down on a throwaway vault on the local network run: npm run test:wind-down -w scripts/harness env: From 9ea72bdf954067ce9833f2cde55d2cab069ee89d Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:35:39 -0300 Subject: [PATCH 139/147] ci: add fmt, clippy and cargo-deny checks A new rust-checks job runs cargo fmt --check, clippy with warnings as errors and cargo deny on every pull request. deny.toml allows the five licences the dependency graph needs. It ignores two advisories that come in through soroban-env-host, each with its reason: paste is unmaintained, and crypto-bigint 0.5 has a timing issue that no soroban-env-host release has picked up the fix for yet. The devshell gains cargo-deny, and docs/TESTING.md lists the three commands to run before pushing. --- .github/workflows/build.yml | 27 +++++++++++++++++++++++++++ deny.toml | 31 +++++++++++++++++++++++++++++++ docs/TESTING.md | 12 ++++++++++++ flake.nix | 1 + 4 files changed, 71 insertions(+) create mode 100644 deny.toml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7689bc93..67b0bce0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -165,3 +165,30 @@ jobs: name: harness-runs path: scripts/harness/runs/ if-no-files-found: ignore + + rust-checks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-cargo-checks-${{ hashFiles('**/Cargo.lock') }} + # rustup reads rust-toolchain.toml, which includes clippy and rustfmt. + - name: Check formatting + run: cargo fmt --all -- --check + - name: Lint with warnings as errors + run: cargo clippy --locked --workspace --all-targets -- -D warnings + - name: Check licences, advisories, bans and sources + uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2.1.1 + with: + # Replaces the action's default --all-features, so CI runs the + # same command as docs/TESTING.md. + arguments: --locked + command: check diff --git a/deny.toml b/deny.toml new file mode 100644 index 00000000..c1b86271 --- /dev/null +++ b/deny.toml @@ -0,0 +1,31 @@ +# Checked in CI by `cargo deny --locked check`. Exact pins mean a flagged +# dependency waits for the next milestone's upgrade; each ignore says why. + +[advisories] +version = 2 +yanked = "deny" +ignore = [ + { id = "RUSTSEC-2024-0436", reason = "paste is unmaintained, not vulnerable; it is a build-time macro that soroban-env-host and soroban-env-common bring in through the pinned soroban-sdk" }, + { id = "RUSTSEC-2026-0387", reason = "crypto-bigint 0.5 comes in through soroban-env-host's elliptic-curve crates, which run only in host-side tests and are not in the contract Wasm; no soroban-env-host release uses the fixed 0.7.6 yet (checked 2026-10-26)" }, +] + +[licenses] +version = 2 +allow = [ + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-3-Clause", + "MIT", + "Unicode-3.0", +] +confidence-threshold = 0.9 + +[bans] +multiple-versions = "warn" +wildcards = "deny" +allow-wildcard-paths = true + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] diff --git a/docs/TESTING.md b/docs/TESTING.md index d52064d5..27c0d4a4 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -33,6 +33,18 @@ wind-down; local tests only`. Check that it compiles with `cargo check -p async-vault --features short_wind_down`. Do not run the test suite under it: tests that subtract from the minimum assume seven days. +CI also runs three checks that fail on any finding. Run them before pushing, +inside `nix develop`: + +```sh +cargo fmt --all -- --check +cargo clippy --locked --workspace --all-targets -- -D warnings +cargo deny --locked check +``` + +`deny.toml` lists the allowed licences and every ignored advisory with its +reason. + ## Storage and TTL The contracts read and write through the shared helpers in diff --git a/flake.nix b/flake.nix index 4b742272..d376f4b3 100644 --- a/flake.nix +++ b/flake.nix @@ -74,6 +74,7 @@ pkgs.twiggy # wasm size profiler pkgs.cargo-nextest pkgs.cargo-llvm-cov # line/region coverage (needs llvm-tools-preview) + pkgs.cargo-deny # licence, advisory and source checks (deny.toml) pkgs.nodejs_22 # Formal methods pkgs.tlaplus # tlc + pcal (TLA+ / PlusCal) From fc396004e76f3cbcf81f0ff85d3591a2b1a80c96 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:23:44 -0300 Subject: [PATCH 140/147] feat(oracle): anchor a proof to every report An attestation carried only the price and its expiry, so nothing on chain linked the number to its evidence. attest now takes a NavInput with a 32-byte proof hash and an optional URI. The oracle stores both with the acceptance time, returns them from latest() and emits them in NavAttested. It anchors the proof and never verifies it. An all-zero hash is refused (EmptyProofHash, 3023), and so is a URI over 256 bytes (ProofUriTooLong, 3024). The input no longer carries a timestamp the oracle ignored; the stored report names its acceptance time accepted_at. An oracle upgraded in place cannot read a report an earlier release stored. No migration rewrites it: governance pauses the NAV, clears the report, lifts the pause, and the attester posts a new one. A test stores a report of the earlier shape and recovers it that way, and the kit spec lists the steps. --- contracts/async-vault/src/test/controls.rs | 18 +- contracts/async-vault/src/test/mod.rs | 23 +- contracts/async-vault/src/test/oracle_swap.rs | 9 +- contracts/async-vault/src/test/ttl.rs | 10 +- contracts/nav-oracle/src/guards.rs | 18 +- contracts/nav-oracle/src/lib.rs | 57 +++-- contracts/nav-oracle/src/state.rs | 27 ++- contracts/nav-oracle/src/test.rs | 222 ++++++++++++------ contracts/nav-oracle/src/test_governance.rs | 32 ++- contracts/nav-oracle/src/test_guards.rs | 53 ++++- docs/AUTHORIZATION_MATRIX.md | 2 +- docs/TESTING.md | 3 +- docs/kit-spec.md | 18 +- docs/strata-product-and-architecture.md | 17 +- 14 files changed, 339 insertions(+), 170 deletions(-) diff --git a/contracts/async-vault/src/test/controls.rs b/contracts/async-vault/src/test/controls.rs index 44059782..34adbc99 100644 --- a/contracts/async-vault/src/test/controls.rs +++ b/contracts/async-vault/src/test/controls.rs @@ -165,14 +165,7 @@ fn a_cooldown_holds_the_standing_price() { f.advance(60); assert!(f .oracle - .try_attest( - &NavReport { - nav_per_share: wad(1), - timestamp: 0, - expires_at: 1_000_000, - }, - &f.attester - ) + .try_attest(&nav_input(&f.e, wad(1), 1_000_000), &f.attester) .is_err()); f.vault.fulfill_epoch(&epoch); @@ -196,14 +189,7 @@ fn a_cooldown_delays_pricing_until_the_attester_can_post() { // valuation younger than its close and waits. assert!(f .oracle - .try_attest( - &NavReport { - nav_per_share: wad(1), - timestamp: 0, - expires_at: 1_000_000, - }, - &f.attester - ) + .try_attest(&nav_input(&f.e, wad(1), 1_000_000), &f.attester) .is_err()); refused( f.vault.try_fulfill_epoch(&epoch), diff --git a/contracts/async-vault/src/test/mod.rs b/contracts/async-vault/src/test/mod.rs index 87b8cfe7..10940c0a 100644 --- a/contracts/async-vault/src/test/mod.rs +++ b/contracts/async-vault/src/test/mod.rs @@ -41,7 +41,7 @@ pub(crate) use soroban_sdk::{ pub(crate) use ::compliance::{Compliance, ComplianceChange, ComplianceClient}; pub(crate) use identity_verifier::{IdentityVerifier, IdentityVerifierClient}; -pub(crate) use nav_oracle::{NavOracleContract, NavOracleContractClient, NavReport, OracleConfig}; +pub(crate) use nav_oracle::{NavInput, NavOracleContract, NavOracleContractClient, OracleConfig}; pub(crate) use share_token::{ShareToken, ShareTokenClient}; pub(crate) use stellar_contract_utils::math::wad::WAD_SCALE; @@ -58,6 +58,15 @@ fn wad(whole: i128) -> i128 { whole * WAD_SCALE } +fn nav_input(e: &Env, nav_per_share: i128, expires_at: u64) -> NavInput { + NavInput { + nav_per_share, + expires_at, + proof_hash: BytesN::from_array(e, &[7; 32]), + proof_uri: String::from_str(e, "ipfs://strata-proof"), + } +} + struct Fixture<'a> { e: Env, vault: AsyncVaultClient<'a>, @@ -148,21 +157,15 @@ impl Fixture<'_> { /// the cooldown first when the last report is too recent. fn attest(&self, nav_per_share: i128) { self.wait_out_the_cooldown(); - self.oracle.attest( - &NavReport { - nav_per_share, - timestamp: 0, - expires_at: u64::MAX, - }, - &self.attester, - ); + self.oracle + .attest(&nav_input(&self.e, nav_per_share, u64::MAX), &self.attester); } fn wait_out_the_cooldown(&self) { let Ok(Ok(latest)) = self.oracle.try_latest() else { return; }; - let ready = latest.timestamp + self.oracle.config().cooldown_secs; + let ready = latest.accepted_at + self.oracle.config().cooldown_secs; let now = self.e.ledger().timestamp(); if now < ready { self.advance(ready - now); diff --git a/contracts/async-vault/src/test/oracle_swap.rs b/contracts/async-vault/src/test/oracle_swap.rs index 365fbdcd..19fcd254 100644 --- a/contracts/async-vault/src/test/oracle_swap.rs +++ b/contracts/async-vault/src/test/oracle_swap.rs @@ -58,14 +58,7 @@ fn second_feed<'a>(f: &Fixture<'a>) -> NavOracleContractClient<'a> { } fn attest_on(f: &Fixture, feed: &NavOracleContractClient, nav_per_share: i128) { - feed.attest( - &NavReport { - nav_per_share, - timestamp: 0, - expires_at: u64::MAX, - }, - &f.attester, - ); + feed.attest(&nav_input(&f.e, nav_per_share, u64::MAX), &f.attester); } #[test] diff --git a/contracts/async-vault/src/test/ttl.rs b/contracts/async-vault/src/test/ttl.rs index 2a7a7485..e39064c2 100644 --- a/contracts/async-vault/src/test/ttl.rs +++ b/contracts/async-vault/src/test/ttl.rs @@ -155,14 +155,8 @@ fn a_pending_request_still_claims_after_most_of_a_month() { let epoch = f.close_epoch(); f.advance(29 * DAY_DURATION); - f.oracle.attest( - &NavReport { - nav_per_share: wad(2), - timestamp: 0, - expires_at: u64::MAX, - }, - &f.attester, - ); + f.oracle + .attest(&nav_input(&f.e, wad(2), u64::MAX), &f.attester); f.vault.fulfill_epoch(&epoch); assert_eq!(f.vault.claim_deposit(&user, &epoch), 200); diff --git a/contracts/nav-oracle/src/guards.rs b/contracts/nav-oracle/src/guards.rs index 7d9436d7..d4e7481e 100644 --- a/contracts/nav-oracle/src/guards.rs +++ b/contracts/nav-oracle/src/guards.rs @@ -1,11 +1,11 @@ -use soroban_sdk::{panic_with_error, Env}; +use soroban_sdk::{panic_with_error, BytesN, Env, String}; use stellar_access::{access_control::AccessControlStorageKey, role_transfer}; use strata_timelock::{queue, setup}; use crate::state::{self, get_config, NavReport, OracleConfig, OracleState}; use crate::{ compute_state, in_band, shortens_freshness_only, OracleChange, OracleError, BPS_DENOM, - MAX_ANSWER, MAX_FRESHNESS_SECS, MIN_COOLDOWN_SECS, + MAX_ANSWER, MAX_FRESHNESS_SECS, MAX_PROOF_URI_LEN, MIN_COOLDOWN_SECS, }; pub(crate) fn valid_config(e: &Env, cfg: &OracleConfig) { @@ -113,8 +113,20 @@ pub(crate) fn expires_in_future(e: &Env, expires_at: u64) { } } +pub(crate) fn proof_hash_not_empty(e: &Env, proof_hash: &BytesN<32>) { + if proof_hash.to_array() == [0; 32] { + panic_with_error!(e, OracleError::EmptyProofHash); + } +} + +pub(crate) fn proof_uri_fits(e: &Env, proof_uri: &String) { + if proof_uri.len() > MAX_PROOF_URI_LEN { + panic_with_error!(e, OracleError::ProofUriTooLong); + } +} + pub(crate) fn cooldown_elapsed(e: &Env, cfg: &OracleConfig, last: &NavReport) { - if e.ledger().timestamp() < last.timestamp.saturating_add(cfg.cooldown_secs) { + if e.ledger().timestamp() < last.accepted_at.saturating_add(cfg.cooldown_secs) { panic_with_error!(e, OracleError::CooldownActive); } } diff --git a/contracts/nav-oracle/src/lib.rs b/contracts/nav-oracle/src/lib.rs index bb130e1d..10b80b5a 100644 --- a/contracts/nav-oracle/src/lib.rs +++ b/contracts/nav-oracle/src/lib.rs @@ -7,6 +7,9 @@ //! - **The ledger dates a report.** It expires at the earlier of `expires_at` //! and its acceptance plus `freshness_duration`. The cost: the vault compares //! acceptance times, not valuation dates. +//! - **The proof is anchored, never verified.** A report carries a hash of its +//! evidence and may say where to read it. The cost: any hash that is not all +//! zeros passes, so a reader fetches the evidence and compares the hash. //! - **No report while the NAV is paused.** The guardian or the admin pauses //! it; only the admin lifts it. The cost: the vault prices nothing meanwhile, //! and sealed epochs reopen to cancellation. @@ -37,8 +40,8 @@ mod guards; mod state; use soroban_sdk::{ - contract, contracterror, contractevent, contractimpl, panic_with_error, Address, Env, Symbol, - Vec, + contract, contracterror, contractevent, contractimpl, panic_with_error, Address, BytesN, Env, + String, Symbol, Vec, }; use stellar_access::access_control::{self, AccessControl}; use stellar_contract_utils::upgradeable; @@ -50,7 +53,7 @@ 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}; +pub use state::{NavInput, NavReport, OracleConfig, OracleState}; const BPS_DENOM: i128 = 10_000; const WAD: i128 = 1_000_000_000_000_000_000; @@ -62,6 +65,8 @@ pub const MAX_FRESHNESS_SECS: u64 = 35 * strata_timelock::DAY_DURATION; /// A million units of the asset per share. Far above any real price, and far /// below where the deviation arithmetic could overflow. pub const MAX_ANSWER: i128 = 1_000_000 * WAD; +/// Longest proof URI an attestation may carry, in bytes. +pub const MAX_PROOF_URI_LEN: u32 = 256; strata_bindings::error_enum! { strata_bindings::ORACLE_ERRORS, @@ -124,6 +129,10 @@ strata_bindings::error_enum! { DelayBelowNotice = 3020, /// An upgrade already waits. Cancel it before proposing another. UpgradeAlreadyPending = 3021, + /// The proof hash is all zeros, so it commits to nothing. + EmptyProofHash = 3023, + /// The proof URI is longer than `MAX_PROOF_URI_LEN` bytes. + ProofUriTooLong = 3024, /// 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. @@ -140,6 +149,8 @@ pub struct NavAttested { pub attester: Address, pub nav_per_share: i128, pub expires_at: u64, + pub proof_hash: BytesN<32>, + pub proof_uri: String, } #[contractevent] @@ -512,8 +523,9 @@ impl NavOracleContract { governance::pending(e) } - /// The stored report: `nav_per_share` in WAD, `timestamp` and - /// `expires_at` in seconds. + /// The stored report: `nav_per_share` in WAD, `accepted_at` and + /// `expires_at` in seconds, and the proof the attester gave with it, + /// `proof_hash` and `proof_uri`. /// /// # Errors /// @@ -541,15 +553,16 @@ impl NavOracleContract { guards::consumable(e); } - /// Stores a valuation for the vault to price epochs from. The stored - /// `expires_at` is the earlier of the report's and the time of acceptance - /// plus `freshness_duration`. + /// Stores a valuation and its proof for the vault to price epochs from. + /// The stored `expires_at` is the earlier of the input's and the time of + /// acceptance plus `freshness_duration`. It never verifies the proof. /// /// # Arguments /// - /// * `report` - `nav_per_share` in WAD: asset base units per share base + /// * `input` - `nav_per_share` in WAD: asset base units per share base /// unit. `expires_at` in seconds: the last moment the attester vouches - /// for it. `timestamp` is ignored; the ledger time is stored. + /// for it. `proof_hash` hashes the evidence; `proof_uri` says where to + /// read it, or is empty. /// * `caller` - An `attester`. /// /// # Errors @@ -558,6 +571,8 @@ impl NavOracleContract { /// * `NavPaused` - The NAV is paused. /// * `NavOutOfBand` - `nav_per_share` is outside the band. /// * `ExpiresInPast` - `expires_at` is not in the future. + /// * `EmptyProofHash` - `proof_hash` is all zeros. + /// * `ProofUriTooLong` - `proof_uri` is over 256 bytes. /// * `CooldownActive` - The last report is younger than `cooldown_secs`. /// * `DeviationExceeded` - The move from the last report exceeds its cap. /// @@ -569,24 +584,28 @@ impl NavOracleContract { /// /// `caller` signs. #[only_role(caller, "attester")] - pub fn attest(e: &Env, report: NavReport, caller: Address) { + pub fn attest(e: &Env, input: NavInput, caller: Address) { guards::nav_not_paused(e); let cfg = get_config(e); - guards::nav_in_band(e, &cfg, report.nav_per_share); - guards::expires_in_future(e, report.expires_at); + guards::nav_in_band(e, &cfg, input.nav_per_share); + guards::expires_in_future(e, input.expires_at); + guards::proof_hash_not_empty(e, &input.proof_hash); + guards::proof_uri_fits(e, &input.proof_uri); if let Some(last) = get_latest(e) { guards::cooldown_elapsed(e, &cfg, &last); - guards::within_deviation(e, &cfg, &last, report.nav_per_share); + guards::within_deviation(e, &cfg, &last, input.nav_per_share); } strata_storage::bump_instance(e); let now = e.ledger().timestamp(); let stored = NavReport { - nav_per_share: report.nav_per_share, - timestamp: now, - expires_at: report + nav_per_share: input.nav_per_share, + expires_at: input .expires_at .min(now.saturating_add(cfg.freshness_duration)), + accepted_at: now, + proof_hash: input.proof_hash, + proof_uri: input.proof_uri, }; state::set_latest(e, &stored); @@ -594,6 +613,8 @@ impl NavOracleContract { attester: caller, nav_per_share: stored.nav_per_share, expires_at: stored.expires_at, + proof_hash: stored.proof_hash, + proof_uri: stored.proof_uri, } .publish(e); } @@ -725,7 +746,7 @@ impl OracleFeed for NavOracleContract { /// /// * `NoRecord` - No report is stored. fn attested_at(e: &Env) -> u64 { - Self::latest(e).timestamp + Self::latest(e).accepted_at } /// How long a report stays valid after it is accepted, in seconds. diff --git a/contracts/nav-oracle/src/state.rs b/contracts/nav-oracle/src/state.rs index d0e74860..94508fba 100644 --- a/contracts/nav-oracle/src/state.rs +++ b/contracts/nav-oracle/src/state.rs @@ -1,4 +1,4 @@ -use soroban_sdk::{contracttype, panic_with_error, symbol_short, Env, Symbol}; +use soroban_sdk::{contracttype, panic_with_error, symbol_short, BytesN, Env, String, Symbol}; use crate::OracleError; @@ -17,16 +17,31 @@ pub const ATTESTER_ROLE: Symbol = symbol_short!("attester"); /// Pauses the NAV in an emergency. Cannot unpause it. pub const GUARDIAN_ROLE: Symbol = symbol_short!("guardian"); -/// A valuation. On input, `expires_at` is the latest moment the reporter -/// vouches for it, and `timestamp` is ignored. As stored, `timestamp` is the -/// ledger time the oracle accepted the report, and `expires_at` is the -/// reporter's expiry capped at that time plus `freshness_duration`. +/// What the attester submits. The proof is anchored, never verified: the hash +/// commits to the evidence behind the price, and the URI says where to find it. +#[contracttype] +#[derive(Clone)] +pub struct NavInput { + pub nav_per_share: i128, + /// The reporter's own expiry. The contract keeps the earlier of this and + /// its freshness window. + pub expires_at: u64, + pub proof_hash: BytesN<32>, + /// Where the evidence can be read. Empty when the hash alone is published. + pub proof_uri: String, +} + +/// What the contract stores and serves. #[contracttype] #[derive(Clone)] pub struct NavReport { pub nav_per_share: i128, + /// The reporter's expiry, capped at `accepted_at` plus `freshness_duration`. pub expires_at: u64, - pub timestamp: u64, + /// Ledger time the contract accepted the report. + pub accepted_at: u64, + pub proof_hash: BytesN<32>, + pub proof_uri: String, } #[contracttype] diff --git a/contracts/nav-oracle/src/test.rs b/contracts/nav-oracle/src/test.rs index ad5e3b0d..dd97ed49 100644 --- a/contracts/nav-oracle/src/test.rs +++ b/contracts/nav-oracle/src/test.rs @@ -5,11 +5,11 @@ use soroban_sdk::{ testutils::{ storage::Persistent as _, Address as _, Events as _, Ledger as _, MockAuth, MockAuthInvoke, }, - Address, Env, Event as _, IntoVal, + Address, BytesN, Env, Event as _, IntoVal, String, }; use crate::state::DataKey; -use crate::{NavOracleContract, NavOracleContractClient, NavReport, OracleConfig, OracleState}; +use crate::{NavInput, NavOracleContract, NavOracleContractClient, OracleConfig, OracleState}; const SCALE: i128 = 1_000_000_000_000_000_000; const DAY_IN_LEDGERS: u32 = 17_280; @@ -72,11 +72,16 @@ fn setup<'a>() -> Fixture<'a> { } } -fn report(_e: &Env, nav: i128, timestamp: u64, expires_at: u64) -> NavReport { - NavReport { +fn proof(e: &Env) -> BytesN<32> { + BytesN::from_array(e, &[7; 32]) +} + +fn input(e: &Env, nav: i128, expires_at: u64) -> NavInput { + NavInput { nav_per_share: nav, - timestamp, expires_at, + proof_hash: proof(e), + proof_uri: String::from_str(e, "ipfs://strata-proof"), } } @@ -87,7 +92,7 @@ fn attest_then_valid_and_consumable() { assert_eq!(f.oracle.state(), OracleState::Stale); assert!(f.oracle.is_stale()); - let r = report(&f.e, SCALE, 1, 1_000_000); + let r = input(&f.e, SCALE, 1_000_000); f.oracle.attest(&r, &f.attester); assert_eq!(f.oracle.state(), OracleState::Valid); @@ -98,7 +103,7 @@ fn attest_then_valid_and_consumable() { #[test] fn heartbeat_expiry_makes_it_stale() { let f = setup(); - let r = report(&f.e, SCALE, 1, 1_000_000); + let r = input(&f.e, SCALE, 1_000_000); f.oracle.attest(&r, &f.attester); assert_eq!(f.oracle.state(), OracleState::Valid); @@ -111,7 +116,7 @@ fn heartbeat_expiry_makes_it_stale() { #[test] fn the_nav_pause_holds_regardless_of_freshness() { let f = setup(); - let r = report(&f.e, SCALE, 1, 1_000_000); + let r = input(&f.e, SCALE, 1_000_000); f.oracle.attest(&r, &f.attester); assert_eq!(f.oracle.state(), OracleState::Valid); @@ -127,54 +132,53 @@ fn the_nav_pause_holds_regardless_of_freshness() { fn attest_rejects_out_of_band() { let f = setup(); // Below min_answer. - let r = report(&f.e, SCALE / 4, 1, 1_000_000); + let r = input(&f.e, SCALE / 4, 1_000_000); assert!(f.oracle.try_attest(&r, &f.attester).is_err()); // Zero is out of band (min_answer > 0). - let r0 = report(&f.e, 0, 1, 1_000_000); + let r0 = input(&f.e, 0, 1_000_000); assert!(f.oracle.try_attest(&r0, &f.attester).is_err()); } #[test] fn attest_enforces_cooldown_and_deviation() { let f = setup(); - let r1 = report(&f.e, SCALE, 1, 1_000_000); + let r1 = input(&f.e, SCALE, 1_000_000); f.oracle.attest(&r1, &f.attester); // Cooldown: only 30s later (< 60), reject. f.e.ledger().set_timestamp(10_030); - let r2 = report(&f.e, SCALE, 2, 1_000_000); + let r2 = input(&f.e, SCALE, 1_000_000); assert!(f.oracle.try_attest(&r2, &f.attester).is_err()); // Past cooldown now. f.e.ledger().set_timestamp(10_100); // Deviation: +20% exceeds the 10% cap. - let big = report(&f.e, SCALE + SCALE / 5, 2, 1_000_000); + let big = input(&f.e, SCALE + SCALE / 5, 1_000_000); assert!(f.oracle.try_attest(&big, &f.attester).is_err()); // Valid update: +5% within cap. - let ok = report(&f.e, SCALE + SCALE / 20, 2, 1_000_000); + let ok = input(&f.e, SCALE + SCALE / 20, 1_000_000); f.oracle.attest(&ok, &f.attester); assert_eq!(f.oracle.nav_per_share(), SCALE + SCALE / 20); } -/// The caller's `timestamp` is advisory: the contract stamps ledger time and -/// derives `expires_at` from its own freshness window. +/// The contract stamps ledger time and derives `expires_at` from its own +/// freshness window. #[test] fn attest_stamps_its_own_timestamp() { let f = setup(); - let r = report(&f.e, SCALE, 1, 1_000_000); + let r = input(&f.e, SCALE, 1_000_000); f.oracle.attest(&r, &f.attester); let stored = f.oracle.latest(); - assert_eq!(stored.timestamp, 10_000); + assert_eq!(stored.accepted_at, 10_000); assert_eq!(stored.expires_at, 10_000 + 3600); } #[test] fn attesting_publishes_the_stored_valuation() { let f = setup(); - f.oracle - .attest(&report(&f.e, SCALE, 1, 1_000_000), &f.attester); + f.oracle.attest(&input(&f.e, SCALE, 1_000_000), &f.attester); assert_eq!( f.e.events().all().filter_by_contract(&f.oracle.address), @@ -182,6 +186,8 @@ fn attesting_publishes_the_stored_valuation() { attester: f.attester.clone(), nav_per_share: SCALE, expires_at: 10_000 + 3600, + proof_hash: proof(&f.e), + proof_uri: String::from_str(&f.e, "ipfs://strata-proof"), } .to_xdr(&f.e, &f.oracle.address)] ); @@ -190,8 +196,7 @@ fn attesting_publishes_the_stored_valuation() { #[test] fn the_latest_valuation_keeps_a_full_ttl_while_it_is_read() { let f = setup(); - f.oracle - .attest(&report(&f.e, SCALE, 1, 1_000_000), &f.attester); + f.oracle.attest(&input(&f.e, SCALE, 1_000_000), &f.attester); f.e.ledger() .with_mut(|l| l.sequence_number += 2 * DAY_IN_LEDGERS); @@ -211,7 +216,7 @@ fn the_latest_valuation_keeps_a_full_ttl_while_it_is_read() { #[test] fn set_config_takes_effect() { let f = setup(); - let r1 = report(&f.e, SCALE, 1, 1_000_000); + let r1 = input(&f.e, SCALE, 1_000_000); f.oracle.attest(&r1, &f.attester); // Tighten the per-attestation deviation band to 1%; a subsequent +5% move @@ -224,7 +229,7 @@ fn set_config_takes_effect() { f.oracle.set_config(&tight); f.e.ledger().set_timestamp(10_100); - let five_pct = report(&f.e, SCALE + SCALE / 20, 2, 1_000_000); + let five_pct = input(&f.e, SCALE + SCALE / 20, 1_000_000); assert!( f.oracle.try_attest(&five_pct, &f.attester).is_err(), "the tightened band rejects a +5% move" @@ -235,14 +240,14 @@ fn set_config_takes_effect() { fn attest_is_role_gated() { let f = setup(); let stranger = Address::generate(&f.e); - let r = report(&f.e, SCALE, 1, 1_000_000); + let r = input(&f.e, SCALE, 1_000_000); assert!(f.oracle.try_attest(&r, &stranger).is_err()); } #[test] fn narrowing_the_band_demotes_a_stored_record() { let f = setup(); - let r = report(&f.e, SCALE, 1, 1_000_000); + let r = input(&f.e, SCALE, 1_000_000); f.oracle.attest(&r, &f.attester); assert_eq!(f.oracle.state(), OracleState::Valid); @@ -325,7 +330,7 @@ fn only_admin_can_set_config() { #[test] fn the_guardian_pauses_the_nav_but_unpausing_needs_governance() { let f = setup(); - let r = report(&f.e, SCALE, 1, 1_000_000); + let r = input(&f.e, SCALE, 1_000_000); f.oracle.attest(&r, &f.attester); assert_eq!(f.oracle.state(), OracleState::Valid); @@ -383,12 +388,11 @@ fn a_full_downside_cap_lets_a_large_loss_land_at_once() { cfg.max_down_bps = 10_000; let f = setup_with(cfg); - f.oracle - .attest(&report(&f.e, SCALE, 1, 1_000_000), &f.attester); + f.oracle.attest(&input(&f.e, SCALE, 1_000_000), &f.attester); f.e.ledger().set_timestamp(10_100); // A 40% fall, far past the 10% upward cap, is admitted at once. - let crash = report(&f.e, SCALE * 60 / 100, 2, 1_000_000); + let crash = input(&f.e, SCALE * 60 / 100, 1_000_000); f.oracle.attest(&crash, &f.attester); assert_eq!(f.oracle.nav_per_share(), SCALE * 60 / 100); } @@ -399,11 +403,10 @@ fn a_full_downside_cap_still_bounds_a_rise() { cfg.max_down_bps = 10_000; let f = setup_with(cfg); - f.oracle - .attest(&report(&f.e, SCALE, 1, 1_000_000), &f.attester); + f.oracle.attest(&input(&f.e, SCALE, 1_000_000), &f.attester); f.e.ledger().set_timestamp(10_100); - let spike = report(&f.e, SCALE * 140 / 100, 2, 1_000_000); + let spike = input(&f.e, SCALE * 140 / 100, 1_000_000); assert!(f.oracle.try_attest(&spike, &f.attester).is_err()); } @@ -411,11 +414,10 @@ fn a_full_downside_cap_still_bounds_a_rise() { fn a_configured_downside_still_bounds_a_fall() { let f = setup(); - f.oracle - .attest(&report(&f.e, SCALE, 1, 1_000_000), &f.attester); + f.oracle.attest(&input(&f.e, SCALE, 1_000_000), &f.attester); f.e.ledger().set_timestamp(10_100); - let crash = report(&f.e, SCALE * 60 / 100, 2, 1_000_000); + let crash = input(&f.e, SCALE * 60 / 100, 1_000_000); assert!(f.oracle.try_attest(&crash, &f.attester).is_err()); } @@ -437,8 +439,7 @@ fn constructor_rejects_a_zero_upside_cap() { #[test] fn the_record_clears_only_while_the_nav_is_paused() { let f = setup(); - f.oracle - .attest(&report(&f.e, SCALE, 1, 1_000_000), &f.attester); + f.oracle.attest(&input(&f.e, SCALE, 1_000_000), &f.attester); assert!(f.oracle.try_clear_latest().is_err()); @@ -448,7 +449,7 @@ fn the_record_clears_only_while_the_nav_is_paused() { // With no stored record, one in-band value lands however far it sits. f.oracle.set_nav_paused(&false); f.e.ledger().set_timestamp(10_100); - let far = report(&f.e, SCALE * 50, 2, 1_000_000); + let far = input(&f.e, SCALE * 50, 1_000_000); f.oracle.attest(&far, &f.attester); assert_eq!(f.oracle.nav_per_share(), SCALE * 50); } @@ -457,7 +458,7 @@ fn the_record_clears_only_while_the_nav_is_paused() { fn only_admin_can_clear_latest() { let f = setup(); let stranger = Address::generate(&f.e); - let r = report(&f.e, SCALE, 1, 1_000_000); + let r = input(&f.e, SCALE, 1_000_000); f.oracle.attest(&r, &f.attester); f.oracle.pause_nav(&f.guardian); @@ -501,21 +502,19 @@ fn a_full_downside_cap_leaves_min_answer_as_the_only_floor() { cfg.max_down_bps = 10_000; let f = setup_with(cfg); - f.oracle - .attest(&report(&f.e, SCALE, 1, 1_000_000), &f.attester); + f.oracle.attest(&input(&f.e, SCALE, 1_000_000), &f.attester); f.e.ledger().set_timestamp(10_100); // Straight to the floor in one step, whatever the distance. let floor = config().min_answer; - f.oracle - .attest(&report(&f.e, floor, 2, 1_000_000), &f.attester); + f.oracle.attest(&input(&f.e, floor, 1_000_000), &f.attester); assert_eq!(f.oracle.nav_per_share(), floor); // Below the floor is refused, so min_answer is the only bound. f.e.ledger().set_timestamp(10_200); assert!(f .oracle - .try_attest(&report(&f.e, floor - 1, 3, 1_000_000), &f.attester) + .try_attest(&input(&f.e, floor - 1, 1_000_000), &f.attester) .is_err()); } @@ -524,8 +523,7 @@ fn attested_at_is_when_the_contract_took_the_report_not_when_the_caller_says() { let f = setup(); f.e.ledger().set_timestamp(50_000); - f.oracle - .attest(&report(&f.e, SCALE, 7, 1_000_000), &f.attester); + f.oracle.attest(&input(&f.e, SCALE, 1_000_000), &f.attester); assert_eq!(f.oracle.attested_at(), 50_000); } @@ -541,7 +539,7 @@ fn attested_at_without_a_report_is_refused() { fn a_reporter_expiry_inside_the_window_is_kept() { let f = setup(); f.oracle - .attest(&report(&f.e, SCALE, 0, 10_000 + 600), &f.attester); + .attest(&input(&f.e, SCALE, 10_000 + 600), &f.attester); assert_eq!(f.oracle.latest().expires_at, 10_000 + 600); f.e.ledger().set_timestamp(10_000 + 601); @@ -552,21 +550,11 @@ fn a_reporter_expiry_inside_the_window_is_kept() { fn a_reporter_expiry_past_the_window_is_cut_to_the_window() { let f = setup(); f.oracle - .attest(&report(&f.e, SCALE, 0, 10_000 + 10_000), &f.attester); + .attest(&input(&f.e, SCALE, 10_000 + 10_000), &f.attester); assert_eq!(f.oracle.latest().expires_at, 10_000 + 3600); } -#[test] -fn the_reporters_timestamp_is_ignored() { - let f = setup(); - f.oracle - .attest(&report(&f.e, SCALE, 99_999, 1_000_000), &f.attester); - - assert_eq!(f.oracle.latest().timestamp, 10_000); - assert_eq!(f.oracle.attested_at(), 10_000); -} - #[test] fn attesting_is_refused_while_the_nav_is_paused() { let f = setup(); @@ -574,14 +562,13 @@ fn attesting_is_refused_while_the_nav_is_paused() { assert_eq!( f.oracle - .try_attest(&report(&f.e, SCALE, 0, 1_000_000), &f.attester) + .try_attest(&input(&f.e, SCALE, 1_000_000), &f.attester) .err(), Some(Ok(crate::OracleError::NavPaused.into())) ); f.oracle.set_nav_paused(&false); - f.oracle - .attest(&report(&f.e, SCALE, 0, 1_000_000), &f.attester); + f.oracle.attest(&input(&f.e, SCALE, 1_000_000), &f.attester); assert_eq!(f.oracle.state(), OracleState::Valid); } @@ -590,20 +577,18 @@ fn two_reports_in_one_ledger_are_refused_at_the_shortest_cooldown() { let mut cfg = config(); cfg.cooldown_secs = crate::MIN_COOLDOWN_SECS; let f = setup_with(cfg); - f.oracle - .attest(&report(&f.e, SCALE, 0, 1_000_000), &f.attester); + f.oracle.attest(&input(&f.e, SCALE, 1_000_000), &f.attester); assert_eq!( f.oracle - .try_attest(&report(&f.e, SCALE, 0, 1_000_000), &f.attester) + .try_attest(&input(&f.e, SCALE, 1_000_000), &f.attester) .err(), Some(Ok(crate::OracleError::CooldownActive.into())) ); f.e.ledger() .set_timestamp(10_000 + crate::MIN_COOLDOWN_SECS); - f.oracle - .attest(&report(&f.e, SCALE, 0, 1_000_000), &f.attester); + f.oracle.attest(&input(&f.e, SCALE, 1_000_000), &f.attester); } fn refuses_config(cfg: OracleConfig) { @@ -675,8 +660,7 @@ fn implements_the_feed() {} fn the_oracle_answers_the_feed_interface_the_vault_reads() { implements_the_feed::(); let f = setup(); - f.oracle - .attest(&report(&f.e, SCALE, 0, 1_000_000), &f.attester); + f.oracle.attest(&input(&f.e, SCALE, 1_000_000), &f.attester); let feed = strata_bindings::OracleFeedClient::new(&f.e, &f.oracle.address); @@ -698,3 +682,103 @@ fn a_missing_config_is_refused_by_name() { Err(Ok(crate::OracleError::NotInitialized.into())) ); } + +mod proof_reference { + use soroban_sdk::contracttype; + + use super::*; + use crate::{OracleError, MAX_PROOF_URI_LEN}; + + #[test] + fn the_latest_report_carries_its_proof() { + let f = setup(); + + f.oracle.attest(&input(&f.e, SCALE, 1_000_000), &f.attester); + + let latest = f.oracle.latest(); + assert_eq!(latest.proof_hash, proof(&f.e)); + assert_eq!( + latest.proof_uri, + String::from_str(&f.e, "ipfs://strata-proof") + ); + } + + #[test] + fn a_report_without_a_proof_hash_is_refused() { + let f = setup(); + let mut unproven = input(&f.e, SCALE, 1_000_000); + unproven.proof_hash = BytesN::from_array(&f.e, &[0; 32]); + + assert_eq!( + f.oracle.try_attest(&unproven, &f.attester).err(), + Some(Ok(OracleError::EmptyProofHash.into())), + ); + } + + #[test] + fn a_proof_uri_past_the_limit_is_refused() { + let f = setup(); + let mut long = input(&f.e, SCALE, 1_000_000); + long.proof_uri = String::from_bytes(&f.e, &[b'x'; (MAX_PROOF_URI_LEN + 1) as usize]); + + assert_eq!( + f.oracle.try_attest(&long, &f.attester).err(), + Some(Ok(OracleError::ProofUriTooLong.into())), + ); + } + + #[test] + fn a_hash_without_a_uri_is_enough() { + let f = setup(); + let mut bare = input(&f.e, SCALE, 1_000_000); + bare.proof_uri = String::from_str(&f.e, ""); + + f.oracle.attest(&bare, &f.attester); + + assert_eq!(f.oracle.latest().proof_uri.len(), 0); + } + + /// The report as earlier releases stored it: same fields, same encoding. + #[contracttype] + struct EarlierNavReport { + nav_per_share: i128, + expires_at: u64, + timestamp: u64, + } + + #[test] + fn a_report_of_the_earlier_shape_is_cleared_and_replaced() { + let f = setup(); + f.e.as_contract(&f.oracle.address, || { + f.e.storage().persistent().set( + &DataKey::Latest, + &EarlierNavReport { + nav_per_share: SCALE, + expires_at: 10_000 + 3600, + timestamp: 10_000, + }, + ) + }); + f.e.ledger().set_timestamp(10_100); + let next = input(&f.e, SCALE * 2, 1_000_000); + + assert!(f.oracle.try_latest().is_err()); + assert!(f.oracle.try_state().is_err()); + assert!(f.oracle.try_attest(&next, &f.attester).is_err()); + + f.oracle.pause_nav(&f.guardian); + assert_eq!(f.oracle.state(), OracleState::Paused); + f.oracle.clear_latest(); + f.oracle.set_nav_paused(&false); + f.oracle.attest(&next, &f.attester); + + let latest = f.oracle.latest(); + assert_eq!(latest.nav_per_share, SCALE * 2); + assert_eq!(latest.accepted_at, 10_100); + assert_eq!(latest.proof_hash, proof(&f.e)); + assert_eq!( + latest.proof_uri, + String::from_str(&f.e, "ipfs://strata-proof") + ); + } +} diff --git a/contracts/nav-oracle/src/test_governance.rs b/contracts/nav-oracle/src/test_governance.rs index abdf1d10..6215bde4 100644 --- a/contracts/nav-oracle/src/test_governance.rs +++ b/contracts/nav-oracle/src/test_governance.rs @@ -7,7 +7,7 @@ use soroban_sdk::{ Address as _, AuthorizedFunction, AuthorizedInvocation, Events as _, Ledger as _, MockAuth, MockAuthInvoke, }, - Address, BytesN, Env, Event as _, IntoVal, Symbol, Val, + Address, BytesN, Env, Event as _, IntoVal, String, Symbol, Val, }; use stellar_access::access_control::{AccessControlStorageKey, ROLE_EXTEND_AMOUNT}; use stellar_access::role_transfer::RoleTransferError; @@ -16,8 +16,8 @@ use strata_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, + shortens_freshness_only, ChangeApplied, ChangeProposed, ConfigSet, NavInput, NavOracleContract, + NavOracleContractClient, OracleChange, OracleConfig, OracleError, OracleState, ADMIN_ACCEPT_LEDGERS, }; @@ -89,7 +89,8 @@ impl Fixture<'_> { } fn attest(&self, nav_per_share: i128) { - self.oracle.attest(&report(nav_per_share), &self.attester); + self.oracle + .attest(&input(&self.e, nav_per_share), &self.attester); } /// Lands an `Admin` change for `next` and returns the ledger of the offer. @@ -166,11 +167,16 @@ fn config() -> OracleConfig { } } -fn report(nav_per_share: i128) -> NavReport { - NavReport { +fn proof(e: &Env) -> BytesN<32> { + BytesN::from_array(e, &[7; 32]) +} + +fn input(e: &Env, nav_per_share: i128) -> NavInput { + NavInput { nav_per_share, expires_at: u64::MAX, - timestamp: 0, + proof_hash: proof(e), + proof_uri: String::from_str(e, "ipfs://strata-proof"), } } @@ -605,7 +611,10 @@ fn revoking_an_attester_or_a_guardian_stays_instant_after_setup() { 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_attest(&input(&f.e, SCALE), &f.attester) + .is_err()); assert!(f.oracle.try_pause_nav(&f.guardian).is_err()); } @@ -1095,11 +1104,11 @@ fn each_role_call_asks_its_holder_for_one_signature() { let f = setup(); let next = Address::generate(&f.e); - f.oracle.attest(&report(SCALE), &f.attester); + f.oracle.attest(&input(&f.e, SCALE), &f.attester); f.signed_once_by( &f.attester, "attest", - (report(SCALE), f.attester.clone()).into_val(&f.e), + (input(&f.e, SCALE), f.attester.clone()).into_val(&f.e), ); f.oracle.pause_nav(&f.guardian); f.signed_once_by( @@ -1203,9 +1212,10 @@ mod with_wasm { let kept = f.oracle.latest(); assert_eq!( - (kept.nav_per_share, kept.timestamp, kept.expires_at), + (kept.nav_per_share, kept.accepted_at, kept.expires_at), (SCALE, 10_000, 10_000 + config().freshness_duration) ); + assert_eq!(kept.proof_hash, proof(&f.e)); 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())); diff --git a/contracts/nav-oracle/src/test_guards.rs b/contracts/nav-oracle/src/test_guards.rs index 63b8989f..dd6f972f 100644 --- a/contracts/nav-oracle/src/test_guards.rs +++ b/contracts/nav-oracle/src/test_guards.rs @@ -4,14 +4,14 @@ use soroban_sdk::{ contract, contractimpl, symbol_short, testutils::{Address as _, Ledger as _}, - Address, BytesN, Env, InvokeError, + Address, BytesN, Env, InvokeError, String, }; use strata_timelock::{Clock, MIN_DELAY}; use crate::guards; use crate::{ - NavOracleContract, NavOracleContractClient, NavReport, OracleChange, OracleConfig, OracleError, - MAX_ANSWER, MAX_FRESHNESS_SECS, + NavInput, NavOracleContract, NavOracleContractClient, NavReport, OracleChange, OracleConfig, + OracleError, MAX_ANSWER, MAX_FRESHNESS_SECS, MAX_PROOF_URI_LEN, }; const SCALE: i128 = 1_000_000_000_000_000_000; @@ -49,10 +49,11 @@ impl Fixture<'_> { fn attest(&self, nav_per_share: i128) { self.oracle.attest( - &NavReport { + &NavInput { nav_per_share, expires_at: u64::MAX, - timestamp: 0, + proof_hash: proof(&self.e), + proof_uri: String::from_str(&self.e, "ipfs://strata-proof"), }, &self.attester, ); @@ -107,11 +108,17 @@ fn refuses(f: &Fixture, error: OracleError, guard: impl FnOnce()) { assert_eq!(run(f, guard), Err(Ok(error.into()))); } -fn report_at(nav_per_share: i128, timestamp: u64) -> NavReport { +fn proof(e: &Env) -> BytesN<32> { + BytesN::from_array(e, &[7; 32]) +} + +fn report_at(e: &Env, nav_per_share: i128, accepted_at: u64) -> NavReport { NavReport { nav_per_share, expires_at: u64::MAX, - timestamp, + accepted_at, + proof_hash: proof(e), + proof_uri: String::from_str(e, "ipfs://strata-proof"), } } @@ -329,13 +336,39 @@ fn expires_in_future_refuses_a_report_born_stale() { }); } +#[test] +fn proof_hash_not_empty_refuses_an_all_zero_hash() { + let f = setup(); + let mut last_byte_set = [0; 32]; + last_byte_set[31] = 1; + let set = BytesN::from_array(&f.e, &last_byte_set); + let empty = BytesN::from_array(&f.e, &[0; 32]); + + holds(&f, || guards::proof_hash_not_empty(&f.e, &set)); + refuses(&f, OracleError::EmptyProofHash, || { + guards::proof_hash_not_empty(&f.e, &empty) + }); +} + +#[test] +fn proof_uri_fits_refuses_a_uri_past_the_limit() { + let f = setup(); + let at_limit = String::from_bytes(&f.e, &[b'x'; MAX_PROOF_URI_LEN as usize]); + let past_it = String::from_bytes(&f.e, &[b'x'; MAX_PROOF_URI_LEN as usize + 1]); + + holds(&f, || guards::proof_uri_fits(&f.e, &at_limit)); + refuses(&f, OracleError::ProofUriTooLong, || { + guards::proof_uri_fits(&f.e, &past_it) + }); +} + #[test] fn cooldown_elapsed_refuses_a_report_inside_the_cooldown() { let f = setup(); let cfg = config(); let now = f.e.ledger().timestamp(); - let elapsed = report_at(SCALE, now - cfg.cooldown_secs); - let running = report_at(SCALE, now - cfg.cooldown_secs + 1); + let elapsed = report_at(&f.e, SCALE, now - cfg.cooldown_secs); + let running = report_at(&f.e, SCALE, now - cfg.cooldown_secs + 1); holds(&f, || guards::cooldown_elapsed(&f.e, &cfg, &elapsed)); refuses(&f, OracleError::CooldownActive, || { @@ -347,7 +380,7 @@ fn cooldown_elapsed_refuses_a_report_inside_the_cooldown() { fn within_deviation_refuses_a_move_past_either_cap() { let f = setup(); let cfg = config(); - let last = report_at(SCALE, 0); + let last = report_at(&f.e, SCALE, 0); let ten_percent = SCALE / 10; holds(&f, || { diff --git a/docs/AUTHORIZATION_MATRIX.md b/docs/AUTHORIZATION_MATRIX.md index af099a7f..9ca2b4dd 100644 --- a/docs/AUTHORIZATION_MATRIX.md +++ b/docs/AUTHORIZATION_MATRIX.md @@ -156,7 +156,7 @@ Tests: [`contracts/nav-oracle/src/test.rs`](../contracts/nav-oracle/src/test.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` | +| `attest` | `#[only_role(caller, "attester")]` | Refused while the NAV is paused; cooldown, deviation cap, bounds `[min, max]`; expiry capped at the freshness window; non-empty proof hash, proof URI ≤ 256 bytes | `test::attest_is_role_gated`, `test::attesting_is_refused_while_the_nav_is_paused`, `test::proof_reference::a_report_without_a_proof_hash_is_refused`, `test::proof_reference::a_proof_uri_past_the_limit_is_refused` | | `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]` | None; instant after setup too | `test::the_guardian_pauses_the_nav_but_unpausing_needs_governance` | | `clear_latest` | `#[only_admin]` | NAV must be paused; instant after setup too | `test::only_admin_can_clear_latest`, `test::the_record_clears_only_while_the_nav_is_paused` | diff --git a/docs/TESTING.md b/docs/TESTING.md index d52064d5..5d7e1324 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -137,7 +137,7 @@ so time rules and TTL can be reached in one test. | The vault publishes each step of a deposit with its amounts | [`contracts/async-vault/src/test/events.rs`](../contracts/async-vault/src/test/events.rs) | `requesting_a_deposit_publishes_the_request`, `closing_an_epoch_publishes_its_totals`, `pricing_an_epoch_publishes_the_share_price`, `claiming_the_last_deposit_publishes_the_shares_paid_and_the_settlement` | | A refused call publishes nothing | [`contracts/async-vault/src/test/events.rs`](../contracts/async-vault/src/test/events.rs) | `a_refused_call_publishes_nothing` | | An epoch's rounding remainder is cleared once its last request settles, a settled epoch leaves nothing in the totals whatever the claim order (property test), and a request that can never settle (a refused redemption or a refused mint) keeps its epoch open | [`contracts/async-vault/src/test/settlement.rs`](../contracts/async-vault/src/test/settlement.rs) | `the_last_redemption_claim_clears_the_cash_remainder_and_says_so_once`, `an_epoch_with_no_requests_settles_when_it_is_priced`, `a_settled_epoch_leaves_no_remainder_in_the_totals`, `a_request_that_cannot_settle_keeps_its_epoch_open`, `a_refused_mint_keeps_a_priced_deposit_epoch_open` | -| The oracle publishes the valuation it stored | [`contracts/nav-oracle/src/test.rs`](../contracts/nav-oracle/src/test.rs) | `attesting_publishes_the_stored_valuation` | +| The oracle publishes the valuation it stored, with its proof reference | [`contracts/nav-oracle/src/test.rs`](../contracts/nav-oracle/src/test.rs) | `attesting_publishes_the_stored_valuation` | | The vault publishes the end of its setup, and an applied change publishes its own event before `ChangeApplied` | [`contracts/async-vault/src/test/setup.rs`](../contracts/async-vault/src/test/setup.rs), [`contracts/async-vault/src/test/governance.rs`](../contracts/async-vault/src/test/governance.rs) | `finishing_setup_publishes_it`, `applying_a_custodian_change_publishes_both_events` | ## Calls between contracts @@ -160,6 +160,7 @@ fixture wires them as the deploy does and finishes the vault's setup; | 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; 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` | +| An oracle upgraded in place cannot read a report an earlier release stored; pausing the NAV, clearing the report and lifting the pause let the attester post a new one | [`contracts/nav-oracle/src/test.rs`](../contracts/nav-oracle/src/test.rs) | `a_report_of_the_earlier_shape_is_cleared_and_replaced` | ## Contract spec docs and error codes diff --git a/docs/kit-spec.md b/docs/kit-spec.md index 68c03afc..70812744 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -37,7 +37,7 @@ The contracts enforce a set of mathematical and state-machine invariants across - **Mathematical Statement**: $`\text{attested\_at} \ge \text{closed\_at}`$ - **Testable Property**: If the latest oracle attestation timestamp precedes `closed_at`, `fulfill_epoch` is rejected with `AttestationBeforeClose`. A valuation accepted in the exact closing ledger timestamp or later is accepted. - `attested_at` is the ledger time the oracle accepted the report, not a valuation date: the reporter's own `timestamp` is ignored. So "accepted before the close" compares acceptance time. The reporter's `expires_at` can only shorten a report's life: the oracle stores `min(expires_at, attested_at + freshness_duration)`. + `attested_at` is the ledger time the oracle accepted the report, stored as `accepted_at`, not a valuation date: the attester submits no time of its own. So "accepted before the close" compares acceptance time. The reporter's `expires_at` can only shorten a report's life: the oracle stores `min(expires_at, attested_at + freshness_duration)`. ### Invariant 4: Covered Claims Always Pay (Exit Guarantee) - **Definition**: Once an exit is priced and covered by the liquid reserve, its cash payout cannot be blocked by an administrative pause, oracle staleness, or an investor's compliance delisting. @@ -212,7 +212,7 @@ Strata distributes control across five native Stellar multisig authorities, two | **Governance** | High-quorum deliberative multisig | Set parameters (`notice`, `custodian`, `deposit_cap`, `wind_down_delay`), propose/apply upgrades, propose/cancel wind-down, unpause the vault, pause/unpause the share token, transfer admin, finish each contract's one-time setup, co-sign compliance's `legal_transfer` and `recover_balance` as the share token's admin. After setup, these wait for the timelock: role grants, revoking the manager, admin hand-over, the custodian, lowering the notice or the wind-down delay, upgrades, the token's compliance and verifier swaps, the compliance contract's module and token-binding changes, and oracle config changes other than a shorter freshness window. Revoking the treasury or the guardian stays instant, because taking a power away moves no value. | **Cannot pause the vault directly**; cannot move funds; cannot initiate custodian transfers; cannot attest prices; cannot bypass timelocks. | | **Guardian** | Low-threshold fast-reaction multisig | `pause` (vault); `pause_nav` (oracle emergency price freeze). | **Cannot unpause**; cannot move funds; cannot block redemption requests; cannot block payable claims or cancellations. | | **Treasury** | Operational capital multisig | `deploy_to_custodian` (moves free reserve). Capital comes back through `fund`, which anyone may call. | **Cannot deploy while anything is uncovered**; cannot touch escrowed subscription assets; cannot alter parameters or pause. | -| **Attester** | High-security attestation multisig | `attest` (submits a NAV report: the price and its expiry). | **Cannot price outside deviation band**; cannot violate cooldown; cannot operate on the vault directly. | +| **Attester** | High-security attestation multisig | `attest` (submits a NAV report: the price, its expiry and a proof reference). | **Cannot price outside deviation band**; cannot violate cooldown; cannot operate on the vault directly. | | **Compliance** | Legal / KYC operator multisig | Manage allowlist (`allow`), topic claims; token interventions (`freeze`; `legal_transfer` and `recovery` co-signed by governance). | **Cannot touch vault reserve**; cannot move a holder's shares without governance's signature in the same call; cannot move shares out of or into the vault's address, or freeze it once the token knows its vault (an earlier freeze makes the vault's `finish_setup` refuse); cannot pause vault operations; cannot bypass exit-only cash redemptions for an unfrozen holder — freezing holds a covered claim until it lifts, rather than paying or redirecting it. | | **Manager (vault role)** | Operational keeper account; a role, not one of the five authorities | `close_epoch`: seals the open epoch, nothing else. | **Cannot choose prices**; cannot mint, burn or move shares; cannot stop a sealed epoch from being priced, though not sealing stalls every request; cannot alter timelocks. After setup, granting or revoking it waits for the timelock. | | **Manager (share-token role)** | The vault contract, alone: the vault's `finish_setup` refuses while another account holds it | `mint`, `burn`, `forced_transfer` on the share token: the vault mints and burns at claim and moves escrowed shares. | Cannot freeze, recover or make a legal transfer. After setup, granting or revoking it waits for the vault's timelock. See [`AUTHORIZATION_MATRIX.md`](./AUTHORIZATION_MATRIX.md) §2. | @@ -229,6 +229,18 @@ Pause first, so no report lands while the roles change, and lift the pause last: 5. Lift the NAV pause: `set_nav_paused(false)` (admin). 6. The new attester posts a report. Pricing resumes: an epoch can be priced once a report accepted after its seal is valid. +### Upgrading the Oracle from an Earlier Release + +Releases before the proof reference stored the report as `nav_per_share`, `expires_at` and `timestamp`. This version stores `nav_per_share`, `expires_at`, `accepted_at`, `proof_hash` and `proof_uri`, and cannot read a report of the earlier shape. After an in-place upgrade, every call that reads the old report fails: `latest`, `nav_per_share`, `attested_at`, `is_stale` and `attest`, and also `state` and `ensure_consumable` while the NAV is not paused. The vault then cannot price, and cancelling a request in a sealed epoch fails. No migration rewrites the old report. Clear it instead: + +1. Pause the NAV before the upgrade applies: the guardian calls `pause_nav`, or the admin calls `set_nav_paused(true)`. While the NAV is paused, `state` answers `Paused` without reading the report, so the vault prices nothing and requests in sealed epochs stay cancellable. +2. Apply the upgrade: `apply_change` once its delay has run. +3. Remove the old report: `clear_latest` (admin). It deletes the entry without reading it. +4. Lift the NAV pause: `set_nav_paused(false)` (admin). +5. The attester posts a report with its proof. As after any clear, no earlier price caps its move: only `min_answer` and `max_answer` bound it. + +The test `a_report_of_the_earlier_shape_is_cleared_and_replaced` stores a report of the earlier shape and recovers it this way. A fresh deployment has no such report and skips these steps. + ### When the Manager or the Attester Stops Pricing needs both roles, and nothing on chain replaces them on its own. The kit assumes they keep working; until then, holders keep these ways out: @@ -555,7 +567,7 @@ for the same failure, so `304` says what failed, not where. | Contract | Error enum | Block | Codes in use | |---|---|---|---| -| `nav_oracle` | `OracleError` | 3000–3999 | 3001–3021, 3025–3026 | +| `nav_oracle` | `OracleError` | 3000–3999 | 3001–3021, 3023–3026 | | `async_vault` | `VaultError` | 6000–6999 | 6000–6001, 6005, 6007, 6009, 6012, 6014, 6029–6035, 6037–6075 | | `share_token` | `ShareTokenError` | 7000–7999 | 7000–7013, 7015–7018 | | `compliance` | `ComplianceContractError` | 8000–8999 | 8001–8011 | diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index 4366c0c6..d716e11f 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -368,10 +368,14 @@ sequenceDiagram ### 8.5 Valuation and accounting The NAV is a permissioned attestation of the share price. The reporter computes -it off-chain under a published methodology. A report carries the price and the -time until which the reporter vouches for it; it carries no proof reference yet. -The contract checks bounds, cooldown and the deviation cap, then stores the price -and exposes it with the liquidity figures it owns: +it off-chain under a published methodology. A report carries the price, the +time until which the reporter vouches for it, and a proof reference. The proof +reference is a 32-byte hash of the evidence behind the price, which must not be +all zeros, and an optional URI of at most 256 bytes that says where to read the +evidence. The contract anchors the proof and never verifies it. It checks +bounds, cooldown and the deviation cap, then stores the report with its +acceptance time, publishes it in `NavAttested`, and exposes the price with the +liquidity figures it owns: ```text liquid_reserve = reserve - cancellable_deposit_escrow @@ -424,8 +428,9 @@ It is a trading restriction, not an emergency stop for the vault, and only governance lifts it. **Integrator surface:** the oracle exposes the attested share price directly: -the latest report with its acceptance time and expiry (`latest`), the price -alone (`nav_per_share`) and the acceptance time alone (`attested_at`). The +the latest report (`latest`), which returns `nav_per_share`, `expires_at`, +`accepted_at`, `proof_hash` and `proof_uri`; the price alone (`nav_per_share`); +and the acceptance time alone (`attested_at`). The SEP-40 feed named in section 4 is an adapter over that surface scheduled for Tranche 3 and is not implemented yet; integrators read the oracle's own interface today. From e3f9276a2fbc028b5eddb3bdd0ab190559ce0652 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:24:12 -0300 Subject: [PATCH 141/147] test(harness): attest with the sample valuation The oracle now needs a proof with every report. The harness attests with the SHA-256 of docs/evidence/sample-valuation.md, a sample marked as test data, and its URL on main, or PROOF_URI when set. The happy path checks that the latest report carries both, and the guard that an account with no role cannot attest sends the same input. --- docs/TESTING.md | 5 +++++ docs/evidence/sample-valuation.md | 9 +++++++++ scripts/harness/README.md | 3 +++ scripts/harness/guards.test.ts | 12 ++---------- scripts/harness/happy-path.test.ts | 6 ++++-- scripts/harness/lib/oracle.ts | 25 ++++++++++++++++++++----- 6 files changed, 43 insertions(+), 17 deletions(-) create mode 100644 docs/evidence/sample-valuation.md diff --git a/docs/TESTING.md b/docs/TESTING.md index 5d7e1324..03b192a2 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -162,6 +162,11 @@ fixture wires them as the deploy does and finishes the vault's setup; | 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` | | An oracle upgraded in place cannot read a report an earlier release stored; pausing the NAV, clearing the report and lifting the pause let the attester post a new one | [`contracts/nav-oracle/src/test.rs`](../contracts/nav-oracle/src/test.rs) | `a_report_of_the_earlier_shape_is_cleared_and_replaced` | +On the network, the harness attests with the SHA-256 and URL of +[`docs/evidence/sample-valuation.md`](./evidence/sample-valuation.md), a sample +marked as test data, so every testnet attestation points at a real file whose +bytes match its hash. + ## Contract spec docs and error codes The contract spec keeps the first 1,024 bytes of each doc and drops the rest diff --git a/docs/evidence/sample-valuation.md b/docs/evidence/sample-valuation.md new file mode 100644 index 00000000..103563ee --- /dev/null +++ b/docs/evidence/sample-valuation.md @@ -0,0 +1,9 @@ +# Sample valuation statement (testnet) + +This is a sample. It describes no real fund, and the prices attested with it are test values. + +- Fund: Strata testnet sample vault +- Basis: net asset value per share, in the vault's deposit asset +- Prepared by: the Strata team, for testnet demonstrations only + +An attestation cites this file by its SHA-256 hash and its URL. Changing a single byte changes the hash, so each attestation shows which version it relied on. diff --git a/scripts/harness/README.md b/scripts/harness/README.md index fd5a0c8c..9921a901 100644 --- a/scripts/harness/README.md +++ b/scripts/harness/README.md @@ -21,6 +21,9 @@ npm run test:guards `HARNESS_NETWORK` picks the network: `local` (the default) or `testnet`. +Each attestation carries the SHA-256 of `docs/evidence/sample-valuation.md` and +a URL to it: the copy on `main`, or `PROOF_URI` when set. + ## Account keys The harness signs with thirteen accounts: `deployer`, `governance`, `manager`, diff --git a/scripts/harness/guards.test.ts b/scripts/harness/guards.test.ts index 02789b82..6ce8f5d7 100644 --- a/scripts/harness/guards.test.ts +++ b/scripts/harness/guards.test.ts @@ -14,7 +14,7 @@ import { vault, type Connected, } from "./lib/clients.js" -import { attest } from "./lib/oracle.js" +import { attest, navInput } from "./lib/oracle.js" import { expectRefused } from "./lib/refused.js" const c: Connected = connect() @@ -96,16 +96,8 @@ describe("an account with no role", () => { it("cannot attest a price", async () => { const o = oracle(c, outsider) - const now = BigInt(Math.floor(Date.now() / 1000)) expectRefused( - await o.attest({ - report: { - nav_per_share: price(2), - expires_at: now + 600n, - timestamp: now, - }, - caller: who, - }), + await o.attest({ input: navInput(price(2)), caller: who }), o, "Unauthorized", ) diff --git a/scripts/harness/happy-path.test.ts b/scripts/harness/happy-path.test.ts index 00691e7e..689abe7f 100644 --- a/scripts/harness/happy-path.test.ts +++ b/scripts/harness/happy-path.test.ts @@ -12,7 +12,7 @@ import { vault, type Connected, } from "./lib/clients.js" -import { attest } from "./lib/oracle.js" +import { attest, navInput, SAMPLE_PROOF_URI } from "./lib/oracle.js" import { expectRefused } from "./lib/refused.js" const c: Connected = connect() @@ -171,9 +171,11 @@ describe("pricing", () => { await attest(c, nav) const latest = (await oracle(c, outsider).latest()).result expect(latest.nav_per_share).toBe(nav) - expect(latest.expires_at - latest.timestamp).toBe( + expect(latest.expires_at - latest.accepted_at).toBe( BigInt(c.net.oracle.freshness_duration), ) + expect(latest.proof_hash).toEqual(navInput(nav).proof_hash) + expect(latest.proof_uri).toBe(SAMPLE_PROOF_URI) }) it("an account with no role prices the epoch", async () => { diff --git a/scripts/harness/lib/oracle.ts b/scripts/harness/lib/oracle.ts index 5b2a1981..7d2a89f9 100644 --- a/scripts/harness/lib/oracle.ts +++ b/scripts/harness/lib/oracle.ts @@ -1,21 +1,36 @@ +import { createHash } from "node:crypto" +import { readFileSync } from "node:fs" +import { join } from "node:path" import { setTimeout as sleep } from "node:timers/promises" import { oracle, send, type Connected } from "./clients.js" +import { REPO_ROOT } from "./deployment.js" import { outcome } from "./refused.js" -export interface NavReport { +export interface NavInput { nav_per_share: bigint expires_at: bigint - timestamp: bigint + proof_hash: Buffer + proof_uri: string } /** The latest expiry a u64 holds: the oracle cuts it to its own window. */ const NO_EXPIRY = (1n << 64n) - 1n -export function navReport(nav: bigint): NavReport { +const SAMPLE_PROOF = join(REPO_ROOT, "docs", "evidence", "sample-valuation.md") + +// The release attests against the tag's copy; local runs use main's. +export const SAMPLE_PROOF_URI = + process.env["PROOF_URI"] ?? + "https://raw.githubusercontent.com/BootNodeDev/strata-vault-kit/main/docs/evidence/sample-valuation.md" + +export function navInput(nav: bigint): NavInput { return { nav_per_share: nav, expires_at: NO_EXPIRY, - timestamp: 0n, + proof_hash: createHash("sha256") + .update(readFileSync(SAMPLE_PROOF)) + .digest(), + proof_uri: SAMPLE_PROOF_URI, } } @@ -26,7 +41,7 @@ export async function attest(c: Connected, nav: bigint): Promise { const deadline = Date.now() + (c.net.oracle.cooldown_secs + 60) * 1_000 for (;;) { const tx = await client.attest({ - report: navReport(nav), + input: navInput(nav), caller: attester.publicKey(), }) const result = outcome(tx, client) From ba794b04e996c3cc38db212a08fb115fa805738c Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:10:45 -0300 Subject: [PATCH 142/147] test(oracle): tighten the proof reference tests The test of a report of the earlier shape checked only that latest, state and attest failed. Its attest was also past the deviation cap, so a contract error would have passed it too. Each call must now fail with the host refusal a decode failure gives, and the attest stays inside the cap and past the cooldown, so only the old entry can refuse it. A URI of exactly 256 bytes is now attested end to end: it is stored and emitted. The attested_at test no longer says the caller can send a time. --- contracts/nav-oracle/src/test.rs | 45 +++++++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/contracts/nav-oracle/src/test.rs b/contracts/nav-oracle/src/test.rs index dd97ed49..1dcc5ce7 100644 --- a/contracts/nav-oracle/src/test.rs +++ b/contracts/nav-oracle/src/test.rs @@ -519,7 +519,7 @@ fn a_full_downside_cap_leaves_min_answer_as_the_only_floor() { } #[test] -fn attested_at_is_when_the_contract_took_the_report_not_when_the_caller_says() { +fn attested_at_is_when_the_contract_took_the_report() { let f = setup(); f.e.ledger().set_timestamp(50_000); @@ -685,6 +685,7 @@ fn a_missing_config_is_refused_by_name() { mod proof_reference { use soroban_sdk::contracttype; + use soroban_sdk::xdr::{ScErrorCode, ScErrorType}; use super::*; use crate::{OracleError, MAX_PROOF_URI_LEN}; @@ -727,6 +728,28 @@ mod proof_reference { ); } + #[test] + fn a_proof_uri_at_the_limit_is_stored_and_published() { + let f = setup(); + let mut longest = input(&f.e, SCALE, 1_000_000); + longest.proof_uri = String::from_bytes(&f.e, &[b'x'; MAX_PROOF_URI_LEN as usize]); + + f.oracle.attest(&longest, &f.attester); + + assert_eq!( + f.e.events().all().filter_by_contract(&f.oracle.address), + [crate::NavAttested { + attester: f.attester.clone(), + nav_per_share: SCALE, + expires_at: 10_000 + 3600, + proof_hash: proof(&f.e), + proof_uri: longest.proof_uri.clone(), + } + .to_xdr(&f.e, &f.oracle.address)] + ); + assert_eq!(f.oracle.latest().proof_uri, longest.proof_uri); + } + #[test] fn a_hash_without_a_uri_is_enough() { let f = setup(); @@ -746,6 +769,12 @@ mod proof_reference { timestamp: u64, } + /// What a caller sees when the host cannot run a call, such as one that + /// reads an entry it cannot decode. A contract error carries its own code. + fn host_refusal() -> soroban_sdk::Error { + soroban_sdk::Error::from_type_and_code(ScErrorType::Context, ScErrorCode::InvalidAction) + } + #[test] fn a_report_of_the_earlier_shape_is_cleared_and_replaced() { let f = setup(); @@ -759,12 +788,16 @@ mod proof_reference { }, ) }); + // Past the cooldown and inside the cap: only the old entry can refuse it. f.e.ledger().set_timestamp(10_100); - let next = input(&f.e, SCALE * 2, 1_000_000); + let next = input(&f.e, SCALE + SCALE / 20, 1_000_000); - assert!(f.oracle.try_latest().is_err()); - assert!(f.oracle.try_state().is_err()); - assert!(f.oracle.try_attest(&next, &f.attester).is_err()); + assert_eq!(f.oracle.try_latest().err(), Some(Ok(host_refusal()))); + assert_eq!(f.oracle.try_state().err(), Some(Ok(host_refusal()))); + assert_eq!( + f.oracle.try_attest(&next, &f.attester).err(), + Some(Ok(host_refusal())) + ); f.oracle.pause_nav(&f.guardian); assert_eq!(f.oracle.state(), OracleState::Paused); @@ -773,7 +806,7 @@ mod proof_reference { f.oracle.attest(&next, &f.attester); let latest = f.oracle.latest(); - assert_eq!(latest.nav_per_share, SCALE * 2); + assert_eq!(latest.nav_per_share, SCALE + SCALE / 20); assert_eq!(latest.accepted_at, 10_100); assert_eq!(latest.proof_hash, proof(&f.e)); assert_eq!( From de7a5246a50e1725e39a32416f36fb46b419e3b3 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:10:49 -0300 Subject: [PATCH 143/147] docs: correct the proof reference notes The test guide said every testnet attestation points at a file whose bytes match its hash. That holds only for the harness's own attestations, and only once the file is on main or when PROOF_URI points at a release tag's copy. It now says so. The design doc's list of what the chain sees now includes the proof hash and URI. The upgrade runbook says to pause the NAV at once if the upgrade applied first, since neither pause_nav nor clear_latest reads the report. The test guide lists the new 256-byte URI test. --- docs/TESTING.md | 12 +++++++----- docs/kit-spec.md | 2 +- docs/strata-product-and-architecture.md | 4 ++-- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/TESTING.md b/docs/TESTING.md index 03b192a2..347daca0 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -137,7 +137,7 @@ so time rules and TTL can be reached in one test. | The vault publishes each step of a deposit with its amounts | [`contracts/async-vault/src/test/events.rs`](../contracts/async-vault/src/test/events.rs) | `requesting_a_deposit_publishes_the_request`, `closing_an_epoch_publishes_its_totals`, `pricing_an_epoch_publishes_the_share_price`, `claiming_the_last_deposit_publishes_the_shares_paid_and_the_settlement` | | A refused call publishes nothing | [`contracts/async-vault/src/test/events.rs`](../contracts/async-vault/src/test/events.rs) | `a_refused_call_publishes_nothing` | | An epoch's rounding remainder is cleared once its last request settles, a settled epoch leaves nothing in the totals whatever the claim order (property test), and a request that can never settle (a refused redemption or a refused mint) keeps its epoch open | [`contracts/async-vault/src/test/settlement.rs`](../contracts/async-vault/src/test/settlement.rs) | `the_last_redemption_claim_clears_the_cash_remainder_and_says_so_once`, `an_epoch_with_no_requests_settles_when_it_is_priced`, `a_settled_epoch_leaves_no_remainder_in_the_totals`, `a_request_that_cannot_settle_keeps_its_epoch_open`, `a_refused_mint_keeps_a_priced_deposit_epoch_open` | -| The oracle publishes the valuation it stored, with its proof reference | [`contracts/nav-oracle/src/test.rs`](../contracts/nav-oracle/src/test.rs) | `attesting_publishes_the_stored_valuation` | +| The oracle publishes the valuation it stored, with its proof reference | [`contracts/nav-oracle/src/test.rs`](../contracts/nav-oracle/src/test.rs) | `attesting_publishes_the_stored_valuation`, `a_proof_uri_at_the_limit_is_stored_and_published` | | The vault publishes the end of its setup, and an applied change publishes its own event before `ChangeApplied` | [`contracts/async-vault/src/test/setup.rs`](../contracts/async-vault/src/test/setup.rs), [`contracts/async-vault/src/test/governance.rs`](../contracts/async-vault/src/test/governance.rs) | `finishing_setup_publishes_it`, `applying_a_custodian_change_publishes_both_events` | ## Calls between contracts @@ -162,10 +162,12 @@ fixture wires them as the deploy does and finishes the vault's setup; | 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` | | An oracle upgraded in place cannot read a report an earlier release stored; pausing the NAV, clearing the report and lifting the pause let the attester post a new one | [`contracts/nav-oracle/src/test.rs`](../contracts/nav-oracle/src/test.rs) | `a_report_of_the_earlier_shape_is_cleared_and_replaced` | -On the network, the harness attests with the SHA-256 and URL of -[`docs/evidence/sample-valuation.md`](./evidence/sample-valuation.md), a sample -marked as test data, so every testnet attestation points at a real file whose -bytes match its hash. +The harness's own attestations, from its tests and `npm run seed`, carry the +SHA-256 of [`docs/evidence/sample-valuation.md`](./evidence/sample-valuation.md), +a sample marked as test data, and a URL to it. By default the URL is the file's +raw copy on `main`, which resolves once the file is on `main`; the hash then +matches only while that copy is unchanged. With `PROOF_URI` pointing at a +release tag's copy, the URL and the hash name the same bytes for good. ## Contract spec docs and error codes diff --git a/docs/kit-spec.md b/docs/kit-spec.md index 70812744..74c63ab2 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -233,7 +233,7 @@ Pause first, so no report lands while the roles change, and lift the pause last: Releases before the proof reference stored the report as `nav_per_share`, `expires_at` and `timestamp`. This version stores `nav_per_share`, `expires_at`, `accepted_at`, `proof_hash` and `proof_uri`, and cannot read a report of the earlier shape. After an in-place upgrade, every call that reads the old report fails: `latest`, `nav_per_share`, `attested_at`, `is_stale` and `attest`, and also `state` and `ensure_consumable` while the NAV is not paused. The vault then cannot price, and cancelling a request in a sealed epoch fails. No migration rewrites the old report. Clear it instead: -1. Pause the NAV before the upgrade applies: the guardian calls `pause_nav`, or the admin calls `set_nav_paused(true)`. While the NAV is paused, `state` answers `Paused` without reading the report, so the vault prices nothing and requests in sealed epochs stay cancellable. +1. Pause the NAV before the upgrade applies: the guardian calls `pause_nav`, or the admin calls `set_nav_paused(true)`. While the NAV is paused, `state` answers `Paused` without reading the report, so the vault prices nothing and requests in sealed epochs stay cancellable. If the upgrade applied first, pause the NAV at once: neither `pause_nav` nor `clear_latest` reads the report. 2. Apply the upgrade: `apply_change` once its delay has run. 3. Remove the old report: `clear_latest` (admin). It deletes the entry without reading it. 4. Lift the NAV pause: `set_nav_paused(false)` (admin). diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index d716e11f..8a1d1ff2 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -281,8 +281,8 @@ under the [Admin panel](#admin-panel) table in §8.8. Both interfaces are backend-free: they read contract state and build transactions that the authority multisigs sign. The chain never sees KYC data or -valuation methodology, only their outputs: an allowlisted address, an attested -number. +valuation methodology, only their outputs: an allowlisted address, and an +attested number with a hash of its evidence and, optionally, where to read it. ### 8.3 Components and authorities From a8fcf8717786acb2a34dc90b0fb3d3c90b37fe73 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:29:48 -0300 Subject: [PATCH 144/147] fix(token): accept external rules in a swap After setup, a compliance swap refused any contract that does not answer VaultBinding, though the vault's finish_setup accepts such an external module. A standard compliance contract, such as OpenZeppelin's, could never replace the testnet stub. The swap check now follows the rule finish_setup uses. The new contract must list the token in linked_tokens. If it answers vault() and setup_open(), they must name the token's vault with setup closed. If either view does not answer, the contract is external and accepted on trust. The check still runs when the change is proposed and again when it applies, and ComplianceNotBound (7017) is still the error. The design notes, the error and entrypoint docs, the kit spec, the design doc, the authorization matrix and the test map now say so. --- contracts/share-token/src/contract.rs | 2 +- contracts/share-token/src/error.rs | 9 +- contracts/share-token/src/guards.rs | 15 ++-- contracts/share-token/src/lib.rs | 9 +- contracts/share-token/src/test_governance.rs | 91 ++++++++++++++++++-- crates/strata-bindings/src/lib.rs | 5 +- docs/AUTHORIZATION_MATRIX.md | 6 +- docs/TESTING.md | 2 +- docs/kit-spec.md | 14 +-- docs/strata-product-and-architecture.md | 6 +- 10 files changed, 122 insertions(+), 37 deletions(-) diff --git a/contracts/share-token/src/contract.rs b/contracts/share-token/src/contract.rs index d952484b..f743e051 100644 --- a/contracts/share-token/src/contract.rs +++ b/contracts/share-token/src/contract.rs @@ -127,7 +127,7 @@ impl ShareToken { /// # Errors /// /// * `ComplianceNotBound` - The new compliance contract does not list - /// this token, or is not on the vault's clock with its setup closed. + /// this token, or reports another vault or an open setup. /// * `VerifierRefusesVault` - The new verifier does not accept the vault. /// * `UpgradeAlreadyPending` - An upgrade already waits. /// * `SetupOpen` - Setup has not finished; change it directly. diff --git a/contracts/share-token/src/error.rs b/contracts/share-token/src/error.rs index 4f15b946..eb84b777 100644 --- a/contracts/share-token/src/error.rs +++ b/contracts/share-token/src/error.rs @@ -54,10 +54,11 @@ strata_bindings::error_enum! { /// once it is accepted, cancelled or expired, so no nominee from setup can /// accept after changes start to wait. AdminTransferPending = 7016, - /// The proposed compliance contract does not name this token's vault, - /// its setup is still open, or its `linked_tokens` leaves out this - /// token. Every mint, transfer and burn would fail, or its rules could - /// change without the vault's delay. + /// The proposed compliance contract's `linked_tokens` leaves out this + /// token, so every mint, transfer and burn would fail. Or it answers + /// `vault()` and `setup_open()` with another vault or an open setup, so + /// its rules could change without the vault's delay. One that does not + /// answer them is an external module and passes, on trust. ComplianceNotBound = 7017, /// The proposed identity verifier refuses the vault's address or does /// not answer `verify_identity`. The vault needs that check to convert diff --git a/contracts/share-token/src/guards.rs b/contracts/share-token/src/guards.rs index b8ae6527..0a42d542 100644 --- a/contracts/share-token/src/guards.rs +++ b/contracts/share-token/src/guards.rs @@ -78,7 +78,7 @@ pub(crate) fn valid_change(e: &Env, change: &TokenChange) { } /// Rules that leave out this token stop every mint, transfer and burn; rules -/// off the vault's clock could change with no delay. +/// that report another vault or an open setup could change with no delay. pub(crate) fn compliance_bound_here(e: &Env, rules: &Address) { if !on_the_vaults_clock(e, rules) || !lists_this_token(e, rules) { panic_with_error!(e, ShareTokenError::ComplianceNotBound); @@ -97,12 +97,17 @@ pub(crate) fn verifier_accepts_vault(e: &Env, verifier: &Address) { } } -/// `contract` names this token's vault and its own setup has finished. +/// When `contract` answers both binding views, they name this token's vault +/// with its setup closed. fn on_the_vaults_clock(e: &Env, contract: &Address) -> bool { let binding = VaultBindingClient::new(e, contract); - setup::source(e) - .is_some_and(|vault| matches!(binding.try_vault(), Ok(Ok(Some(named))) if named == vault)) - && matches!(binding.try_setup_open(), Ok(Ok(false))) + match (binding.try_vault(), binding.try_setup_open()) { + (Ok(Ok(named)), Ok(Ok(open))) => { + setup::source(e).is_some_and(|vault| named == Some(vault)) && !open + } + // An external module answers no binding view; its governance is a trust assumption. + _ => true, + } } fn lists_this_token(e: &Env, rules: &Address) -> bool { diff --git a/contracts/share-token/src/lib.rs b/contracts/share-token/src/lib.rs index 87c41e92..3760583c 100644 --- a/contracts/share-token/src/lib.rs +++ b/contracts/share-token/src/lib.rs @@ -19,10 +19,11 @@ //! - **The pause halts trading only.** The admin's transfer pause stops //! `transfer` and `transfer_from`; mints, burns, escrow moves and legal //! transfers go on. The cost: it is no emergency stop for the vault. -//! - **A swap must fit the vault.** New rules must list this token and run on -//! the vault's clock with their setup closed; a new verifier must accept the -//! vault. Both are checked when proposed and when applied. The cost: after -//! setup, an external module that reports no vault cannot be swapped in. +//! - **A swap must fit the vault.** New rules must list this token, and rules +//! that answer `VaultBinding` must run on the vault's clock with their setup +//! closed; a new verifier must accept the vault. Both are checked when +//! proposed and when applied. The cost: as at setup, an external module that +//! does not answer is taken on trust, and its rules can change with no delay. #![no_std] mod contract; diff --git a/contracts/share-token/src/test_governance.rs b/contracts/share-token/src/test_governance.rs index f44367f4..53c26090 100644 --- a/contracts/share-token/src/test_governance.rs +++ b/contracts/share-token/src/test_governance.rs @@ -82,6 +82,11 @@ impl RulesStub { store.remove(&symbol_short!("linked")); } + /// One view, `vault` or `open`, fails from now on. + pub fn forget(e: Env, view: Symbol) { + e.storage().instance().remove(&view); + } + pub fn linked_tokens(e: Env) -> Vec
{ e.storage() .instance() @@ -101,6 +106,27 @@ impl strata_bindings::VaultBinding for RulesStub { } } +/// Compliance rules from outside the kit, such as OpenZeppelin's: they list +/// the tokens a test sets and answer no binding view. +#[contract] +struct ExternalRules; + +#[contractimpl] +impl ExternalRules { + pub fn list(e: Env, linked: Vec
) { + e.storage() + .instance() + .set(&symbol_short!("linked"), &linked); + } + + pub fn linked_tokens(e: Env) -> Vec
{ + e.storage() + .instance() + .get(&symbol_short!("linked")) + .unwrap() + } +} + /// A verifier that accepts every account until a test silences it. #[contract] struct VerifierStub; @@ -161,6 +187,23 @@ impl Fixture<'_> { rules.address } + /// Stub rules that qualify until a test changes their answers. + fn rules_stub_bound_here(&self) -> RulesStubClient<'_> { + let rules = RulesStubClient::new(&self.e, &self.e.register(RulesStub, ())); + rules.report( + &Some(self.vault.address.clone()), + &false, + &vec![&self.e, self.token.address.clone()], + ); + rules + } + + fn rules_from_outside(&self, linked: &Vec
) -> ExternalRulesClient<'_> { + let rules = ExternalRulesClient::new(&self.e, &self.e.register(ExternalRules, ())); + rules.list(linked); + rules + } + fn verifier(&self) -> IdentityVerifierClient<'_> { IdentityVerifierClient::new( &self.e, @@ -696,12 +739,14 @@ fn rules_off_this_vaults_clock_or_without_the_token_are_never_proposed() { let in_setup = f.rules(); in_setup.bind_token(&f.token.address, &f.admin); in_setup.bind_vault(&f.vault.address); + let outside_for_another_token = f.rules_from_outside(&vec![&f.e, Address::generate(&f.e)]); let not_a_contract = Address::generate(&f.e); for rules in [ unlisted.address, elsewhere.address, in_setup.address, + outside_for_another_token.address, not_a_contract, ] { refused( @@ -715,12 +760,7 @@ fn rules_off_this_vaults_clock_or_without_the_token_are_never_proposed() { /// Proposes a swap to rules that qualify, lets `turn` change their answers, /// then checks the swap no longer lands. fn refused_once_the_rules_turn(f: &Fixture, turn: impl FnOnce(&RulesStubClient)) { - let rules = RulesStubClient::new(&f.e, &f.e.register(RulesStub, ())); - rules.report( - &Some(f.vault.address.clone()), - &false, - &vec![&f.e, f.token.address.clone()], - ); + let rules = f.rules_stub_bound_here(); let id = f.propose(TokenChange::Compliance(rules.address.clone())); turn(&rules); f.advance(MIN_DELAY); @@ -747,6 +787,45 @@ fn rules_that_stop_qualifying_are_never_installed() { refused_once_the_rules_turn(&f, |rules| rules.silence()); } +/// Their own governance is a trust assumption, as at the vault's setup. +#[test] +fn rules_from_outside_the_kit_are_installed_while_they_list_the_token() { + let f = setup(); + f.finish(); + let listed = vec![&f.e, f.token.address.clone()]; + let kept = f.rules_from_outside(&listed); + let dropped = f.rules_from_outside(&listed); + let first = f.propose(TokenChange::Compliance(kept.address.clone())); + let second = f.propose(TokenChange::Compliance(dropped.address.clone())); + dropped.list(&vec![&f.e]); + f.advance(MIN_DELAY); + + refused( + f.token.try_apply_change(&second), + ShareTokenError::ComplianceNotBound, + ); + f.token.apply_change(&first); + assert_eq!(f.token.compliance(), kept.address); +} + +/// As at the vault's setup, a contract that fails either binding view counts +/// as external, so only its `linked_tokens` decides. +#[test] +fn rules_that_stop_answering_a_binding_view_are_installed_as_external() { + let f = setup(); + f.finish(); + + for view in [symbol_short!("vault"), symbol_short!("open")] { + let rules = f.rules_stub_bound_here(); + let id = f.propose(TokenChange::Compliance(rules.address.clone())); + rules.forget(&view); + f.advance(MIN_DELAY); + + f.token.apply_change(&id); + assert_eq!(f.token.compliance(), rules.address); + } +} + #[test] fn a_verifier_that_refuses_the_vault_or_cannot_answer_is_never_proposed() { let f = setup(); diff --git a/crates/strata-bindings/src/lib.rs b/crates/strata-bindings/src/lib.rs index d2feb01d..1226f12d 100644 --- a/crates/strata-bindings/src/lib.rs +++ b/crates/strata-bindings/src/lib.rs @@ -6,9 +6,8 @@ //! which the kit's oracle and any adopter's feed implement. The cost: a feed //! must answer its four calls as written, or use an adapter. //! - **Bound contracts report their binding.** The vault's `finish_setup` and -//! the token's compliance swaps read `VaultBinding`. The cost: setup accepts -//! a compliance contract that does not answer it, on trust, as external; a -//! later swap refuses one. +//! the token's compliance swaps read `VaultBinding`. The cost: both accept a +//! compliance contract that does not answer it, on trust, as external. //! - **Fixed blocks of error codes.** The vault, token, oracle and compliance //! contract each own one; `error_enum!` fails the build outside it, //! `errors.rs` on overlaps. The cost: removed codes stay retired. diff --git a/docs/AUTHORIZATION_MATRIX.md b/docs/AUTHORIZATION_MATRIX.md index af099a7f..293ced98 100644 --- a/docs/AUTHORIZATION_MATRIX.md +++ b/docs/AUTHORIZATION_MATRIX.md @@ -113,7 +113,7 @@ Tests: [`controls.rs`](../contracts/async-vault/src/test/controls.rs), [`epochs. ### 5.2 ShareToken (`contracts/share-token`) -The token's `admin` is governance; the guardian holds no role on the token. Until `finish_setup`, admin changes are instant, so the deploy can grant the vault the `manager` role. 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, revoking `manager`, admin hand-over, `set_compliance`, `set_identity_verifier` and upgrades. A compliance or verifier swap is checked when proposed and again when applied: new rules must run on the vault's clock and list the token, and a new verifier must accept the vault. Revoking `compliance` and `pause`/`unpause` stay instant. The `manager` role is held by the vault and the `compliance` role by the compliance authority. The token's `pause` refuses SEP-41 transfers between holders only. The vault's own escrow moves (`forced_transfer`), mint and burn ignore it, so a token pause stops neither entry nor exit. +The token's `admin` is governance; the guardian holds no role on the token. Until `finish_setup`, admin changes are instant, so the deploy can grant the vault the `manager` role. 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, revoking `manager`, admin hand-over, `set_compliance`, `set_identity_verifier` and upgrades. A compliance or verifier swap is checked when proposed and again when applied: new rules must list the token, and rules that answer `vault()` and `setup_open()` must run on the vault's clock with their setup closed. As at `finish_setup`, rules that do not answer them count as an external module and are accepted. A new verifier must accept the vault. Revoking `compliance` and `pause`/`unpause` stay instant. The `manager` role is held by the vault and the `compliance` role by the compliance authority. The token's `pause` refuses SEP-41 transfers between holders only. The vault's own escrow moves (`forced_transfer`), mint and burn ignore it, so a token pause stops neither entry nor exit. Tests: [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.rs) and [`contracts/share-token/src/test_governance.rs`](../contracts/share-token/src/test_governance.rs). What each privileged call asks to sign: `test_governance::each_admin_call_asks_the_admin_for_one_signature`, `test::each_role_call_asks_its_holder_for_one_signature` and `test::legal_transfer::a_legal_transfer_or_a_recovery_asks_each_signer_once`. @@ -137,8 +137,8 @@ Tests: [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.r | `propose_change` | `#[only_admin]` | After setup; vault not winding down; one upgrade at a time | `test_governance::nothing_is_proposed_during_setup`, `test_governance::only_the_admin_proposes_cancels_and_applies` | | `cancel_change` | `#[only_admin]` | The change must be pending | `test_governance::only_the_admin_proposes_cancels_and_applies` | | `apply_change` | `#[only_admin]` | Delay run on the vault's clock, vault unpaused and not winding down, delay `>=` the vault's notice | `test_governance::a_vault_pause_moves_the_eta_and_holds_the_change`, `test_governance::a_change_shorter_than_a_raised_notice_never_lands` | -| `propose_change` (`Compliance(address)`, `IdentityVerifier(address)`) | `#[only_admin]` | New rules name the token's vault through `vault()` with their setup closed and list the token in `linked_tokens` (`ComplianceNotBound`); a new verifier's `verify_identity` accepts the vault's address (`VerifierRefusesVault`). A contract that does not answer fails | `test_governance::rules_off_this_vaults_clock_or_without_the_token_are_never_proposed`, `test_governance::a_verifier_that_refuses_the_vault_or_cannot_answer_is_never_proposed`, `test::compliance::an_unbound_compliance_swap_is_refused_and_every_move_goes_on` in `contracts/async-vault` | -| `apply_change` (`Compliance(address)`, `IdentityVerifier(address)`) | `#[only_admin]` | Delay run; the new rules or verifier still pass the same check | `test_governance::applied_rule_and_verifier_changes_take_effect`, `test_governance::rules_that_stop_qualifying_are_never_installed`, `test_governance::a_verifier_that_stops_accepting_the_vault_is_never_installed` | +| `propose_change` (`Compliance(address)`, `IdentityVerifier(address)`) | `#[only_admin]` | New rules list the token in `linked_tokens`, and rules that answer `vault()` and `setup_open()` name the token's vault with their setup closed (`ComplianceNotBound`); rules that do not answer them count as an external module and are accepted. A new verifier's `verify_identity` accepts the vault's address (`VerifierRefusesVault`). Rules that do not answer `linked_tokens` and a verifier that does not answer fail | `test_governance::rules_off_this_vaults_clock_or_without_the_token_are_never_proposed`, `test_governance::a_verifier_that_refuses_the_vault_or_cannot_answer_is_never_proposed`, `test::compliance::an_unbound_compliance_swap_is_refused_and_every_move_goes_on` in `contracts/async-vault` | +| `apply_change` (`Compliance(address)`, `IdentityVerifier(address)`) | `#[only_admin]` | Delay run; the new rules or verifier still pass the same check | `test_governance::applied_rule_and_verifier_changes_take_effect`, `test_governance::rules_that_stop_qualifying_are_never_installed`, `test_governance::rules_from_outside_the_kit_are_installed_while_they_list_the_token`, `test_governance::rules_that_stop_answering_a_binding_view_are_installed_as_external`, `test_governance::a_verifier_that_stops_accepting_the_vault_is_never_installed` | | `pending_changes` | Open view | None | None (view) | | `grant_role` | Admin, `caller.require_auth()` | Instant during setup; `UseTimelock` after | `test_governance::grants_and_manager_revokes_wait_after_setup` | | `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`) | diff --git a/docs/TESTING.md b/docs/TESTING.md index d52064d5..56bca23a 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -156,7 +156,7 @@ fixture wires them as the deploy does and finishes the vault's setup; | 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` | | The vault finishes setup only once the token and the oracle report it with their own setup closed, it is the token's only `manager`, the token has no shares outstanding, it passes the identity check, its address is not frozen, the token is bound to compliance (which runs on this vault's clock with its setup closed unless it is an external module that does not answer `VaultBinding`), the asset has the token's decimals, the asset, token, oracle and vault have four different addresses, the oracle answers as a feed, and a custodian other than the vault is set. `health()` reports each check, and before setup finishes the vault takes no deposit | [`contracts/async-vault/src/test/setup.rs`](../contracts/async-vault/src/test/setup.rs), [`contracts/async-vault/src/test/health.rs`](../contracts/async-vault/src/test/health.rs) | `every_wiring_step_is_checked_before_setup_finishes`, `each_missing_wiring_step_shows_in_its_own_flag`, `a_token_that_cannot_answer_fails_its_checks_instead_of_trapping`, `a_compliance_contract_from_outside_the_kit_is_accepted`, `a_deposit_before_setup_finishes_is_refused`, `setup_cannot_finish_while_a_hand_over_is_pending` | | The vault is never its own custodian: the direct setter, during setup and after, and a `Custodian` change, when proposed and when applied, refuse it with `InvalidCustodian` | [`contracts/async-vault/src/test/treasury.rs`](../contracts/async-vault/src/test/treasury.rs), [`contracts/async-vault/src/test/governance.rs`](../contracts/async-vault/src/test/governance.rs), [`contracts/async-vault/src/test/guards.rs`](../contracts/async-vault/src/test/guards.rs) | `setting_the_vault_as_its_own_custodian_is_refused`, `a_custodian_change_to_the_vault_is_refused_when_proposed_and_when_applied`, `custodian_not_the_vault_refuses_the_vault_itself` | -| The share token swaps to new compliance rules only if they run on the vault's clock with their setup closed and list the token (`ComplianceNotBound`), and to a new verifier only if it accepts the vault (`VerifierRefusesVault`), checked when proposed and again when applied; a contract that does not answer is refused | [`contracts/share-token/src/test_governance.rs`](../contracts/share-token/src/test_governance.rs), [`contracts/share-token/src/test_guards.rs`](../contracts/share-token/src/test_guards.rs), [`contracts/async-vault/src/test/compliance.rs`](../contracts/async-vault/src/test/compliance.rs) | `rules_off_this_vaults_clock_or_without_the_token_are_never_proposed`, `rules_that_stop_qualifying_are_never_installed`, `a_verifier_that_refuses_the_vault_or_cannot_answer_is_never_proposed`, `a_verifier_that_stops_accepting_the_vault_is_never_installed`, `applied_rule_and_verifier_changes_take_effect`, `an_unbound_compliance_swap_is_refused_and_every_move_goes_on` | +| The share token swaps to new compliance rules only if they list the token and, when they answer `vault()` and `setup_open()`, run on the vault's clock with their setup closed (`ComplianceNotBound`); as at setup, rules that do not answer them count as an external module and are accepted. It swaps to a new verifier only if the verifier accepts the vault (`VerifierRefusesVault`). Both are checked when proposed and again when applied; rules that do not answer `linked_tokens` and a verifier that does not answer are refused | [`contracts/share-token/src/test_governance.rs`](../contracts/share-token/src/test_governance.rs), [`contracts/share-token/src/test_guards.rs`](../contracts/share-token/src/test_guards.rs), [`contracts/async-vault/src/test/compliance.rs`](../contracts/async-vault/src/test/compliance.rs) | `rules_off_this_vaults_clock_or_without_the_token_are_never_proposed`, `rules_that_stop_qualifying_are_never_installed`, `rules_from_outside_the_kit_are_installed_while_they_list_the_token`, `rules_that_stop_answering_a_binding_view_are_installed_as_external`, `a_verifier_that_refuses_the_vault_or_cannot_answer_is_never_proposed`, `a_verifier_that_stops_accepting_the_vault_is_never_installed`, `applied_rule_and_verifier_changes_take_effect`, `an_unbound_compliance_swap_is_refused_and_every_move_goes_on` | | 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; 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 68c03afc..a84e3beb 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -100,7 +100,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. - - A compliance swap needs a contract that names the token's vault through `vault()` with its own setup finished, and lists the token in `linked_tokens` (`ComplianceNotBound`). A verifier swap needs a verifier whose `verify_identity` accepts the vault's address (`VerifierRefusesVault`). Both are checked when the change is proposed and again when it applies, and a contract that does not answer fails the check. + - A compliance swap needs a contract that lists the token in `linked_tokens`; one that answers `vault()` and `setup_open()` must also name the token's vault with its own setup finished (`ComplianceNotBound`). As at `finish_setup`, one that does not answer them is an external module and is accepted. A verifier swap needs a verifier whose `verify_identity` accepts the vault's address (`VerifierRefusesVault`). Both are checked when the change is proposed and again when it applies, and a contract that does not answer `linked_tokens` or `verify_identity` fails the check. - 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. - Once its one-time setup closes, the compliance contract's module changes, token bindings and admin hand-overs wait on the vault's clock under the same rules; a direct call is refused with `UseTimelock`. @@ -535,12 +535,12 @@ compliance contract (OpenZeppelin `TokenBinder`), and calls the oracle's `state` and `freshness_duration` (`OracleFeed`). A replacement for any of these must answer those calls, or the vault's setup cannot finish. -After setup, the share token makes the same kind of check before it swaps a -contract, when the swap is proposed and again when it applies. New compliance -rules must answer `VaultBinding` with the token's vault and their setup closed, -and list the token in `linked_tokens`; unlike `finish_setup`, the token accepts -no external module that does not answer. A new identity verifier must accept -the vault's address in `verify_identity`. +After setup, the share token makes the same check before it swaps a contract, +when the swap is proposed and again when it applies. New compliance rules must +list the token in `linked_tokens`, and rules that answer `VaultBinding` must +name the token's vault with their setup closed. Like `finish_setup`, the token +accepts an external module that does not answer `VaultBinding`, on trust. A new +identity verifier must accept the vault's address in `verify_identity`. --- diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index 4366c0c6..19648541 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -289,9 +289,9 @@ number. | 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 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 (set only by the admin; governance by deploy) | Manager role held only by the vault, for mint, burn and escrow; compliance role held by the compliance authority, for freezes, plus legal transfers and recovery co-signed by the token admin; swapping the compliance or identity-verifier contract needs the admin and, after a one-time setup, waits for the vault's timelock, as do the token's upgrades, role grants and admin hand-overs. New rules must run on the vault's clock and list the token, and a new verifier must accept the vault, both when the swap is proposed and when it applies | +| **Share token** | OZ SEP-41 + SEP-57 RWA extensions: freeze, forced transfer, recovery, identity and compliance checks on every transfer, independent transfer pause (set only by the admin; governance by deploy) | Manager role held only by the vault, for mint, burn and escrow; compliance role held by the compliance authority, for freezes, plus legal transfers and recovery co-signed by the token admin; swapping the compliance or identity-verifier contract needs the admin and, after a one-time setup, waits for the vault's timelock, as do the token's upgrades, role grants and admin hand-overs. New rules must list the token and, if they answer `VaultBinding`, run on the vault's clock; as at setup, an external module that does not answer is accepted on trust. A new verifier must accept the vault. Both are checked when the swap is proposed and when it applies | | **Token roles** | Access-control roles on the share token, checked directly via native access control: `manager` mints, burns and escrows; `compliance` freezes, and makes legal transfers and recoveries co-signed by the token admin | 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 | The compliance authority writes the allowlist on the identity verifier. Governance runs the compliance contract as its admin: adding or removing a module and binding or unbinding a token are instant only during its one-time setup, and after it wait for the vault's timelock, cannot land while the vault is paused and never land during a wind-down. Replaceable by OZ's identity verifier and compliance contracts (with RWA Wizard modules). After setup, the token's swap waits for the vault's timelock, and new rules must report this vault through `VaultBinding` with their setup closed, which OZ's compliance contract does not do on its own | +| **Compliance module** | Implements the SEP-57 identity and rules interfaces the share token consults, with the allowlist as its only rule | The compliance authority writes the allowlist on the identity verifier. Governance runs the compliance contract as its admin: adding or removing a module and binding or unbinding a token are instant only during its one-time setup, and after it wait for the vault's timelock, cannot land while the vault is paused and never land during a wind-down. Replaceable by OZ's identity verifier and compliance contracts (with RWA Wizard modules). After setup, the token's swap waits for the vault's timelock, and new rules must list the token. OZ's compliance contract does not answer `VaultBinding`, so the swap, like the vault's setup, accepts it as an external module whose own governance is a trust assumption | | **Authorities** | governance (parameters, roles, timelocked upgrades), compliance (allowlist, token interventions), attestation (valuation only), treasury (reserve movements only), guardian (pause; never payable claims) | Each contract grants its own roles; the oracle's, share token's and verifier's admins and the oracle's attester and guardian are constructor parameters independent of the vault's roles, equal to governance, compliance and guardian by deploy only; treasury and guardian distinct, treasury and governance distinct, compliance distinct from governance and treasury; governance and guardian may coincide; the vault's manager role is unconstrained | | **Custodian** | Off-chain party holding the real-world structure; a genesis-configured slot rotatable only by governance | Not an on-chain authority | @@ -628,7 +628,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. A compromised attester is replaced in this order: pause the NAV, revoke the attester (instant), grant a replacement (through the timelock after setup, so the NAV stays paused that long), clear the stored report, which would otherwise price epochs again once the pause lifts, then lift the pause ([`kit-spec.md`](./kit-spec.md) §3) | -| Governance keys | Malicious upgrade | Timelock on every code change and on the delay itself: at least seven days, never shorter than the standing notice, at most 90 days. The delay is an announcement period. A pause freezes the timelock clock and pushes the ETA back by the time paused, so the whole delay runs unpaused, and applying while paused is refused. The delay does not guarantee an exit before a change lands: an exit also needs a sealed epoch, a valid price accepted after the seal, and cash in the reserve to pay the claim. Nothing is proposed or applied while the vault winds down. The vault's own custodian, role grants, `manager` revocations, admin handovers and lowering of the notice or the wind-down delay wait on that clock once its one-time setup closes, and it takes no deposit before then; revoking its treasury or guardian stays instant. Setup closes only once the token and the oracle run on the vault's clock with their own setup closed, the vault is the token's only minter, no shares exist yet (shares minted earlier would redeem against depositors' money), the vault passes the identity check, its address is not frozen on the token, the token is bound to its compliance contract, the asset has the token's decimals, the asset, the token, the oracle and the vault are four different addresses, the oracle answers the feed interface, and a custodian other than the vault is set; the kit's own compliance contract must also run on the vault's clock with its setup closed. The share token's upgrades, grants, `manager` revocations, admin handovers and compliance or verifier swaps wait on the same clock once its setup closes; new rules must also run on that clock and list the token, and a new verifier must accept the vault. 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. On the kit's compliance contract, adding or removing a module, binding or unbinding a token and handing over the admin wait on the same clock once its setup closes. | +| Governance keys | Malicious upgrade | Timelock on every code change and on the delay itself: at least seven days, never shorter than the standing notice, at most 90 days. The delay is an announcement period. A pause freezes the timelock clock and pushes the ETA back by the time paused, so the whole delay runs unpaused, and applying while paused is refused. The delay does not guarantee an exit before a change lands: an exit also needs a sealed epoch, a valid price accepted after the seal, and cash in the reserve to pay the claim. Nothing is proposed or applied while the vault winds down. The vault's own custodian, role grants, `manager` revocations, admin handovers and lowering of the notice or the wind-down delay wait on that clock once its one-time setup closes, and it takes no deposit before then; revoking its treasury or guardian stays instant. Setup closes only once the token and the oracle run on the vault's clock with their own setup closed, the vault is the token's only minter, no shares exist yet (shares minted earlier would redeem against depositors' money), the vault passes the identity check, its address is not frozen on the token, the token is bound to its compliance contract, the asset has the token's decimals, the asset, the token, the oracle and the vault are four different addresses, the oracle answers the feed interface, and a custodian other than the vault is set; the kit's own compliance contract must also run on the vault's clock with its setup closed. The share token's upgrades, grants, `manager` revocations, admin handovers and compliance or verifier swaps wait on the same clock once its setup closes; new rules must also list the token and, if they answer `VaultBinding`, run on that clock (an external module that does not answer is taken on trust, as at setup), and a new verifier must accept the vault. 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. On the kit's compliance contract, adding or removing a module, binding or unbinding a token and handing over the admin wait on the same clock once its setup closes. | | Mis-wired deployment | A vault goes live without every binding wired | `health()` reports each wiring step; `finish_setup` refuses until all hold, and the vault takes no deposit before that | | 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 | From 4103bb46090e0313134f10e419a60841b319e71b Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:08:25 -0300 Subject: [PATCH 145/147] test(token): pin the exact compliance swap rule A compliance contract that answers vault() with None now has a test: the swap is refused at apply. A rule that treated that answer as external would have passed every test. The test for a contract that stops answering one binding view now gives the other view an answer that would fail the check. It passes only under the vault's rule: once either view fails, the other is ignored. The guard test that said every rule is refused before a vault is bound now says it refuses rules that name a vault before one is bound. The vault's external stub comment says it has no binding view. --- contracts/async-vault/src/test/setup.rs | 2 +- contracts/share-token/src/test_governance.rs | 37 +++++++++++++------- contracts/share-token/src/test_guards.rs | 2 +- 3 files changed, 26 insertions(+), 15 deletions(-) diff --git a/contracts/async-vault/src/test/setup.rs b/contracts/async-vault/src/test/setup.rs index bd798e68..83b0133a 100644 --- a/contracts/async-vault/src/test/setup.rs +++ b/contracts/async-vault/src/test/setup.rs @@ -81,7 +81,7 @@ fn every_wiring_step_is_checked_before_setup_finishes() { } } -/// A compliance contract from outside the kit: it lists tokens and reports no vault. +/// A compliance contract from outside the kit, with no binding view. #[contract] struct ExternalRules; diff --git a/contracts/share-token/src/test_governance.rs b/contracts/share-token/src/test_governance.rs index 53c26090..df254c38 100644 --- a/contracts/share-token/src/test_governance.rs +++ b/contracts/share-token/src/test_governance.rs @@ -106,8 +106,7 @@ impl strata_bindings::VaultBinding for RulesStub { } } -/// Compliance rules from outside the kit, such as OpenZeppelin's: they list -/// the tokens a test sets and answer no binding view. +/// Rules from outside the kit, such as OpenZeppelin's, with no binding view. #[contract] struct ExternalRules; @@ -783,6 +782,7 @@ fn rules_that_stop_qualifying_are_never_installed() { refused_once_the_rules_turn(&f, |rules| { rules.report(&Some(clock_stub(&f.e).address), &false, &listed) }); + refused_once_the_rules_turn(&f, |rules| rules.report(&None, &false, &listed)); refused_once_the_rules_turn(&f, |rules| rules.report(&here, &true, &listed)); refused_once_the_rules_turn(&f, |rules| rules.silence()); } @@ -808,22 +808,33 @@ fn rules_from_outside_the_kit_are_installed_while_they_list_the_token() { assert_eq!(f.token.compliance(), kept.address); } -/// As at the vault's setup, a contract that fails either binding view counts -/// as external, so only its `linked_tokens` decides. +/// Like `refused_once_the_rules_turn`, but the swap still lands. +fn installed_once_the_rules_turn(f: &Fixture, turn: impl FnOnce(&RulesStubClient)) { + let rules = f.rules_stub_bound_here(); + let id = f.propose(TokenChange::Compliance(rules.address.clone())); + turn(&rules); + f.advance(MIN_DELAY); + + f.token.apply_change(&id); + assert_eq!(f.token.compliance(), rules.address); +} + +/// As at the vault's setup, once one view fails, the other is ignored. #[test] fn rules_that_stop_answering_a_binding_view_are_installed_as_external() { let f = setup(); f.finish(); + let here = Some(f.vault.address.clone()); + let listed = vec![&f.e, f.token.address.clone()]; - for view in [symbol_short!("vault"), symbol_short!("open")] { - let rules = f.rules_stub_bound_here(); - let id = f.propose(TokenChange::Compliance(rules.address.clone())); - rules.forget(&view); - f.advance(MIN_DELAY); - - f.token.apply_change(&id); - assert_eq!(f.token.compliance(), rules.address); - } + installed_once_the_rules_turn(&f, |rules| { + rules.report(&Some(clock_stub(&f.e).address), &false, &listed); + rules.forget(&symbol_short!("open")); + }); + installed_once_the_rules_turn(&f, |rules| { + rules.report(&here, &true, &listed); + rules.forget(&symbol_short!("vault")); + }); } #[test] diff --git a/contracts/share-token/src/test_guards.rs b/contracts/share-token/src/test_guards.rs index acb2a635..6c99d594 100644 --- a/contracts/share-token/src/test_guards.rs +++ b/contracts/share-token/src/test_guards.rs @@ -147,7 +147,7 @@ fn not_a_second_upgrade_refuses_an_upgrade_while_one_waits() { } #[test] -fn compliance_bound_here_refuses_rules_without_the_token_or_before_the_vault_is_known() { +fn compliance_bound_here_refuses_rules_without_the_token_or_naming_a_vault_before_one_is_bound() { let f = setup(); let listing = f.rules_listing(&[&f.token.address]); refuses(&f, ShareTokenError::ComplianceNotBound, || { From 479f3a4eb7d5f85c4cc2064305b341a76cc901e5 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:08:44 -0300 Subject: [PATCH 146/147] refactor(token): clarify the compliance check on_the_vaults_clock returned true for an external module, which is not on the vault's clock. It is now bound_here_or_external, and the vault's matching local has the same name. The comment on the catch-all arm now says that either view failing marks an external module, not only a contract that answers neither. The ComplianceNotBound and propose_change docs said "another vault or an open setup", which left out a contract that names no vault. They now say the contract does not name this token's vault with its setup closed. --- contracts/async-vault/src/governance.rs | 6 +++--- contracts/share-token/src/contract.rs | 3 ++- contracts/share-token/src/error.rs | 7 ++++--- contracts/share-token/src/guards.rs | 11 ++++------- 4 files changed, 13 insertions(+), 14 deletions(-) diff --git a/contracts/async-vault/src/governance.rs b/contracts/async-vault/src/governance.rs index 1accb496..8a4152ea 100644 --- a/contracts/async-vault/src/governance.rs +++ b/contracts/async-vault/src/governance.rs @@ -144,12 +144,12 @@ fn compliance_bound(e: &Env, compliance: &Address, token: &Address) -> bool { Ok(Ok(linked)) if linked.contains(token) ); let binding = VaultBindingClient::new(e, compliance); - let on_this_clock = match (binding.try_vault(), binding.try_setup_open()) { + let bound_here_or_external = match (binding.try_vault(), binding.try_setup_open()) { (Ok(Ok(vault)), Ok(Ok(open))) => vault == Some(e.current_contract_address()) && !open, - // An external module answers no binding view; its governance is a trust assumption. + // Either view failing marks an external module; its governance is a trust assumption. _ => true, }; - linked && on_this_clock + linked && bound_here_or_external } fn all_different(addresses: &[&Address]) -> bool { diff --git a/contracts/share-token/src/contract.rs b/contracts/share-token/src/contract.rs index f743e051..c919fae0 100644 --- a/contracts/share-token/src/contract.rs +++ b/contracts/share-token/src/contract.rs @@ -127,7 +127,8 @@ impl ShareToken { /// # Errors /// /// * `ComplianceNotBound` - The new compliance contract does not list - /// this token, or reports another vault or an open setup. + /// this token, or answers `VaultBinding` but does not name this token's + /// vault with its setup closed. /// * `VerifierRefusesVault` - The new verifier does not accept the vault. /// * `UpgradeAlreadyPending` - An upgrade already waits. /// * `SetupOpen` - Setup has not finished; change it directly. diff --git a/contracts/share-token/src/error.rs b/contracts/share-token/src/error.rs index eb84b777..4a29c6a1 100644 --- a/contracts/share-token/src/error.rs +++ b/contracts/share-token/src/error.rs @@ -56,9 +56,10 @@ strata_bindings::error_enum! { AdminTransferPending = 7016, /// The proposed compliance contract's `linked_tokens` leaves out this /// token, so every mint, transfer and burn would fail. Or it answers - /// `vault()` and `setup_open()` with another vault or an open setup, so - /// its rules could change without the vault's delay. One that does not - /// answer them is an external module and passes, on trust. + /// `vault()` and `setup_open()` but does not name this token's vault + /// with its setup closed, so its rules could change without the + /// vault's delay. One that leaves either view unanswered is an + /// external module and passes, on trust. ComplianceNotBound = 7017, /// The proposed identity verifier refuses the vault's address or does /// not answer `verify_identity`. The vault needs that check to convert diff --git a/contracts/share-token/src/guards.rs b/contracts/share-token/src/guards.rs index 0a42d542..62b07327 100644 --- a/contracts/share-token/src/guards.rs +++ b/contracts/share-token/src/guards.rs @@ -77,10 +77,9 @@ pub(crate) fn valid_change(e: &Env, change: &TokenChange) { } } -/// Rules that leave out this token stop every mint, transfer and burn; rules -/// that report another vault or an open setup could change with no delay. +/// The same rule the vault's `finish_setup` applies to compliance contracts. pub(crate) fn compliance_bound_here(e: &Env, rules: &Address) { - if !on_the_vaults_clock(e, rules) || !lists_this_token(e, rules) { + if !bound_here_or_external(e, rules) || !lists_this_token(e, rules) { panic_with_error!(e, ShareTokenError::ComplianceNotBound); } } @@ -97,15 +96,13 @@ pub(crate) fn verifier_accepts_vault(e: &Env, verifier: &Address) { } } -/// When `contract` answers both binding views, they name this token's vault -/// with its setup closed. -fn on_the_vaults_clock(e: &Env, contract: &Address) -> bool { +fn bound_here_or_external(e: &Env, contract: &Address) -> bool { let binding = VaultBindingClient::new(e, contract); match (binding.try_vault(), binding.try_setup_open()) { (Ok(Ok(named)), Ok(Ok(open))) => { setup::source(e).is_some_and(|vault| named == Some(vault)) && !open } - // An external module answers no binding view; its governance is a trust assumption. + // Either view failing marks an external module; its governance is a trust assumption. _ => true, } } From 4f0bb2127f22e090004652eda5ce911dfe973f9d Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Tue, 27 Oct 2026 12:52:38 -0300 Subject: [PATCH 147/147] chore: point the README and the app at the new testnet deployment --- README.md | 10 +++++----- environments.toml | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 0992729d..daedd190 100644 --- a/README.md +++ b/README.md @@ -35,11 +35,11 @@ deploy from `environments.toml` → `[staging.contracts]`, which is the record. | Contract | Address | | --- | --- | -| `async_vault` | `CD5LYYXGOJ7R23P6D7YUN6DUJQYMI23KEUEOIFKXOSWOX3KHKTXRITED` | -| `nav_oracle` | `CBKVMUMIX7MXYHX5K34N5HYZOP6S2VGWF7MN27RIWBV3A3WD3X3L63CO` | -| `share_token` | `CDOISQTQRCYOTH6S3DFQ6UKJ2TEXLNO7NFIGOZS4RZL63IRCVXXCGP5N` | -| `identity_verifier` | `CCTCCY7AVFAIRGEWWJZT2UDMBHS3NGNKVUWLY43CHDNN75AQEV6P3EOY` | -| `compliance` | `CDEH6FJBK7NDFLALO3X3GDXZUREYHUSNC7ODPM64RBG3BBXW2USMW7OO` | +| `async_vault` | `CCDWVENC7GNFO757JON65OKWJXHYIEGPVPDWVCGRZOPAHOQURPHQRPUG` | +| `nav_oracle` | `CADX6LVWOQ6DGFHEQ6AMJHDPKWOO2RTHEEIERVPWHOUJCN3WNEGYHHL3` | +| `share_token` | `CDRAUSDVE7OKWKCJJNPD4HYTMLGF5XFVG5DY7KMLQCBHJTW53TYN7UGK` | +| `identity_verifier` | `CBZEZBVOFNEYBGWRMON7WDFAXCNLH3DEAF3EDIS7PJRSLY6BK7XN5O7J` | +| `compliance` | `CDHEEBIAMKLMJHJZHQ3R63PVWQUMQAX4WY6WDODZOY2E5X5GNSFFG6DR` | | `asset` | `CCOHKZLQ5EAD2A3RLHYHH7W4K2TNLNELXUVLWZIZALH45WXHH5H3QV7E` | diff --git a/environments.toml b/environments.toml index 28b5acee..5ba7c522 100644 --- a/environments.toml +++ b/environments.toml @@ -82,11 +82,11 @@ default = true # underlying Stellar Asset Contract and has no crate here; it still gets a # generated client, because a SAC carries its own spec. [staging.contracts] -async_vault = { id = "CD5LYYXGOJ7R23P6D7YUN6DUJQYMI23KEUEOIFKXOSWOX3KHKTXRITED" } -nav_oracle = { id = "CBKVMUMIX7MXYHX5K34N5HYZOP6S2VGWF7MN27RIWBV3A3WD3X3L63CO" } -share_token = { id = "CDOISQTQRCYOTH6S3DFQ6UKJ2TEXLNO7NFIGOZS4RZL63IRCVXXCGP5N" } -identity_verifier = { id = "CCTCCY7AVFAIRGEWWJZT2UDMBHS3NGNKVUWLY43CHDNN75AQEV6P3EOY" } -compliance = { id = "CDEH6FJBK7NDFLALO3X3GDXZUREYHUSNC7ODPM64RBG3BBXW2USMW7OO" } +async_vault = { id = "CCDWVENC7GNFO757JON65OKWJXHYIEGPVPDWVCGRZOPAHOQURPHQRPUG" } +nav_oracle = { id = "CADX6LVWOQ6DGFHEQ6AMJHDPKWOO2RTHEEIERVPWHOUJCN3WNEGYHHL3" } +share_token = { id = "CDRAUSDVE7OKWKCJJNPD4HYTMLGF5XFVG5DY7KMLQCBHJTW53TYN7UGK" } +identity_verifier = { id = "CBZEZBVOFNEYBGWRMON7WDFAXCNLH3DEAF3EDIS7PJRSLY6BK7XN5O7J" } +compliance = { id = "CDHEEBIAMKLMJHJZHQ3R63PVWQUMQAX4WY6WDODZOY2E5X5GNSFFG6DR" } asset = { id = "CCOHKZLQ5EAD2A3RLHYHH7W4K2TNLNELXUVLWZIZALH45WXHH5H3QV7E" } ### Production environment configuration