diff --git a/contracts/async-vault/src/error.rs b/contracts/async-vault/src/error.rs index c9d05a0..a4cf8c4 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 3fd0849..4329f79 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 51b42d1..1999e52 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 0000000..ef5966f --- /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 93d2e67..eff093f 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 91fe5d7..3d0a64a 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. | ---