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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
45 changes: 45 additions & 0 deletions contracts/async-vault/src/test/upgrade.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;

Expand All @@ -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));
Expand Down
7 changes: 7 additions & 0 deletions contracts/nav-oracle/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
187 changes: 187 additions & 0 deletions contracts/nav-oracle/src/governance.rs
Original file line number Diff line number Diff line change
@@ -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<Clock, Refusal> {
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 {
<NavOracleContract as TimelockHost>::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::<OracleChange>(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::<NavOracleContract>(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::<NavOracleContract, _>(e, &change).unwrap_or_else(|r| refuse(e, r));
let (_, schedule) =
queue::pending::<OracleChange>(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::<NavOracleContract, _>(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<OraclePendingChange> {
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::<OracleChange>(e, id) {
out.push_back(OraclePendingChange {
id,
change,
eta: schedule.eta(&clock),
});
}
}
out
}
Loading
Loading