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

Expand All @@ -135,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
Expand Down
2 changes: 2 additions & 0 deletions contracts/async-vault/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
2 changes: 2 additions & 0 deletions contracts/async-vault/src/deposit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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 {
Expand Down
18 changes: 18 additions & 0 deletions contracts/async-vault/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,14 +115,32 @@ 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, 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.
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,
/// 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,
}
3 changes: 3 additions & 0 deletions contracts/async-vault/src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,9 @@ pub struct ChangeApplied {
pub change: VaultChange,
}

#[contractevent]
pub struct SetupFinished {}

#[contractevent]
pub struct WindDownDelaySet {
pub secs: u64,
Expand Down
204 changes: 187 additions & 17 deletions contracts/async-vault/src/governance.rs
Original file line number Diff line number Diff line change
@@ -1,23 +1,50 @@
use soroban_sdk::{contracttype, panic_with_error, BytesN, Env, Vec};
use bindings::VaultBindingClient;
use soroban_sdk::{
contracttype, panic_with_error, symbol_short, 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::{
fungible::FungibleTokenClient,
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]
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum VaultChange {
Upgrade(BytesN<32>),
UpgradeDelay(u64),
Custodian(Address),
Notice(u64),
WindDownDelay(u64),
GrantRole(Symbol, Address),
RevokeRole(Symbol, Address),
Admin(Address),
}

#[contracttype]
Expand All @@ -28,6 +55,31 @@ 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,
}

impl Wiring {
fn complete(&self) -> bool {
self.token_bound
&& self.oracle_bound
&& self.sole_token_manager
&& self.no_shares_outstanding
&& 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 {
Expand All @@ -40,21 +92,107 @@ 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),
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)
}
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(&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(()))
),
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::<AsyncVault>(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,
Expand All @@ -69,11 +207,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);
}
Expand All @@ -95,17 +247,35 @@ pub(crate) fn cancel(e: &Env, id: u32) {
pub(crate) fn apply(e: &Env, id: u32) {
let change: VaultChange =
queue::take_ready::<AsyncVault, _>(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<VaultPendingChange> {
let clock = clock(e);
let mut out = Vec::new(e);
Expand Down
Loading
Loading