Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions contracts/async-vault/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 12 additions & 1 deletion contracts/async-vault/src/governance.rs
Original file line number Diff line number Diff line change
@@ -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,
};
Expand Down Expand Up @@ -45,6 +45,7 @@ pub enum VaultChange {
GrantRole(Symbol, Address),
RevokeRole(Symbol, Address),
Admin(Address),
Oracle(Address),
}

#[contracttype]
Expand Down Expand Up @@ -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(..)
Expand Down Expand Up @@ -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);
}
Expand Down
1 change: 1 addition & 0 deletions contracts/async-vault/src/test/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ mod lock_policy;
mod multi_epoch;
mod notice;
mod oracle_pricing;
mod oracle_swap;
mod pricing;
mod redeem;
mod settlement;
Expand Down
167 changes: 167 additions & 0 deletions contracts/async-vault/src/test/oracle_swap.rs
Original file line number Diff line number Diff line change
@@ -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<Address>) {
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<Address> {
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(&register_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);
}
4 changes: 3 additions & 1 deletion docs/AUTHORIZATION_MATRIX.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/).

Expand All @@ -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`. |

Expand Down
1 change: 1 addition & 0 deletions docs/kit-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

---

Expand Down
Loading