diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5f7a7ab7..79b1f0f5 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 @@ -148,10 +158,45 @@ 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@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: 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/AGENTS.md b/AGENTS.md index 7ad91313..193f9d71 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,15 +20,18 @@ 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; - a delisted, non-frozen investor leaves through the exit-only cash path. -3. **Cancellation is atomic and single-step**, open only until the attestation - that prices the request is accepted. There is no instant exit. + 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 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. @@ -111,6 +114,45 @@ 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. 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`. +- **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, 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. +- **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.** @@ -118,8 +160,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. diff --git a/Cargo.lock b/Cargo.lock index 2eda79d5..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", @@ -194,7 +193,10 @@ dependencies = [ "stellar-access", "stellar-contract-utils", "stellar-macros", - "storage", + "stellar-tokens", + "strata-bindings", + "strata-storage", + "strata-timelock", ] [[package]] @@ -221,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" @@ -353,6 +348,9 @@ dependencies = [ "stellar-access", "stellar-macros", "stellar-tokens", + "strata-bindings", + "strata-storage", + "strata-timelock", ] [[package]] @@ -1031,7 +1029,7 @@ dependencies = [ "stellar-access", "stellar-macros", "stellar-tokens", - "storage", + "strata-storage", ] [[package]] @@ -1208,11 +1206,13 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" name = "nav-oracle" version = "0.1.0" dependencies = [ - "bindings", "soroban-sdk", "stellar-access", + "stellar-contract-utils", "stellar-macros", - "storage", + "strata-bindings", + "strata-storage", + "strata-timelock", ] [[package]] @@ -1711,6 +1711,9 @@ dependencies = [ "stellar-contract-utils", "stellar-macros", "stellar-tokens", + "strata-bindings", + "strata-storage", + "strata-timelock", ] [[package]] @@ -2052,12 +2055,28 @@ 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]] name = "strsim" version = "0.11.1" diff --git a/README.md b/README.md index b3770010..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` | @@ -139,16 +139,21 @@ Output of a real run from a fresh clone. Contract IDs differ on every run. deploying to local (http://localhost:8000/rpc) account keys in .env.keys.local (13 new) funded 13 accounts; 0 already existed -asset contract CDKWXRAHPQVFOAEBOOWWR62ZXAE2IV4V47PATFN7IX4XDW5QIUUL7D2B -compliance CDGKQCYSMTDRBO7SYV3V72H6AMCQ3UNQFKUU4AD5GSU2I32XDYFS6F3U -identity_verifier CCZE76VWEWJ47NSFMIXCKU3L55CZZTI5JJY4CSYAWPO3WXPDFXUCA5G6 -share_token CD6IA3BOPFX7VNADJ3GFK7IZGPVPNZA6HBCF425CNPSQ2GN7QZ3UUCP2 -nav_oracle CAHD5ZKMIDD67ZEJUB5ACR62FMAYMQJ2KCRV2MBDNXWT7WEZ2VV3MRDD -async_vault CDQNMAZP7D5IA7AXZGK3XWYPFD2LDR6V5HQD2OLXZJYNQN4BMNN6SXWJ +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 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 {"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 ``` @@ -159,8 +164,32 @@ 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. `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 + +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/contracts/async-vault/Cargo.toml b/contracts/async-vault/Cargo.toml index 2da5c3d3..64160de6 100644 --- a/contracts/async-vault/Cargo.toml +++ b/contracts/async-vault/Cargo.toml @@ -11,11 +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 } -bindings = { path = "../../crates/bindings" } +stellar-tokens = { workspace = true } +strata-bindings = { path = "../../crates/strata-bindings" } +strata-timelock = { path = "../../crates/strata-timelock" } [dev-dependencies] @@ -24,7 +26,9 @@ 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 = [] +# 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 b4023db6..15cc8643 100644 --- a/contracts/async-vault/src/deposit.rs +++ b/contracts/async-vault/src/deposit.rs @@ -1,42 +1,26 @@ -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; -use crate::event::{DepositCancelled, DepositClaimed, DepositRequested}; +use crate::event::{DepositCancelled, DepositClaimed, DepositConvertedToExit, DepositRequested}; +use crate::guards; use crate::keys::DataKey; +use crate::lock_policy::{self, Decision, Exit, Standing}; use crate::pricing::{Pricing, PricingScheme}; -use crate::state::{self, DepositRequest, EpochStatus}; -use crate::treasury; +use crate::redeem; +use crate::settlement; +use crate::state::{self, DepositRequest}; use crate::wind_down; -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, from: &Address, amount: i128) -> u64 { - wind_down::refuse_if_active(e); - from.require_auth(); - - if amount <= 0 { - panic_with_error!(e, VaultError::InvalidAmount); - } - refuse_if_exceeds_cap(e, amount); - +pub(crate) fn request(e: &Env, holder: &Address, amount: i128) -> u64 { + guards::not_winding_down(e); + guards::setup_finished(e); + holder.require_auth(); + 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, from).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)); @@ -49,20 +33,24 @@ pub(crate) fn request(e: &Env, from: &Address, amount: i128) -> u64 { state::set_deposit_request( e, epoch_id, - from, + holder, &DepositRequest { amount, claimed: false, }, ); 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); - 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, } @@ -71,98 +59,122 @@ 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)); - - if epoch.status != EpochStatus::Fulfilled { - panic_with_error!(e, VaultError::EpochNotFulfilled); - } - - let mut request = state::get_deposit_request(e, epoch_id, caller) + 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)); + 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); - } - return refund_deposit(e, caller, epoch_id, request.amount); + guards::refund_covered(e, 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_pending_mint_shares(e, state::pending_mint_shares(e).saturating_sub(shares)); + 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, holder, epoch_id, shares, standing, decision); + return 0; + } 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(holder, &shares, &e.current_contract_address()); DepositClaimed { - controller: caller.clone(), + holder: holder.clone(), epoch: epoch_id, amount: request.amount, shares, } .publish(e); + settlement::on_deposit_claimed(e, epoch_id, shares); 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 { + holder: 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) -> 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 -= 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); + 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); - - 0 } -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)); - - 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); - } - - let request = state::get_deposit_request(e, epoch_id, from) + 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)); + guards::allowed_to(e, Exit::CancelDeposit, holder); - state::set_cancellable_escrow(e, state::cancellable_escrow(e) - request.amount); - refund_deposit(e, from, epoch_id, 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, holder, epoch_id, request.amount); request.amount } diff --git a/contracts/async-vault/src/epoch.rs b/contracts/async-vault/src/epoch.rs index ce915f3e..2ed633ab 100644 --- a/contracts/async-vault/src/epoch.rs +++ b/contracts/async-vault/src/epoch.rs @@ -1,13 +1,15 @@ -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}; +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; @@ -48,22 +50,20 @@ 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 { - 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) @@ -90,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)); @@ -108,8 +105,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 +122,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 +137,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 9d487fff..8f22f895 100644 --- a/contracts/async-vault/src/error.rs +++ b/contracts/async-vault/src/error.rs @@ -1,114 +1,173 @@ 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 controller 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 - /// second request is rejected rather than added to the first, so that at - /// most one request per controller 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 - /// controller claims instead. - AlreadyPriced = 6039, - /// 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 proposal already stands. 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, - /// 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. - 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, +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 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, + /// 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, wind-down proposals and timelocked + /// 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. 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, + /// 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/async-vault/src/event.rs b/contracts/async-vault/src/event.rs index 26f1cf5a..b2ba60ac 100644 --- a/contracts/async-vault/src/event.rs +++ b/contracts/async-vault/src/event.rs @@ -1,9 +1,11 @@ use soroban_sdk::{contractevent, Address}; +use crate::governance::VaultChange; + #[contractevent] pub struct DepositRequested { #[topic] - pub controller: Address, + pub holder: Address, pub epoch: u64, pub amount: i128, } @@ -19,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, @@ -28,7 +30,7 @@ pub struct DepositClaimed { #[contractevent] pub struct RedeemRequested { #[topic] - pub controller: Address, + pub holder: Address, pub epoch: u64, pub shares: i128, } @@ -36,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, @@ -72,7 +74,7 @@ pub struct Deployed { #[contractevent] pub struct Funded { #[topic] - pub from: Address, + pub caller: Address, pub assets: i128, pub net_deployed: i128, } @@ -80,7 +82,7 @@ pub struct Funded { #[contractevent] pub struct DepositCancelled { #[topic] - pub controller: Address, + pub holder: Address, pub epoch: u64, pub amount: i128, } @@ -88,34 +90,34 @@ pub struct DepositCancelled { #[contractevent] pub struct RedeemCancelled { #[topic] - pub controller: Address, + pub holder: Address, pub epoch: u64, pub shares: i128, } #[contractevent] -pub struct UpgradeProposed { +pub struct ChangeProposed { + #[topic] + pub id: u32, + pub change: VaultChange, pub eta: u64, } #[contractevent] -pub struct UpgradeDelayProposed { - pub secs: u64, - pub eta: u64, +pub struct ChangeCancelled { + #[topic] + pub id: u32, } #[contractevent] -pub struct UpgradeCancelled {} - -#[contractevent] -pub struct UpgradeDelaySet { - pub secs: u64, +pub struct ChangeApplied { + #[topic] + pub id: u32, + pub change: VaultChange, } #[contractevent] -pub struct Upgraded { - pub schema_version: u32, -} +pub struct SetupFinished {} #[contractevent] pub struct WindDownDelaySet { @@ -150,6 +152,33 @@ pub struct WindDownClaimed { pub assets: i128, } +/// An escrowed redemption burned into the holder's wind-down entitlement. +#[contractevent] +pub struct RedemptionSurrendered { + #[topic] + pub holder: 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 holder: Address, + pub epoch: u64, + pub into_epoch: u64, + pub shares: 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/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 new file mode 100644 index 00000000..8a4152ea --- /dev/null +++ b/contracts/async-vault/src/governance.rs @@ -0,0 +1,284 @@ +use soroban_sdk::{ + contracttype, panic_with_error, symbol_short, token::TokenClient, Address, BytesN, Env, Symbol, + Vec, +}; +use stellar_access::{ + access_control::{self, AccessControlClient, AccessControlStorageKey}, + role_transfer, +}; +use stellar_contract_utils::{ + pausable::{self, PausableError}, + upgradeable, +}; +use stellar_tokens::{ + fungible::FungibleTokenClient, + rwa::{ + identity_verification::IdentityVerifierClient, utils::token_binder::TokenBinderClient, + RWATokenClient, + }, +}; +use strata_bindings::{OracleFeedClient, VaultBindingClient}; +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; +use crate::treasury; +use crate::upgrade; +use crate::wind_down; +use crate::AsyncVault; + +/// 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), + Oracle(Address), +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VaultPendingChange { + pub id: u32, + pub change: VaultChange, + 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. `health()` reports +/// 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, + pub sole_token_manager: bool, + /// 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, + pub decimals_match: bool, + pub addresses_distinct: bool, + pub custodian_set: bool, + pub feed_answers: bool, +} + +impl Wiring { + pub(crate) fn complete(&self) -> bool { + self.token_bound + && self.oracle_bound + && self.sole_token_manager + && self.no_shares_outstanding + && self.allowlisted + && self.vault_unfrozen + && self.compliance_bound + && self.decimals_match + && self.addresses_distinct + && self.custodian_set + && self.feed_answers + } +} + +/// 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: strata_timelock::pause::paused_secs(e), + paused: pausable::paused(e), + winding_down: wind_down::is_active(e), + } +} + +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), + 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), + } +} + +/// 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))) +} + +/// 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(), + Ok(Ok(linked)) if linked.contains(token) + ); + let binding = VaultBindingClient::new(e, compliance); + 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, + // Either view failing marks an external module; its governance is a trust assumption. + _ => true, + }; + linked && bound_here_or_external +} + +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); + Wiring { + token_bound: bound_here(e, &token), + oracle_bound: bound_here(e, &oracle), + 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 + ), + 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), + Ok(Ok(())) + ), + _ => false, + }, + vault_unfrozen: matches!(rwa.try_is_frozen(&me), Ok(Ok(false))), + compliance_bound: match rwa.try_compliance() { + 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: answers_as_feed(e, &oracle), + } +} + +pub(crate) fn finish_setup(e: &Env) { + 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); +} + +pub(crate) fn set_notice(e: &Env, secs: u64) { + state::set_notice(e, secs); + NoticeSet { secs }.publish(e); +} + +pub(crate) fn propose(e: &Env, change: VaultChange) -> u32 { + guards::not_winding_down(e); + guards::setup_finished(e); + 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 { + 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)); + // The notice or the delay may have moved 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, VaultError::AdminRequired)); + match &change { + 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); + extend::role_entries(e, account, role); + } + VaultChange::RevokeRole(role, account) => { + access_control::revoke_role_no_auth(e, account, role, &admin) + } + VaultChange::Admin(next) => offer_admin(e, &admin, next), + VaultChange::Oracle(feed) => state::set_addr(e, &DataKey::Oracle, feed), + } + 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); + 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/guards.rs b/contracts/async-vault/src/guards.rs new file mode 100644 index 00000000..577eb4c9 --- /dev/null +++ b/contracts/async-vault/src/guards.rs @@ -0,0 +1,376 @@ +use soroban_sdk::{panic_with_error, Address, Env, Symbol}; +use stellar_access::{ + access_control::{self, 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); + } +} + +/// 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); + } +} + +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); + } +} + +/// 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); + } +} + +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); + } +} + +/// 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); + } + 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 custodian_not_the_vault(e: &Env, custodian: &Address) { + if *custodian == e.current_contract_address() { + panic_with_error!(e, VaultError::InvalidCustodian); + } +} + +/// A feed that cannot answer would stall every epoch after the swap. +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); + } +} + +/// 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_and_answering(e, feed), + VaultChange::Custodian(custodian) => custodian_not_the_vault(e, custodian), + VaultChange::Upgrade(_) + | VaultChange::GrantRole(..) + | VaultChange::RevokeRole(..) + | VaultChange::Admin(_) => {} + } +} + +/// One code change at a time, so a standing eta is never quietly replaced. +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); + } +} + +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); + } + } +} + +/// 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); + } +} + +/// 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); + } +} + +/// 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); + } +} + +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. +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/health.rs b/contracts/async-vault/src/health.rs new file mode 100644 index 00000000..cef963dc --- /dev/null +++ b/contracts/async-vault/src/health.rs @@ -0,0 +1,59 @@ +use soroban_sdk::{contracttype, Env}; +use strata_bindings::{OracleFeedClient, OracleState}; + +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. On a healthy vault `setup_open` is false and every +/// other field is true. +#[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 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. + 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: strata_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, + 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(), + Ok(Ok(freshness)) if freshness >= notice + ), + } +} diff --git a/contracts/async-vault/src/keys.rs b/contracts/async-vault/src/keys.rs index 401cd278..ad78eec9 100644 --- a/contracts/async-vault/src/keys.rs +++ b/contracts/async-vault/src/keys.rs @@ -17,13 +17,13 @@ pub(crate) enum DataKey { UserDeposit(u64, Address), UserRedeem(u64, Address), UpgradeDelay, - UpgradeProposal, - PausedAt, WindDownDelay, WindDown, WindDownSupply, 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 69b552d7..d423b821 100644 --- a/contracts/async-vault/src/lib.rs +++ b/contracts/async-vault/src/lib.rs @@ -1,25 +1,69 @@ +//! # 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.** 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 +//! 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] mod deposit; mod epoch; mod error; mod event; +mod extend; +mod governance; +mod guards; +mod health; mod keys; +mod lock_policy; mod pricing; mod redeem; mod roles; +mod settlement; mod state; mod timing; mod treasury; 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 stellar_tokens::fungible::FungibleTokenClient; +use strata_timelock::{Clock, ClockSource, Refusal, TimelockHost, DAY_DURATION}; use keys::DataKey; use roles::{GUARDIAN_ROLE, MANAGER_ROLE, TREASURY_ROLE}; @@ -27,21 +71,29 @@ 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, DepositConvertedToExit, DepositRequested, EpochClosed, EpochFulfilled, + EpochReconciled, Funded, NoticeSet, RedeemClaimed, RedeemRequested, RedemptionSurrendered, + SetupFinished, WindDownActivated, WindDownClaimed, WindDownDelaySet, WindDownProposalCancelled, + 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; pub use state::{DepositRequest, EpochInfo, EpochStatus, RedeemRequest}; -pub use upgrade::{UpgradeAction, UpgradeProposal, MAX_UPGRADE_DELAY, MIN_UPGRADE_DELAY}; -pub use wind_down::{WindDownInfo, WindDownPosition, WindDownStatus, MAX_WIND_DOWN_DELAY}; +pub use upgrade::{MAX_UPGRADE_DELAY, MIN_UPGRADE_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 * 24 * 60 * 60; +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 { @@ -55,6 +107,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` is read only by the + /// distinctness check; `attester` is not read. + /// + /// # 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, @@ -62,24 +138,15 @@ 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. - 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); + 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); @@ -90,93 +157,133 @@ 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 = 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); + 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)) } + /// 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, caller: Address) { - caller.require_auth(); - if let Some(c) = cap { - if c < 0 { - panic_with_error!(e, VaultError::InvalidAmount); - } - } + 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); DepositCapUpdated { @@ -186,192 +293,730 @@ impl AsyncVault { .publish(e); } + /// 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, caller: Address) { - caller.require_auth(); + 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); } + /// 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, 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); } + /// 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, caller: Address) { - caller.require_auth(); + 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, caller: Address) { - caller.require_auth(); + 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 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. 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. + /// * The asset's own error when `holder` cannot receive it. + /// + /// # 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) } + /// 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, caller: Address) { - caller.require_auth(); - if secs > MAX_NOTICE_SECS { - panic_with_error!(e, VaultError::NoticeTooLong); - } - if secs > upgrade::delay(e) { - panic_with_error!(e, VaultError::NoticeAboveUpgradeDelay); - } - state::set_notice(e, secs); - NoticeSet { secs }.publish(e); + 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); } + /// Ends the window in which admin changes are instant. Needs every wiring + /// 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 propose_upgrade(e: &Env, wasm_hash: BytesN<32>, caller: Address) { - caller.require_auth(); - upgrade::propose_wasm(e, wasm_hash); + 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) + } + + /// 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) } + /// 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_upgrade_delay(e: &Env, secs: u64, caller: Address) { - caller.require_auth(); - upgrade::propose_delay(e, secs); + 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_upgrade(e: &Env, caller: Address) { - caller.require_auth(); - upgrade::cancel(e); + pub fn cancel_change(e: &Env, id: u32) { + governance::cancel(e, id); } + /// 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] - #[when_not_paused] - pub fn apply_upgrade(e: &Env, caller: Address) { - caller.require_auth(); - upgrade::apply(e); + pub fn apply_change(e: &Env, id: u32) { + governance::apply(e, id); } - pub fn upgrade_delay(e: &Env) -> u64 { - upgrade::delay(e) + /// 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) } - pub fn upgrade_proposal(e: &Env) -> Option { - upgrade::proposal(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) } - pub fn fund(e: &Env, from: Address, assets: i128) -> i128 { - treasury::fund(e, &from, 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) } - pub fn get_deposit_request( - e: &Env, - epoch_id: u64, - controller: Address, - ) -> Option { - state::get_deposit_request(e, epoch_id, &controller) + /// 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, 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) } - pub fn claim_deposit(e: &Env, caller: Address, epoch_id: u64) -> i128 { - deposit::claim(e, &caller, epoch_id) + /// Mints a priced deposit's shares and returns them, in share base units. + /// Zero means none: a deposit worth less than one share base unit was + /// refunded, or a delisted holder's shares joined an exit. + /// + /// # 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` - The vault is delisted, for a + /// conversion. + /// * The asset's own error when `holder` cannot receive a refund. + /// + /// # Events + /// + /// * `DepositClaimed`, `DepositConvertedToExit` or, for a refund, + /// `DepositCancelled`. + /// * `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. - pub fn cancel_deposit(e: &Env, from: Address, epoch_id: u64) -> i128 { - deposit::cancel(e, &from, epoch_id) + /// 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` - 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 + /// + /// * `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. Refused for a controller the share token - /// will not let hold shares; that controller exits through the cash claim. - pub fn cancel_redeem(e: &Env, from: Address, epoch_id: u64) -> i128 { - redeem::cancel(e, &from, epoch_id) + /// 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` - 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. + /// * `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) } - pub fn request_redeem(e: &Env, from: Address, shares: i128) -> u64 { - redeem::request(e, &from, shares) + /// 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) } - pub fn get_redeem_request( - e: &Env, - epoch_id: u64, - controller: Address, - ) -> Option { - state::get_redeem_request(e, epoch_id, &controller) + /// 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) } - pub fn claim_redeem(e: &Env, caller: Address, epoch_id: u64) -> i128 { - redeem::claim(e, &caller, epoch_id) + /// 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. + /// * The asset's own error when `holder` cannot receive it. + /// + /// # 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) @@ -380,9 +1025,115 @@ impl AsyncVault { #[contractimpl(contracttrait)] impl AccessControl for AsyncVault { + /// 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); + } + + /// 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); + } + + /// 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); + } + /// 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`. + /// + /// # Errors + /// + /// * `AdminRequired` - Always. fn renounce_admin(e: &Env) { panic_with_error!(e, VaultError::AdminRequired); } @@ -390,17 +1141,71 @@ 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); - upgrade::on_pause(e); + strata_timelock::pause::start(e); } - #[only_admin] + /// 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(); pausable::unpause(e); - upgrade::on_unpause(e); + strata_timelock::pause::end(e); + } +} + +#[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) + } +} + +impl TimelockHost for AsyncVault { + fn clock(e: &Env) -> Result { + Ok(governance::clock(e)) } } diff --git a/contracts/async-vault/src/lock_policy.rs b/contracts/async-vault/src/lock_policy.rs new file mode 100644 index 00000000..ec51cd00 --- /dev/null +++ b/contracts/async-vault/src/lock_policy.rs @@ -0,0 +1,101 @@ +use soroban_sdk::{Address, Env}; +use stellar_tokens::{ + fungible::FungibleTokenClient, + rwa::{identity_verification::IdentityVerifierClient, RWATokenClient}, +}; + +use crate::error::VaultError; +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, + Frozen, +} + +/// 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 = 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, + } +} + +/// The vault's entrypoints that move a holder's value. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum Exit { + RequestDeposit, + ClaimDeposit, + CancelRedeem, + CancelDeposit, + RequestRedeem, + ClaimRedeem, + ClaimWindDown, +} + +#[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, 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, + + (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, + } +} + +/// 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, + } +} + +/// 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/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/redeem.rs b/contracts/async-vault/src/redeem.rs index c85e5e72..1ccb3ed5 100644 --- a/contracts/async-vault/src/redeem.rs +++ b/contracts/async-vault/src/redeem.rs @@ -1,28 +1,24 @@ -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; -use crate::event::{RedeemCancelled, RedeemClaimed, RedeemRequested}; +use crate::event::{RedeemCancelled, RedeemClaimed, RedeemRequested, RedemptionSurrendered}; +use crate::guards; use crate::keys::DataKey; +use crate::lock_policy::{self, Decision, Exit}; use crate::pricing::{Pricing, PricingScheme}; -use crate::state::{self, EpochStatus, RedeemRequest}; -use crate::treasury; +use crate::settlement; +use crate::state::{self, RedeemRequest}; use crate::wind_down; -pub(crate) fn request(e: &Env, from: &Address, shares: i128) -> u64 { - wind_down::refuse_if_active(e); - from.require_auth(); - - if shares <= 0 { - panic_with_error!(e, VaultError::InvalidAmount); - } - +pub(crate) fn request(e: &Env, holder: &Address, shares: i128) -> u64 { + guards::not_winding_down(e); + holder.require_auth(); + 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, from).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)); @@ -35,20 +31,21 @@ pub(crate) fn request(e: &Env, from: &Address, shares: i128) -> u64 { state::set_redeem_request( e, epoch_id, - from, + holder, &RedeemRequest { shares, claimed: false, }, ); 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); - ShareClient::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, } @@ -57,111 +54,157 @@ 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)); - - if epoch.status != EpochStatus::Fulfilled { - panic_with_error!(e, VaultError::EpochNotFulfilled); - } - - let mut request = state::get_redeem_request(e, epoch_id, caller) + 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); - } + 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)); 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); + // 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, holder, epoch_id, request.shares); + settlement::on_redeem_claimed(e, epoch_id, 0); 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); request.claimed = true; - state::set_redeem_request(e, epoch_id, caller, &request); - state::set_committed(e, state::committed(e).saturating_sub(assets)); + state::set_redeem_request(e, epoch_id, holder, &request); + state::set_committed(e, state::reduce(e, state::committed(e), assets)); - TokenClient::new(e, &asset).transfer(&vault, caller, &assets); - ShareClient::new(e, &state::get_addr(e, &DataKey::ShareToken)).burn( + if assets > 0 { + TokenClient::new(e, &asset).transfer(&vault, holder, &assets); + } + RWATokenClient::new(e, &state::get_addr(e, &DataKey::ShareToken)).burn( &vault, &request.shares, &vault, ); RedeemClaimed { - controller: caller.clone(), + holder: holder.clone(), epoch: epoch_id, shares: request.shares, assets, } .publish(e); + settlement::on_redeem_claimed(e, epoch_id, assets); assets } -/// 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. -fn return_shares(e: &Env, controller: &Address, epoch_id: u64, shares: i128) { +/// Queues shares the vault already holds, adding to any redemption open in the epoch. +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)); - epoch.total_shares_redeeming -= shares; + 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, holder) { + 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, holder, &request); state::set_epoch(e, epoch_id, &epoch); - state::remove_redeem_request(e, epoch_id, controller); + epoch_id +} - let vault = e.current_contract_address(); - ShareClient::new(e, &state::get_addr(e, &DataKey::ShareToken)) - .transfer(&vault, controller, &shares); +/// 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, holder); + + deliver(e, holder, shares); RedeemCancelled { - controller: controller.clone(), + holder: holder.clone(), epoch: epoch_id, shares, } .publish(e); } -pub(crate) fn cancel(e: &Env, from: &Address, epoch_id: u64) -> i128 { - from.require_auth(); +/// 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); + 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); + 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); + 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 => { + panic_with_error!(e, lock_policy::refused_with(standing)) + } } + settlement::on_redeem_cancelled(e, epoch_id); + request.shares +} - // 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); - } +/// Burns the escrow into the entitlement, so no token pause or allowlist place can hold it. +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, holder); - let request = state::get_redeem_request(e, epoch_id, from) - .unwrap_or_else(|| panic_with_error!(e, VaultError::RequestNotFound)); + let vault = e.current_contract_address(); + RWATokenClient::new(e, &state::get_addr(e, &DataKey::ShareToken)).burn(&vault, &shares, &vault); + wind_down::credit(e, holder, shares); - return_shares(e, from, epoch_id, request.shares); - request.shares + RedemptionSurrendered { + holder: holder.clone(), + epoch: epoch_id, + shares, + } + .publish(e); } diff --git a/contracts/async-vault/src/roles.rs b/contracts/async-vault/src/roles.rs index dbedcab8..3e095562 100644 --- a/contracts/async-vault/src/roles.rs +++ b/contracts/async-vault/src/roles.rs @@ -1,9 +1,9 @@ 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. +/// 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/contracts/async-vault/src/settlement.rs b/contracts/async-vault/src/settlement.rs new file mode 100644 index 00000000..9db3d948 --- /dev/null +++ b/contracts/async-vault/src/settlement.rs @@ -0,0 +1,126 @@ +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 { + strata_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) { + 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 = open_one(e, s.deposits_open); + 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 = open_one(e, s.redeems_open); + 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 = add(e, 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 = add(e, 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..f10fd294 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 { @@ -43,141 +52,123 @@ 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( - e: &Env, - epoch: u64, - controller: &Address, - request: &DepositRequest, -) { - 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 { - 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, -) { - 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 { - 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. 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) { +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) { - 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/cancel.rs b/contracts/async-vault/src/test/cancel.rs index 8cf93ca2..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); @@ -282,14 +280,59 @@ 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); } + +/// 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); +} diff --git a/contracts/async-vault/src/test/compliance.rs b/contracts/async-vault/src/test/compliance.rs index 3ed1148d..821c0577 100644 --- a/contracts/async-vault/src/test/compliance.rs +++ b/contracts/async-vault/src/test/compliance.rs @@ -1,41 +1,168 @@ +use soroban_sdk::{contract, contractimpl}; +use stellar_tokens::rwa::{compliance::ComplianceHook, RWA}; + 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); + + 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)); - assert!(f.vault.try_claim_deposit(&investor, &1).is_err()); + 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); } +/// Delisted after pricing, she cannot cancel, and a wind-down still pays her slice. +#[test] +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); + + refused( + f.vault.try_cancel_deposit(&investor, &epoch), + VaultError::AlreadyPriced, + ); + + 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); + 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); +} + +#[contract] +pub(super) 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, ()); + f.add_compliance_module(ComplianceHook::CanCreate, &module); + + 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 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); + let epoch = f.vault.request_deposit(&investor, &100); + f.vault.request_redeem(&holder, &4); + f.fulfill_epoch(wad(2)); + + 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.share + .try_propose_change(&share_token::TokenChange::Compliance(unbound.address)) + .err(), + Some(Ok(share_token::ShareTokenError::ComplianceNotBound.into())) + ); + + 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] +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); + f.e.as_contract(&f.share.address, || { + RWA::set_identity_verifier(&f.e, &silent) + }); + 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(); 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); @@ -48,7 +175,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); @@ -61,7 +188,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); @@ -75,10 +202,12 @@ 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); - // 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 @@ -88,23 +217,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 - assert!(f.vault.try_cancel_redeem(&user, &epoch).is_err()); + 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] @@ -118,48 +245,134 @@ 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()); } #[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] -fn a_frozen_holder_can_still_queue_an_exit() { +fn a_frozen_investors_zero_share_refund_is_held() { let f = setup(); - let user = f.holder(500); - let epoch = f.vault.current_epoch(); + 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] +fn a_frozen_holder_cannot_queue_an_exit() { + let f = setup(); + let user = f.holder(500); 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.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/controls.rs b/contracts/async-vault/src/test/controls.rs index 12104a76..34adbc99 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,10 +119,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(), + )); + 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.vault.apply_change(&revoke); assert_eq!(f.vault.guardian(), Some(next.clone())); assert!(f.vault.try_pause(&f.guardian).is_err()); @@ -147,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); @@ -178,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), @@ -235,7 +239,9 @@ 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.advance(MIN_UPGRADE_DELAY); + 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 new file mode 100644 index 00000000..c5a25efd --- /dev/null +++ b/contracts/async-vault/src/test/delisted.rs @@ -0,0 +1,332 @@ +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); + (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 { + holder: 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.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.advance(MIN_WIND_DOWN_DELAY); + 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 { + holder: 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); + 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.advance(MIN_WIND_DOWN_DELAY); + 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.advance(MIN_WIND_DOWN_DELAY); + 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.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); + + assert_eq!( + f.e.events().all().filter_by_contract(&f.vault.address), + [RedemptionSurrendered { + holder: 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.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); + + 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.vault.propose_wind_down(); + f.advance(MIN_WIND_DOWN_DELAY); + 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.identity.allow(&f.vault.address, &false); + + 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); + 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); + } + 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/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/deposit_cap.rs b/contracts/async-vault/src/test/deposit_cap.rs index a0332b54..7f836314 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); @@ -122,7 +115,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); @@ -133,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); @@ -143,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); @@ -178,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( @@ -186,3 +178,81 @@ 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, + ); +} + +#[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/contracts/async-vault/src/test/events.rs b/contracts/async-vault/src/test/events.rs index 38caa3bf..8d2988a7 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() { @@ -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, } @@ -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 { + holder: 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/governance.rs b/contracts/async-vault/src/test/governance.rs new file mode 100644 index 00000000..9b23d0dd --- /dev/null +++ b/contracts/async-vault/src/test/governance.rs @@ -0,0 +1,375 @@ +use soroban_sdk::{testutils::Events as _, 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.advance(f.vault.upgrade_delay()); + f.vault.apply_change(&id); +} + +#[test] +fn the_custodian_changes_through_the_timelock() { + let f = setup(); + let next = Address::generate(&f.e); + + refused(f.vault.try_set_custodian(&next), VaultError::UseTimelock); + + let id = f + .vault + .propose_change(&VaultChange::Custodian(next.clone())); + f.advance(MIN_UPGRADE_DELAY - 1); + refused(f.vault.try_apply_change(&id), VaultError::ChangeNotReady); + f.advance(1); + f.vault.apply_change(&id); + + 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.advance(MIN_UPGRADE_DELAY); + + f.vault.apply_change(&id); + + 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 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(); + + f.vault.set_notice(&DAY_DURATION); + assert_eq!(f.vault.notice(), DAY_DURATION); + + refused(f.vault.try_set_notice(&3_600), 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)), + 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)); + let delay = f + .vault + .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); + + refused( + f.vault.try_apply_change(¬ice), + 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)); + refused( + f.vault.try_set_wind_down_delay(&MIN_WIND_DOWN_DELAY), + 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)), + 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.advance(MIN_UPGRADE_DELAY); + + // 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())); + 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)); + let delay = f + .vault + .propose_change(&VaultChange::UpgradeDelay(30 * DAY_DURATION)); + f.advance(MIN_UPGRADE_DELAY); + 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.advance(30 * DAY_DURATION); + + refused( + f.vault.try_apply_change(&custodian), + 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)); + assert_eq!(f.vault.deposit_cap(), Some(1_000)); + f.vault.set_deposit_cap(&Some(500)); + assert_eq!(f.vault.deposit_cap(), Some(500)); + f.vault.set_deposit_cap(&None); + 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.vault.pause(&f.guardian); + f.advance(MIN_UPGRADE_DELAY); + + assert!(f.vault.try_apply_change(&id).is_err()); + + f.vault.unpause(&f.admin); + f.advance(MIN_UPGRADE_DELAY); + f.vault.apply_change(&id); + 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.vault.propose_wind_down(); + f.advance(MIN_WIND_DOWN_DELAY); + f.vault.activate_wind_down(); + + refused( + f.vault.try_apply_change(&revoke), + VaultError::WindDownActive, + ); + refused( + f.vault.try_propose_change(&VaultChange::RevokeRole( + symbol_short!("manager"), + f.manager.clone(), + )), + 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/guards.rs b/contracts/async-vault/src/test/guards.rs new file mode 100644 index 00000000..b484fd16 --- /dev/null +++ b/contracts/async-vault/src/test/guards.rs @@ -0,0 +1,741 @@ +//! 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::wind_down::wind_down_now; +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() +} + +#[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(); + 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(); + 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 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(); + 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 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_and_answering_refuses_an_unbound_feed_or_a_contract_that_is_not_a_feed() { + let f = setup(); + holds(&f, || { + guards::feed_bound_here_and_answering(&f.e, &f.oracle.address) + }); + + let unbound = f.e.register( + NavOracleContract, + ( + f.admin.clone(), + f.attester.clone(), + f.guardian.clone(), + open_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_and_answering(&f.e, ¬_a_feed_here) + }); + } +} + +#[test] +fn valid_change_checks_each_kind_of_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::valid_change(&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, + ), + ( + VaultChange::Oracle(f.share.compliance()), + VaultError::FeedNotBound, + ), + ( + VaultChange::Custodian(f.vault.address.clone()), + VaultError::InvalidCustodian, + ), + ] { + refuses(&f, error, || guards::valid_change(&f.e, &change)); + } +} + +#[test] +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::not_a_second_code_change(&f.e, &upgrade)); + + f.vault + .propose_change(&VaultChange::UpgradeDelay(MIN_UPGRADE_DELAY)); + refuses(&f, VaultError::UpgradeProposalExists, || { + guards::not_a_second_code_change(&f.e, &upgrade) + }); + refuses(&f, VaultError::UpgradeProposalExists, || { + guards::not_a_second_code_change(&f.e, &VaultChange::UpgradeDelay(MAX_UPGRADE_DELAY)) + }); + holds(&f, || { + guards::not_a_second_code_change(&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(); + 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(); + 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)); + 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); + 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/health.rs b/contracts/async-vault/src/test/health.rs new file mode 100644 index 00000000..4ee84791 --- /dev/null +++ b/contracts/async-vault/src/test/health.rs @@ -0,0 +1,182 @@ +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, + 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, + } +} + +#[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::VaultFrozen, + Health { + vault_unfrozen: false, + ..all_healthy() + }, + ), + ( + Miswire::ComplianceUnbound, + Health { + compliance_bound: false, + ..all_healthy() + }, + ), + ( + Miswire::ComplianceBoundElsewhere, + Health { + compliance_bound: false, + ..all_healthy() + }, + ), + ( + Miswire::ComplianceSetupOpen, + Health { + compliance_bound: false, + ..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); + f.attest(wad(1)); + + assert_eq!( + f.vault.health(), + Health { + setup_open: true, + ..expected + }, + "{miswire:?}" + ); + } +} + +#[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)); + + assert!(!f.vault.health().freshness_covers_notice); +} 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..09a8ccfd --- /dev/null +++ b/contracts/async-vault/src/test/lock_policy.rs @@ -0,0 +1,323 @@ +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!( + 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!( + 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_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_holders_redemption_request_is_held() { + 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!( + 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(); + 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); + + assert_eq!( + f.e.as_contract(&f.vault.address, || lock_policy::standing(&f.e, &holder)), + 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.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 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/mod.rs b/contracts/async-vault/src/test/mod.rs index a22c194a..10940c0a 100644 --- a/contracts/async-vault/src/test/mod.rs +++ b/contracts/async-vault/src/test/mod.rs @@ -3,15 +3,25 @@ mod compliance; mod constructor; mod controls; mod conversions; +mod delisted; mod deposit; mod deposit_cap; mod epochs; mod events; +mod governance; +mod guards; +mod health; +mod lock_policy; mod multi_epoch; mod notice; mod oracle_pricing; +mod oracle_swap; mod pricing; mod redeem; +mod settlement; +mod setup; +mod share_returns; +mod signatures; mod supply; mod timing; mod treasury; @@ -21,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 _}, @@ -28,23 +39,34 @@ 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 nav_oracle::{NavInput, NavOracleContract, NavOracleContractClient, 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 strata_timelock::DAY_DURATION; pub(crate) use crate::{ - AsyncVault, AsyncVaultClient, DirectUnitPricing, EpochStatus, PricingScheme, UpgradeAction, - VaultError, VaultRoles, WindDownStatus, MAX_NOTICE_SECS, MAX_UPGRADE_DELAY, - MAX_WIND_DOWN_DELAY, MIN_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, MIN_WIND_DOWN_DELAY, }; 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>, @@ -72,9 +94,48 @@ 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: &[], + }, + }]); + } + + /// 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, &self.compliance); + self.identity.allow(&who, &true); who } @@ -92,15 +153,23 @@ 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.oracle.attest( - &NavReport { - nav_per_share, - timestamp: 0, - expires_at: 1_000_000, - }, - &self.attester, - ); + self.wait_out_the_cooldown(); + 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.accepted_at + self.oracle.config().cooldown_secs; + let now = self.e.ledger().timestamp(); + if now < ready { + self.advance(ready - now); + } } fn holder(&self, shares: i128) -> Address { @@ -127,12 +196,19 @@ 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); } } + + /// 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.advance(MIN_UPGRADE_DELAY); + rules.apply_change(&id); + } } pub(crate) fn distinct_roles(e: &Env) -> VaultRoles { @@ -162,13 +238,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, } @@ -178,9 +254,87 @@ 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, + OtherManager, + SecondManager, + PreMinted, + NotAllowlisted, + VaultFrozen, + 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`. +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); + +/// 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, + miswire: Miswire, + finish: bool, +) -> Fixture<'a> { let e = Env::default(); e.mock_all_auths(); e.ledger().set_timestamp(10_000); @@ -203,8 +357,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"), @@ -215,7 +369,9 @@ fn setup_with_feed<'a>(feed: OracleConfig) -> Fixture<'a> { ), ), ); - compliance.bind_token(&share.address, &admin); + if miswire != Miswire::ComplianceUnbound { + compliance.bind_token(&share.address, &admin); + } let oracle = NavOracleContractClient::new( &e, @@ -225,12 +381,21 @@ fn setup_with_feed<'a>(feed: OracleConfig) -> Fixture<'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(), @@ -241,14 +406,83 @@ fn setup_with_feed<'a>(feed: OracleConfig) -> Fixture<'a> { }, ), ); - share.grant_role(&contract_id, &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); + 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); + 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); + } + 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 => { + share.bind_vault(®ister_with(&e, distinct_roles(&e))); + share.finish_setup(); + } + Miswire::TokenSetupOpen => share.bind_vault(&contract_id), + _ => { + share.bind_vault(&contract_id); + share.finish_setup(); + } + } + match miswire { + Miswire::OracleUnbound => {} + Miswire::OracleBoundElsewhere => { + oracle.bind_vault(®ister_with(&e, distinct_roles(&e))); + oracle.finish_setup(); + } + Miswire::OracleSetupOpen => oracle.bind_vault(&contract_id), + _ => { + oracle.bind_vault(&contract_id); + oracle.finish_setup(); + } + } + match miswire { + Miswire::ComplianceUnbound => {} + Miswire::ComplianceBoundElsewhere => { + compliance.bind_vault(®ister_with(&e, distinct_roles(&e))); + compliance.finish_setup(); + } + Miswire::ComplianceSetupOpen => compliance.bind_vault(&contract_id), + _ => { + compliance.bind_vault(&contract_id); + compliance.finish_setup(); + } + } + 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.finish_setup(); + } 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 3bbe2c0c..c8996cba 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,15 +72,17 @@ 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 +/// 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] -fn one_controller_redeems_across_two_epochs() { +fn one_holder_redeems_across_two_epochs() { let f = setup(); let a = f.holder(500); @@ -264,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/notice.rs b/contracts/async-vault/src/test/notice.rs index 3541e753..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(&86_400, &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); @@ -171,11 +171,13 @@ 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.advance(MIN_UPGRADE_DELAY); - f.vault.apply_upgrade(&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_pricing.rs b/contracts/async-vault/src/test/oracle_pricing.rs index 2e4a9aae..427e6b50 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); refused(f.vault.try_fulfill_epoch(&1), VaultError::FeedNotValid); - f.oracle.set_ripcord(&false, &f.admin); + f.oracle.set_nav_paused(&false); 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); } @@ -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/async-vault/src/test/oracle_swap.rs b/contracts/async-vault/src/test/oracle_swap.rs new file mode 100644 index 00000000..19fcd254 --- /dev/null +++ b/contracts/async-vault/src/test/oracle_swap.rs @@ -0,0 +1,178 @@ +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) -> strata_bindings::OracleState { + strata_bindings::OracleState::Valid + } + + pub fn freshness_duration(_e: Env) -> u64 { + 3_600 + } +} + +#[contractimpl(contracttrait)] +impl strata_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); + feed.finish_setup(); + feed +} + +fn attest_on(f: &Fixture, feed: &NavOracleContractClient, nav_per_share: i128) { + feed.attest(&nav_input(&f.e, nav_per_share, 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.advance(MIN_UPGRADE_DELAY - 1); + 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); + 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.advance(MIN_UPGRADE_DELAY); + f.vault.apply_change(&id); + 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.advance(MIN_UPGRADE_DELAY); + f.vault.apply_change(&id); + 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); + let elsewhere = unbound_feed(&f); + elsewhere.bind_vault(®ister_with(&f.e, distinct_roles(&f.e))); + elsewhere.finish_setup(); + + for feed in [ + Address::generate(&f.e), + unbound_feed(&f).address, + open.address, + elsewhere.address, + ] { + refused( + f.vault.try_propose_change(&VaultChange::Oracle(feed)), + 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())); + + stub.report(&Some(Address::generate(&f.e))); + f.advance(MIN_UPGRADE_DELAY); + + 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/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/settlement.rs b/contracts/async-vault/src/test/settlement.rs new file mode 100644 index 00000000..ad3d15e9 --- /dev/null +++ b/contracts/async-vault/src/test/settlement.rs @@ -0,0 +1,290 @@ +use proptest::prelude::*; +use soroban_sdk::{testutils::Events as _, Event as _}; +use stellar_tokens::rwa::compliance::ComplianceHook; + +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 { + holder: 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)); +} + +#[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. +#[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); + + refused( + f.vault.try_claim_redeem(&a, &epoch), + VaultError::HolderNotEligible, + ); + 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); +} + +/// The other case that can never settle: a compliance module refuses a +/// 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(); + 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, ()); + f.add_compliance_module(ComplianceHook::CanCreate, &module); + + 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); +} + +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(prop_oneof![Just(1i128), 1i128..5_000], 1..5), + cancels in proptest::collection::vec(any::(), 5), + 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), + ) { + 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/test/setup.rs b/contracts/async-vault/src/test/setup.rs new file mode 100644 index 00000000..83b0133a --- /dev/null +++ b/contracts/async-vault/src/test/setup.rs @@ -0,0 +1,269 @@ +use soroban_sdk::{contract, contractimpl, testutils::Events as _, vec, Event as _, Vec}; +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(); + + 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(); + + 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(), 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::OtherManager, + Miswire::SecondManager, + Miswire::PreMinted, + Miswire::NotAllowlisted, + Miswire::VaultFrozen, + Miswire::ComplianceUnbound, + Miswire::ComplianceBoundElsewhere, + Miswire::ComplianceSetupOpen, + Miswire::AssetDecimalsDiffer, + Miswire::AssetIsShareToken, + Miswire::OracleNotAFeed, + Miswire::NoCustodian, + Miswire::CustodianIsVault, + ] { + let f = miswired(miswire); + + assert_eq!( + f.vault.try_finish_setup(), + Err(Ok(VaultError::SetupIncomplete.into())), + "{miswire:?}" + ); + assert!(f.vault.setup_open(), "{miswire:?}"); + } +} + +/// A compliance contract from outside the kit, with no binding view. +#[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. +#[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); + oracle.finish_setup(); + + 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.vault_unfrozen); + assert!(!health.compliance_bound); + assert!(!health.decimals_match); + + refused(vault.try_finish_setup(), VaultError::SetupIncomplete); +} + +#[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(), VaultError::AdminTransferPending); + + f.vault.transfer_admin_role(&next, &0); + f.vault.finish_setup(); + 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(); + + 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); + + for who in [&stranger, &f.manager] { + f.sign_only_as(who, "finish_setup", Vec::new(&f.e)); + assert!(f.vault.try_finish_setup().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.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 + .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())), + VaultError::SetupOpen, + ); + refused( + 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/share_returns.rs b/contracts/async-vault/src/test/share_returns.rs new file mode 100644 index 00000000..bba8dbc5 --- /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] +pub(super) 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); + assert!(!f.vault.health().allowlisted); + + assert_eq!(f.vault.cancel_redeem(&holder, &epoch), 200); + assert_eq!(f.shares(&holder), 500); +} + +#[test] +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); + + 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_holder_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_holder_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); + + 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/contracts/async-vault/src/test/signatures.rs b/contracts/async-vault/src/test/signatures.rs new file mode 100644 index 00000000..48e6c8e8 --- /dev/null +++ b/contracts/async-vault/src/test/signatures.rs @@ -0,0 +1,118 @@ +//! 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)); + + 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/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 a6574ea5..d51883a3 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] @@ -5,7 +7,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 +32,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 +48,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 +63,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 +93,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,34 +101,74 @@ 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 = miswired(Miswire::NoCustodian); + 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 = miswired(Miswire::NoCustodian); 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); } +#[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(); 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()); } + +/// 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/ttl.rs b/contracts/async-vault/src/test/ttl.rs index 3e508440..e39064c2 100644 --- a/contracts/async-vault/src/test/ttl.rs +++ b/contracts/async-vault/src/test/ttl.rs @@ -1,15 +1,76 @@ use soroban_sdk::testutils::storage::{Instance as _, Persistent as _}; -use storage::{INSTANCE_EXTEND_AMOUNT, PERSISTENT_EXTEND_AMOUNT}; +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 * 24 * 60 * 60; +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)) +} + +/// Mirrors OpenZeppelin's private member-list key: same variant and fields, same encoding. +#[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, + &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()) } @@ -93,16 +154,89 @@ 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.oracle.attest( - &NavReport { - nav_per_share: wad(2), - timestamp: 0, - expires_at: u64::MAX, - }, - &f.attester, - ); + f.advance(29 * DAY_DURATION); + 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); } + +#[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 + ); +} + +#[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/async-vault/src/test/upgrade.rs b/contracts/async-vault/src/test/upgrade.rs index 5d159cf2..5f3afeda 100644 --- a/contracts/async-vault/src/test/upgrade.rs +++ b/contracts/async-vault/src/test/upgrade.rs @@ -1,6 +1,29 @@ +use soroban_sdk::{testutils::Events as _, Event as _, IntoVal}; +use stellar_contract_utils::pausable::PausableError; + use super::*; +use crate::{ChangeApplied, ChangeCancelled, ChangeProposed}; + +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) +} + +/// 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); +} -const DAY: u64 = 24 * 60 * 60; +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 +35,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))), VaultError::UpgradeProposalExists, ); refused( - f.vault.try_propose_upgrade_delay(&(30 * DAY), &f.admin), + f.vault + .try_propose_change(&VaultChange::UpgradeDelay(30 * DAY_DURATION)), VaultError::UpgradeProposalExists, ); } @@ -41,24 +65,21 @@ 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); + 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] fn cancelling_nothing_is_refused() { let f = setup(); - refused( - f.vault.try_cancel_upgrade(&f.admin), - VaultError::UpgradeProposalNotFound, - ); + refused(f.vault.try_cancel_change(&1), VaultError::ChangeNotFound); } #[test] @@ -67,10 +88,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)), VaultError::UpgradeDelayTooShort, ); - assert_eq!(f.vault.upgrade_proposal(), None); + assert_eq!(f.vault.pending_changes().len(), 0); } #[test] @@ -79,51 +100,54 @@ 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)), 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)); // 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)), 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_upgrade_delay(&(30 * DAY), &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(); - f.vault.propose_upgrade(&hash, &f.admin); - f.e.set_auths(&[]); - assert!(f.vault.try_cancel_upgrade(&stranger).is_err()); - assert!(f.vault.try_cancel_upgrade(&f.guardian).is_err()); + let id = propose(&f, change); + 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] @@ -136,69 +160,54 @@ 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, - ); + refused(f.vault.try_apply_change(&id), VaultError::ChangeNotReady); f.advance(MIN_UPGRADE_DELAY - 1); - refused( - f.vault.try_apply_upgrade(&f.admin), - VaultError::UpgradeDelayNotElapsed, - ); + refused(f.vault.try_apply_change(&id), 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); + 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); - refused( - f.vault.try_apply_upgrade(&f.admin), - VaultError::UpgradeDelayNotElapsed, - ); + let id = propose(&f, VaultChange::UpgradeDelay(MIN_UPGRADE_DELAY)); + f.advance(30 * DAY_DURATION - 1); + refused(f.vault.try_apply_change(&id), VaultError::ChangeNotReady); f.advance(1); - f.vault.apply_upgrade(&f.admin); + f.vault.apply_change(&id); 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)), VaultError::NoticeAboveUpgradeDelay, ); assert_eq!(f.vault.notice(), 0); @@ -208,82 +217,305 @@ fn the_notice_cannot_outgrow_the_delay() { fn applying_nothing_is_refused() { let f = setup(); - refused( - f.vault.try_apply_upgrade(&f.admin), - VaultError::UpgradeProposalNotFound, - ); + refused(f.vault.try_apply_change(&1), 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).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); - refused( - f.vault.try_apply_upgrade(&f.admin), - VaultError::UpgradeDelayNotElapsed, + // Six unpaused days out of seven: still refused. + f.advance(3 * DAY_DURATION); + refused(f.vault.try_apply_change(&id), VaultError::ChangeNotReady); + + f.advance(DAY_DURATION); + f.vault.apply_change(&id); + assert_eq!(f.vault.pending_changes().len(), 0); + 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), + Err(Ok(PausableError::EnforcedPause.into())) ); + assert_eq!(f.vault.upgrade_delay(), MIN_UPGRADE_DELAY); - // 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.vault.unpause(&f.admin); + f.vault.apply_change(&id); + assert_eq!(f.vault.upgrade_delay(), 30 * 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()); + 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); +} + +/// 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 = BytesN::from_array(&f.e, &[7u8; 32]); - - // 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] +fn nothing_is_proposed_during_a_wind_down() { + let f = setup(); + 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))), + 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.advance(MIN_WIND_DOWN_DELAY); + f.vault.activate_wind_down(); + f.advance(MAX_UPGRADE_DELAY); + + refused(f.vault.try_apply_change(&id), VaultError::WindDownActive); + f.vault.cancel_change(&id); + 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.advance(30 * DAY_DURATION); + + refused( + f.vault.try_apply_change(&id), + 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); + 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); + + 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 = strata_timelock::ClockSourceClient::new(&f.e, &f.vault.address).timelock_clock(); + + assert_eq!( + clock, + strata_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 share_token::TokenChange; + use soroban_sdk::xdr::{ContractEventBody, ScError, ScErrorCode, ScErrorType, ScSymbol, ScVal}; + 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" + ); + } + + /// 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)); + 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); + + let probe_hash = f.e.deployer().upload_contract_wasm(token_probe::WASM); + let id = f.share.propose_change(&TokenChange::Upgrade(probe_hash)); + f.advance(MIN_UPGRADE_DELAY); + 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(); + assert_eq!(probe.schema_version(), 2); + assert_eq!( + probe.try_migrate(), + Err(Ok(token_probe::ProbeError::AlreadyMigrated.into())) + ); + + 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(); @@ -298,15 +530,14 @@ 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.advance(MIN_UPGRADE_DELAY); - f.vault.apply_upgrade(&f.admin); + f.vault.apply_change(&id); let target = Client::new(&f.e, &f.vault.address); assert_eq!(target.schema_version(), 1); @@ -319,9 +550,9 @@ 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.advance(MIN_UPGRADE_DELAY); - f.vault.apply_upgrade(&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 e141597d..b9428aec 100644 --- a/contracts/async-vault/src/test/wind_down.rs +++ b/contracts/async-vault/src/test/wind_down.rs @@ -1,13 +1,16 @@ +use proptest::prelude::*; +use soroban_sdk::{contract, contracterror, contractimpl, panic_with_error, IntoVal}; + use super::*; #[test] 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)); + assert_eq!(f.vault.wind_down_delay(), 14 * DAY_DURATION); } #[test] @@ -16,22 +19,22 @@ 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(), 0); + assert_eq!(f.vault.wind_down_delay(), MIN_WIND_DOWN_DELAY); } #[test] fn only_governance_sets_the_delay() { let f = setup(); let stranger = Address::generate(&f.e); + let longer = 2 * MIN_WIND_DOWN_DELAY; - 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()); - assert_eq!(f.vault.wind_down_delay(), 0); + for who in [&stranger, &f.manager, &f.treasury, &f.guardian] { + 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); } #[test] @@ -41,15 +44,15 @@ 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() { 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); @@ -66,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(&[]); @@ -81,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); } @@ -104,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 @@ -127,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); } @@ -144,18 +151,35 @@ 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.vault.propose_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() { + for f in [setup_unfinished(), setup()] { + refused( + f.vault.try_set_wind_down_delay(&(MIN_WIND_DOWN_DELAY - 1)), + 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); +pub(super) fn wind_down_now(f: &Fixture) { + f.vault.propose_wind_down(); + f.advance(MIN_WIND_DOWN_DELAY); f.vault.activate_wind_down(); } @@ -186,7 +210,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)); @@ -198,7 +221,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); @@ -247,7 +269,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); @@ -256,7 +278,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] @@ -308,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()); } @@ -384,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(); @@ -437,20 +507,71 @@ 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); +} + +/// 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] @@ -516,7 +637,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(); @@ -611,10 +732,202 @@ 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()); } + +/// 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.advance(MIN_WIND_DOWN_DELAY); + 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.advance(MIN_WIND_DOWN_DELAY); + 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()); +} + +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.advance(MIN_WIND_DOWN_DELAY); + 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); + } +} + +#[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 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(); + 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); +} + +#[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, + ); +} + +#[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/treasury.rs b/contracts/async-vault/src/treasury.rs index 4958a1fe..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; @@ -39,21 +40,19 @@ 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 { - 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) @@ -73,12 +72,9 @@ 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(); - - if assets <= 0 { - panic_with_error!(e, VaultError::InvalidAmount); - } +pub(crate) fn fund(e: &Env, caller: &Address, assets: i128) -> i128 { + caller.require_auth(); + guards::positive_amount(e, assets); let net_deployed = state::net_deployed(e) .checked_sub(assets) @@ -86,10 +82,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/async-vault/src/upgrade.rs b/contracts/async-vault/src/upgrade.rs index aa4ae603..e045be5e 100644 --- a/contracts/async-vault/src/upgrade.rs +++ b/contracts/async-vault/src/upgrade.rs @@ -1,140 +1,18 @@ -use soroban_sdk::{contracttype, panic_with_error, BytesN, Env}; +use soroban_sdk::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 = 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 = 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 = strata_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) -} - -/// An exit takes a full notice, so a delay shorter than one is not a window. -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); - } -} - -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()); - } + strata_storage::get_instance(e, &DataKey::UpgradeDelay).unwrap_or(MIN_UPGRADE_DELAY) } -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); - } - } +pub(crate) fn set_delay(e: &Env, secs: u64) { + strata_storage::set_instance(e, &DataKey::UpgradeDelay, &secs); } diff --git a/contracts/async-vault/src/wind_down.rs b/contracts/async-vault/src/wind_down.rs index 7a22e250..71edb7aa 100644 --- a/contracts/async-vault/src/wind_down.rs +++ b/contracts/async-vault/src/wind_down.rs @@ -1,19 +1,29 @@ -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::{ WindDownActivated, WindDownClaimed, WindDownDelaySet, WindDownProposalCancelled, WindDownProposed, WindDownRoundFinalized, }; +use crate::guards; use crate::keys::DataKey; +use crate::lock_policy::{self, Exit, Standing}; use crate::state; 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 * 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 * 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; #[contracttype] #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -41,19 +51,16 @@ pub struct WindDownPosition { } pub(crate) fn delay(e: &Env) -> u64 { - storage::get_instance(e, &DataKey::WindDownDelay).unwrap_or(0) + strata_storage::get_instance(e, &DataKey::WindDownDelay).unwrap_or(MIN_WIND_DOWN_DELAY) } pub(crate) fn set_delay(e: &Env, secs: u64) { - if secs > MAX_WIND_DOWN_DELAY { - panic_with_error!(e, VaultError::WindDownDelayTooLong); - } - 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 { @@ -70,28 +77,12 @@ 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)); - storage::set_instance( + strata_storage::set_instance( e, &DataKey::WindDown, &WindDownInfo { @@ -105,85 +96,86 @@ 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); } pub(crate) fn activate(e: &Env) { + guards::not_winding_down(e); 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::wind_down_delay_elapsed(e, &wd); 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 { + strata_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); - ShareClient::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)) } +/// 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)); + 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)); + strata_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 { - panic_with_error!(e, VaultError::WindDownNotActive); - } + guards::winding_down(e); 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) }; - - 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 @@ -197,16 +189,16 @@ 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) .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, @@ -220,7 +212,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, @@ -228,15 +220,17 @@ 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 share_token = state::get_addr(e, &DataKey::ShareToken); - let held = ShareClient::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); @@ -247,17 +241,15 @@ 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); - } + 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); - let share = ShareClient::new(e, &share_token); - let surrendered = share.balance(holder); + // A partial freeze keeps its shares out of the surrender; they earn every + // 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 .entitlement @@ -266,21 +258,22 @@ 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); - } + let assets = state::reduce(e, earned, p.paid); + guards::has_entitlement(e, surrendered, assets); 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 { - share.burn(holder, &surrendered, &vault); + RWATokenClient::new(e, &share_token).burn(holder, &surrendered, &vault); } if assets > 0 { - storage::set_instance(e, &DataKey::WindDownOwed, &(owed(e) - assets)); + strata_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); } diff --git a/contracts/compliance/Cargo.toml b/contracts/compliance/Cargo.toml index 203d3b86..aa79d1b2 100644 --- a/contracts/compliance/Cargo.toml +++ b/contracts/compliance/Cargo.toml @@ -14,3 +14,9 @@ soroban-sdk = { workspace = true } stellar-access = { workspace = true } stellar-macros = { workspace = true } stellar-tokens = { workspace = true } +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/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 new file mode 100644 index 00000000..85e48f70 --- /dev/null +++ b/contracts/compliance/src/governance.rs @@ -0,0 +1,153 @@ +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 strata_timelock::{queue, setup, Clock, Refusal, TimelockHost}; + +use crate::extend; +use crate::guards; +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 { + strata_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)) +} + +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); +} + +pub(crate) fn finish_setup(e: &Env) { + 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: 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()); + 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); + extend::bound_tokens(e); + } + 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/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 5946ddf3..36fe8d6c 100644 --- a/contracts/compliance/src/lib.rs +++ b/contracts/compliance/src/lib.rs @@ -1,8 +1,33 @@ +//! # 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 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 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. + #![no_std] -use soroban_sdk::{contract, contractimpl, contractmeta, Address, Env, Vec}; +mod extend; +mod governance; +mod guards; + +use soroban_sdk::{ + contract, contracterror, contractevent, contractimpl, contractmeta, Address, Env, Vec, +}; contractmeta!( key = "stub", @@ -11,39 +36,527 @@ contractmeta!( 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}; + +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] +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; #[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 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); + governance::bind_vault(e, &vault); + } + + /// 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); + governance::finish_setup(e); + } + + /// 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. + /// + /// # 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) + } + + /// 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) + } +} + +#[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) } } #[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); + 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 + /// 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); + strata_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 { + /// 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) { - compliance::storage::add_module_to(e, hook, module); + guards::direct_change_allowed(e); + strata_storage::bump_instance(e); + compliance::storage::add_module_to(e, hook.clone(), module); + extend::modules(e, &hook); } + /// 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); + strata_storage::bump_instance(e); 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) + } } + +#[cfg(test)] +mod test; diff --git a/contracts/compliance/src/test.rs b/contracts/compliance/src/test.rs new file mode 100644 index 00000000..12ce17b8 --- /dev/null +++ b/contracts/compliance/src/test.rs @@ -0,0 +1,77 @@ +mod changes; +mod guards; +mod ttl; + +use soroban_sdk::{ + testutils::{storage::Instance as _, Address as _, Ledger as _}, + Address, Env, +}; +use strata_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); +} diff --git a/contracts/compliance/src/test/changes.rs b/contracts/compliance/src/test/changes.rs new file mode 100644 index 00000000..1a5ba221 --- /dev/null +++ b/contracts/compliance/src/test/changes.rs @@ -0,0 +1,764 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, symbol_short, + 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; +use strata_storage::INSTANCE_EXTEND_AMOUNT; +use strata_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 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); + 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) + } + + 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); + } + + /// 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: &[], + }, + }]); + } + + /// 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()) { + 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 +} + +fn finished<'a>() -> Fixture<'a> { + let f = bound(); + f.rules.finish_setup(); + 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(), + 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)), + ComplianceContractError::SetupClosed, + ); + + f.rules.finish_setup(); + + refused( + f.rules.try_bind_vault(&Address::generate(&f.e)), + ComplianceContractError::SetupClosed, + ); + refused( + f.rules.try_finish_setup(), + 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)).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 = strata_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.rules.finish_setup(); + + 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.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(); + 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()); +} + +#[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))), + 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); + + assert_eq!( + f.rules.pending_changes(), + vec![ + &f.e, + CompliancePendingChange { + id, + change, + eta: 10_000 + MIN_DELAY, + } + ] + ); + refused( + f.rules.try_apply_change(&id), + ComplianceContractError::ChangeNotReady, + ); + + f.e.ledger().set_timestamp(10_000 + MIN_DELAY); + f.rules.apply_change(&id); + + 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.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.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), + ComplianceContractError::VaultPaused, + ); + + let mut winding = running(); + winding.winding_down = true; + f.clock.set(&winding); + refused( + f.rules.try_apply_change(&id), + ComplianceContractError::WindDownActive, + ); + refused( + f.rules + .try_propose_change(&ComplianceChange::BindToken(Address::generate(&f.e))), + 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), + ComplianceContractError::ChangeNotReady, + ); + + f.e.ledger().set_timestamp(eta); + f.rules.apply_change(&id); +} + +#[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), + 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.rules.cancel_change(&id); + + refused( + f.rules.try_apply_change(&id), + 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.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); +} + +#[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); + 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); + 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(), + ComplianceContractError::AdminTransferPending, + ); + + f.rules.accept_admin_transfer(); + assert_eq!(f.rules.get_admin(), Some(next.clone())); + f.rules.finish_setup(); + f.signed_once_by(&next, "finish_setup", Vec::new(&f.e)); + 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(); + 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(); + + 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.e.ledger().set_timestamp(10_000 + MIN_DELAY); + f.rules.apply_change(&id); + 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.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)); + + 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.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 new file mode 100644 index 00000000..94594eb3 --- /dev/null +++ b/contracts/compliance/src/test/guards.rs @@ -0,0 +1,110 @@ +//! 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, +} + +impl Fixture<'_> { + fn finish_setup(&self) { + self.rules.bind_vault(&self.vault); + self.rules.finish_setup(); + } +} + +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,))); + let vault = e.register(VaultStub, ()); + Fixture { e, rules, vault } +} + +/// 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) + }); +} 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 + ); +} 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 5c3ba014..f535b542 100644 --- a/contracts/identity-verifier/src/lib.rs +++ b/contracts/identity-verifier/src/lib.rs @@ -1,5 +1,24 @@ +//! # 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.** 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: the code says what failed, not which contract refused. + #![no_std] use soroban_sdk::{ @@ -24,36 +43,88 @@ 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, caller: Address) { - caller.require_auth(); - storage::set_persistent(e, &DataKey::Allowed(account), &allowed); + 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 { - 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) } } #[contractimpl(contracttrait)] impl identity_verification::IdentityVerifier for IdentityVerifier { + /// 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 = - 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); } } + /// 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, diff --git a/contracts/identity-verifier/src/test.rs b/contracts/identity-verifier/src/test.rs index de865bfe..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::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 storage::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; +/// 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)); @@ -103,3 +168,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); + 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); +} diff --git a/contracts/nav-oracle/Cargo.toml b/contracts/nav-oracle/Cargo.toml index 6a092b14..3835900c 100644 --- a/contracts/nav-oracle/Cargo.toml +++ b/contracts/nav-oracle/Cargo.toml @@ -11,10 +11,18 @@ 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" } +strata-bindings = { path = "../../crates/strata-bindings" } +strata-timelock = { path = "../../crates/strata-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/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 new file mode 100644 index 00000000..2ceab364 --- /dev/null +++ b/contracts/nav-oracle/src/governance.rs @@ -0,0 +1,165 @@ +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 strata_timelock::{queue, setup, Clock, Refusal, TimelockHost}; + +use crate::extend; +use crate::guards; +use crate::state::OracleConfig; +use crate::{ + store_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 { + strata_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)) +} + +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); +} + +pub(crate) fn finish_setup(e: &Env) { + 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 { + guards::change_in_bounds(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)); + // 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)); + guards::change_in_bounds(e, &change); + match &change { + OracleChange::Upgrade(hash) => upgradeable::upgrade(e, hash), + OracleChange::GrantRole(role, account) => { + 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) + } + OracleChange::Admin(next) => offer_admin(e, &admin, next), + OracleChange::Config(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/guards.rs b/contracts/nav-oracle/src/guards.rs new file mode 100644 index 00000000..d4e7481e --- /dev/null +++ b/contracts/nav-oracle/src/guards.rs @@ -0,0 +1,145 @@ +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, MAX_PROOF_URI_LEN, 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); + } +} + +/// 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); + } +} + +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 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.accepted_at.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 60b34847..10b80b5a 100644 --- a/contracts/nav-oracle/src/lib.rs +++ b/contracts/nav-oracle/src/lib.rs @@ -1,41 +1,146 @@ +//! # 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.** 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. +//! - **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] +mod extend; +mod governance; +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; use stellar_macros::{only_admin, only_role}; +use strata_bindings::{OracleFeed, VaultBinding}; -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}; +pub use governance::{ + shortens_freshness_only, OracleChange, OraclePendingChange, ADMIN_ACCEPT_LEDGERS, +}; +pub use state::{NavInput, NavReport, OracleConfig, OracleState}; const BPS_DENOM: i128 = 10_000; -const MAX_ANSWER: i128 = i128::MAX / BPS_DENOM; - -#[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, - /// Per-share move exceeds `max_deviation_bps`. - 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 ripcord was down. - RipcordNotRaised = 3008, - /// The oracle must always have an admin. - AdminRequired = 3009, +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 * 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, + + /// 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, + /// 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. + AdminTransferPending = 3025, + /// The configuration the constructor writes is absent from instance + /// storage. + NotInitialized = 3026, + } } #[contractevent] @@ -44,37 +149,69 @@ 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] pub struct LatestCleared {} #[contractevent] -pub struct RipcordSet { +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, 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 validate_config(e: &Env, cfg: &OracleConfig) { - if cfg.freshness_duration == 0 - || 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) - { - panic_with_error!(e, OracleError::InvalidConfig); +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 { @@ -86,7 +223,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) { @@ -101,15 +238,129 @@ pub struct NavOracleContract; #[contractimpl(contracttrait)] impl AccessControl for NavOracleContract { + /// 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); + access_control::grant_role(e, &account, &role, &caller); + extend::role_entries(e, &account, &role); + } + + /// 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); + 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, OracleError::UseTimelock); + } + /// 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. 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); } } +#[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) + } +} + #[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, @@ -117,27 +368,174 @@ 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); + 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_ripcord(e, false); + state::set_nav_paused(e, false); + upgradeable::set_schema_version(e, 1); } - pub fn nav_per_share(e: &Env) -> i128 { - Self::latest(e).nav_per_share + /// The storage layout version, `1` until an upgrade migrates it. + pub fn schema_version(e: &Env) -> u32 { + upgradeable::get_schema_version(e) } - pub fn latest(e: &Env) -> NavReport { - get_latest(e).unwrap_or_else(|| panic_with_error!(e, OracleError::NoRecord)) + /// 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); + governance::bind_vault(e, &vault); } - pub fn state(e: &Env) -> OracleState { - compute_state(e) + /// 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); + governance::finish_setup(e); + } + + /// 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. + /// + /// # 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, `accepted_at` and + /// `expires_at` in seconds, and the proof the attester gave with it, + /// `proof_hash` and `proof_uri`. + /// + /// # 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, @@ -145,57 +543,69 @@ 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 - } - + /// 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) { - if compute_state(e) != OracleState::Valid { - panic_with_error!(e, OracleError::NotConsumable); - } + guards::consumable(e); } + /// 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 + /// + /// * `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. `proof_hash` hashes the evidence; `proof_uri` says where to + /// read it, or is empty. + /// * `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. + /// * `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. + /// + /// # Events + /// + /// * `NavAttested` + /// + /// # Authorization + /// + /// `caller` signs. #[only_role(caller, "attester")] - pub fn attest(e: &Env, report: NavReport, caller: Address) { - storage::bump_instance(e); + pub fn attest(e: &Env, input: NavInput, caller: Address) { + 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); - } - // 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) - } 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); - } - } + 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, 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: now.saturating_add(cfg.freshness_duration), + 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); @@ -203,55 +613,177 @@ 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); } + /// 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 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); } + /// 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_ripcord(e: &Env, paused: bool, caller: Address) { - caller.require_auth(); - state::set_ripcord(e, paused); - RipcordSet { paused }.publish(e); + pub fn set_nav_paused(e: &Env, paused: bool) { + 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. + /// + /// # Errors + /// + /// * `NavNotPaused` - The NAV is not paused. + /// + /// # Events + /// + /// * `LatestCleared` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] - pub fn clear_latest(e: &Env, caller: Address) { - caller.require_auth(); - if !ripcord_raised(e) { - panic_with_error!(e, OracleError::RipcordNotRaised); - } + pub fn clear_latest(e: &Env) { + guards::nav_paused(e); state::clear_latest(e); 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) } + /// 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) { - 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, + guards::valid_config(e, &config); + guards::direct_config_allowed(e, &config); + store_config(e, &config); + } +} + +#[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 + } + + /// 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).accepted_at + } + + /// How long a report stays valid after it is accepted, in seconds. + fn freshness_duration(e: &Env) -> u64 { + get_config(e).freshness_duration + } +} + +/// 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) { + strata_storage::bump_instance(e); + if upgradeable::get_schema_version(e) >= 2 { + panic_with_error!(e, ProbeError::AlreadyMigrated); } - .publish(e); + upgradeable::set_schema_version(e, 2); + } + + pub fn probe(_e: &Env) -> bool { + true } } #[cfg(test)] mod test; +#[cfg(test)] +mod test_governance; +#[cfg(test)] +mod test_guards; diff --git a/contracts/nav-oracle/src/state.rs b/contracts/nav-oracle/src/state.rs index 40e3a719..94508fba 100644 --- a/contracts/nav-oracle/src/state.rs +++ b/contracts/nav-oracle/src/state.rs @@ -1,40 +1,59 @@ -use soroban_sdk::{contracttype, symbol_short, Env, Symbol}; +use soroban_sdk::{contracttype, panic_with_error, symbol_short, BytesN, Env, String, Symbol}; + +use crate::OracleError; /// 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. -pub use bindings::OracleState; +// - `Paused`: the NAV is paused (issuer freeze). Value ignored entirely, both +// directions, until the issuer clears the NAV pause. +pub use strata_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"); +/// 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 NavReport { +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 timestamp: 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, + /// Ledger time the contract accepted the report. + pub accepted_at: u64, + pub proof_hash: BytesN<32>, + pub proof_uri: String, +} + +#[contracttype] +#[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 /// 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, } @@ -43,27 +62,28 @@ pub struct OracleConfig { pub(crate) enum DataKey { Config, Latest, - Ripcord, + NavPaused, } 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_or_else(|| panic_with_error!(e, OracleError::NotInitialized)) } -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) { + strata_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 { + 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) { @@ -71,5 +91,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 d6244642..1dcc5ce7 100644 --- a/contracts/nav-oracle/src/test.rs +++ b/contracts/nav-oracle/src/test.rs @@ -2,12 +2,14 @@ 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, 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; @@ -21,12 +23,27 @@ 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, 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, } @@ -55,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"), } } @@ -70,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); @@ -81,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); @@ -92,17 +114,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); + let r = input(&f.e, SCALE, 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); 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); assert_eq!(f.oracle.state(), OracleState::Valid); } @@ -110,54 +132,53 @@ fn ripcord_pauses_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), @@ -165,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)] ); @@ -173,41 +196,40 @@ 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); 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] 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 // 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); 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" @@ -218,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); @@ -264,7 +286,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( @@ -306,33 +328,34 @@ 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); + let r = input(&f.e, SCALE, 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.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_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).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); 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); } @@ -360,32 +383,30 @@ 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 - .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); } #[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 - .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()); } @@ -393,11 +414,10 @@ fn an_unbounded_rise_is_still_refused() { 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()); } @@ -417,20 +437,19 @@ 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); + f.oracle.attest(&input(&f.e, SCALE, 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.raise_ripcord(&f.guardian); - f.oracle.clear_latest(&f.admin); + f.oracle.pause_nav(&f.guardian); + f.oracle.clear_latest(); // 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.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); } @@ -439,14 +458,14 @@ fn the_record_clears_only_while_the_ripcord_is_raised() { 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.raise_ripcord(&f.guardian); + 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] @@ -465,8 +484,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] @@ -476,38 +495,35 @@ 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 - .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()); } #[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); - 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); } @@ -518,3 +534,284 @@ 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(&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); + 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(&input(&f.e, SCALE, 10_000 + 10_000), &f.attester); + + assert_eq!(f.oracle.latest().expires_at, 10_000 + 3600); +} + +#[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(&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(&input(&f.e, SCALE, 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(&input(&f.e, SCALE, 1_000_000), &f.attester); + + assert_eq!( + f.oracle + .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(&input(&f.e, SCALE, 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 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 { + 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, + }); +} + +fn implements_the_feed() {} + +#[test] +fn the_oracle_answers_the_feed_interface_the_vault_reads() { + implements_the_feed::(); + let f = setup(); + f.oracle.attest(&input(&f.e, SCALE, 1_000_000), &f.attester); + + let feed = strata_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); +} + +#[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())) + ); +} + +mod proof_reference { + use soroban_sdk::contracttype; + use soroban_sdk::xdr::{ScErrorCode, ScErrorType}; + + 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_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(); + 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, + } + + /// 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(); + 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, + }, + ) + }); + // 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 + SCALE / 20, 1_000_000); + + 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); + 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 + SCALE / 20); + 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 new file mode 100644 index 00000000..6215bde4 --- /dev/null +++ b/contracts/nav-oracle/src/test_governance.rs @@ -0,0 +1,1230 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, contracttype, symbol_short, + testutils::{ + storage::{Instance as _, Persistent as _}, + Address as _, AuthorizedFunction, AuthorizedInvocation, Events as _, Ledger as _, MockAuth, + MockAuthInvoke, + }, + Address, BytesN, Env, Event as _, IntoVal, String, 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}; + +use crate::state::{ATTESTER_ROLE, GUARDIAN_ROLE}; +use crate::{ + shortens_freshness_only, ChangeApplied, ChangeProposed, ConfigSet, NavInput, NavOracleContract, + NavOracleContractClient, 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; +/// 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] +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 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); + 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 = + strata_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.oracle.finish_setup(); + } + + fn propose(&self, change: OracleChange) -> u32 { + self.oracle.propose_change(&change) + } + + fn attest(&self, nav_per_share: i128) { + self.oracle + .attest(&input(&self.e, 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.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); + } + + /// Signs one call to the oracle as `who` and nothing else, so the call + /// 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, + invoke: &MockAuthInvoke { + contract: &self.oracle.address, + fn_name, + args, + sub_invokes: &[], + }, + }]); + } + + /// 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 { + 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 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, + proof_hash: proof(e), + proof_uri: String::from_str(e, "ipfs://strata-proof"), + } +} + +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_a_tighter_field_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_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(); + 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), OracleError::ChangeNotReady); + f.advance(1); + f.oracle.apply_change(&id); + + 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)), + 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, || { + strata_timelock::queue::propose::( + &f.e, + &OracleChange::Config(invalid), + ) + .unwrap() + }); + f.advance(MIN_DELAY); + + refused(f.oracle.try_apply_change(&id), 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(), 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_bind_vault(&Address::generate(&f.e)), + OracleError::SetupClosed, + ); +} + +#[test] +fn a_second_vault_cannot_be_bound() { + let f = setup(); + f.oracle.bind_vault(&f.vault.address); + + refused( + f.oracle.try_bind_vault(&Address::generate(&f.e)), + 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)).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.sign_only_as( + &stranger, + "bind_vault", + (f.vault.address.clone(),).into_val(&f.e), + ); + 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.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()); +} + +fn implements_the_vault_view() {} + +#[test] +fn the_oracle_answers_the_view_the_vault_reads() { + implements_the_vault_view::(); + let f = setup(); + let view = strata_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.oracle + .transfer_admin_role(&next, &(f.e.ledger().sequence() + 100)); + + refused( + f.oracle.try_finish_setup(), + OracleError::AdminTransferPending, + ); + + f.oracle.transfer_admin_role(&next, &0); + f.oracle.finish_setup(); + 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.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(); + + 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())), + 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.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(&input(&f.e, 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); + + 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.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); + + // 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())); + 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.oracle.set_nav_paused(&true); + f.oracle.clear_latest(); + + 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), 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), OracleError::ChangeNotReady); + + f.advance(3 * DAY_DURATION); + f.oracle.apply_change(&id); + 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())), + OracleError::WindDownActive, + ); + refused(f.oracle.try_apply_change(&id), OracleError::WindDownActive); + f.oracle.cancel_change(&id); +} + +#[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), + 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]))), + 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.sign_only_as( + &stranger, + "propose_change", + (change.clone(),).into_val(&f.e), + ); + 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); +} + +#[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); + 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_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.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)); + + let id = f.propose(change); + f.advance(MIN_DELAY); + f.restores_the_instance(|| f.oracle.apply_change(&id)); +} + +/// Mirrors OpenZeppelin's private member-list key: same variant and fields, same encoding. +#[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(); + 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(&input(&f.e, SCALE), &f.attester); + f.signed_once_by( + &f.attester, + "attest", + (input(&f.e, 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.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)); + f.oracle.accept_admin_transfer(); + f.signed_once_by(&next, "accept_admin_transfer", soroban_sdk::Vec::new(&f.e)); +} + +#[cfg(feature = "upgrade_wasm")] +mod with_wasm { + use soroban_sdk::xdr::{ContractEventBody, ScError, ScErrorCode, ScErrorType, ScSymbol, ScVal}; + + 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" + ); + } + + /// 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)); + 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); + + let probe = oracle_probe::Client::new(&f.e, &f.oracle.address); + assert!(probe.probe()); + assert_eq!(probe.schema_version(), 1); + probe.migrate(); + assert_eq!(probe.schema_version(), 2); + assert_eq!( + probe.try_migrate(), + Err(Ok(oracle_probe::ProbeError::AlreadyMigrated.into())) + ); + + let kept = f.oracle.latest(); + assert_eq!( + (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())); + 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); + } +} diff --git a/contracts/nav-oracle/src/test_guards.rs b/contracts/nav-oracle/src/test_guards.rs new file mode 100644 index 00000000..dd6f972f --- /dev/null +++ b/contracts/nav-oracle/src/test_guards.rs @@ -0,0 +1,398 @@ +//! 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, String, +}; +use strata_timelock::{Clock, MIN_DELAY}; + +use crate::guards; +use crate::{ + 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; + +/// 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, + attester: Address, + guardian: Address, +} + +impl Fixture<'_> { + fn finish_setup(&self) { + self.oracle.bind_vault(&self.vault); + self.oracle.finish_setup(); + } + + fn attest(&self, nav_per_share: i128) { + self.oracle.attest( + &NavInput { + nav_per_share, + expires_at: u64::MAX, + proof_hash: proof(&self.e), + proof_uri: String::from_str(&self.e, "ipfs://strata-proof"), + }, + &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, attester.clone(), guardian.clone(), config()), + ), + ); + let vault = e.register(VaultStub, ()); + Fixture { + e, + oracle, + vault, + 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 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, + accepted_at, + proof_hash: proof(e), + proof_uri: String::from_str(e, "ipfs://strata-proof"), + } +} + +#[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 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::not_a_second_upgrade(&f.e, &upgrade)); + + f.oracle.propose_change(&upgrade); + refuses(&f, OracleError::UpgradeAlreadyPending, || { + guards::not_a_second_upgrade(&f.e, &upgrade) + }); + holds(&f, || guards::not_a_second_upgrade(&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 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(&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, || { + 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(&f.e, 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) + }); +} diff --git a/contracts/share-token/Cargo.toml b/contracts/share-token/Cargo.toml index 3aecab2c..d8588513 100644 --- a/contracts/share-token/Cargo.toml +++ b/contracts/share-token/Cargo.toml @@ -15,8 +15,15 @@ stellar-access = { workspace = true } stellar-macros = { workspace = true } stellar-tokens = { workspace = true } stellar-contract-utils = { workspace = true } +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"] } 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 e69d419a..c919fae0 100644 --- a/contracts/share-token/src/contract.rs +++ b/contracts/share-token/src/contract.rs @@ -1,4 +1,8 @@ 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; use soroban_sdk::{ @@ -6,17 +10,41 @@ use soroban_sdk::{ }; 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_contract_utils::upgradeable; +use stellar_macros::{only_admin, only_role}; use stellar_tokens::{ - fungible::{Base, FungibleToken}, + fungible::{Base, ContractOverrides, FungibleToken}, rwa::{RWAToken, RWA}, }; +use strata_bindings::VaultBinding; + +/// Longest order reference a legal transfer accepts, in bytes. +pub const MAX_REFERENCE_LEN: u32 = 128; #[contract] pub struct ShareToken; #[contractimpl] impl ShareToken { + /// Creates the token with 7 decimals, its admin, its compliance authority + /// and the two contracts that check each `transfer` and mint. + /// + /// # 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, @@ -28,41 +56,525 @@ 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); + 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 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); + governance::bind_vault(e, &vault); + } + + /// 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); + governance::finish_setup(e); + } + + /// 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 + /// + /// * `ComplianceNotBound` - The new compliance contract does not list + /// 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. + /// * `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. + /// + /// # 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. + /// * `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. + /// + /// # 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 pause, freezes and the + /// compliance rules' check 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`; `TokensUnfrozen` first when frozen + /// shares move. + /// + /// # Authorization + /// + /// `caller` and the admin sign, as two different accounts. + #[only_role(caller, "compliance")] + pub fn legal_transfer( + e: &Env, + from: Address, + to: Address, + amount: i128, + reference: String, + caller: Address, + ) { + 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); + extend::balances(e, &[&from, &to]); + LegalTransfer { + from, + to, + amount, + reference, + } + .publish(e); + } +} + +#[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) } } #[contractimpl(contracttrait)] impl Pausable for ShareToken { - #[only_admin] + /// 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); } - #[only_admin] + + /// 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(); + strata_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. 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; + + /// 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( + e, + &owner, + &spender, + amount, + live_until_ledger, + ); + } + + /// All shares in circulation, in share base units. Reading it extends + /// the token's instance. + 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. + fn balance(e: &Env, account: Address) -> i128 { + strata_storage::bump_instance(e); + ::balance(e, &account) + } } #[contractimpl(contracttrait)] impl AccessControl for ShareToken { + /// 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); + access_control::grant_role(e, &account, &role, &caller); + extend::role_entries(e, &account, &role); + } + + /// 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); + } + + /// 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); + 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, 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. + /// 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); } @@ -70,19 +582,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 intervenes with it. - #[only_any_role(operator, ["manager", "compliance"])] + + /// 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 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, @@ -90,28 +714,187 @@ impl RWAToken for ShareToken { new_account: Address, operator: Address, ) -> bool { - RWA::recover_balance(e, &old_account, &new_account) + 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); + 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 } + + /// 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); + strata_storage::bump_instance(e); 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); + strata_storage::bump_instance(e); 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); } - #[only_admin] + + /// 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(); + guards::direct_change_allowed(e); + strata_storage::bump_instance(e); RWA::set_compliance(e, &compliance); } - #[only_admin] + + /// 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(); + guards::direct_change_allowed(e); + strata_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) { + strata_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..4a29c6a1 100644 --- a/contracts/share-token/src/error.rs +++ b/contracts/share-token/src/error.rs @@ -1,13 +1,70 @@ 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 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. - AdminRequired = 7000, +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, + /// 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()` 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 + /// 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/event.rs b/contracts/share-token/src/event.rs new file mode 100644 index 00000000..6a08551b --- /dev/null +++ b/contracts/share-token/src/event.rs @@ -0,0 +1,43 @@ +use soroban_sdk::{contractevent, Address, String}; + +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, +} + +#[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/extend.rs b/contracts/share-token/src/extend.rs new file mode 100644 index 00000000..352dbaa2 --- /dev/null +++ b/contracts/share-token/src/extend.rs @@ -0,0 +1,28 @@ +//! OpenZeppelin extends an entry when it reads it, not when it creates it. + +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]) { + 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); +} + +/// 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 new file mode 100644 index 00000000..7bc065f2 --- /dev/null +++ b/contracts/share-token/src/governance.rs @@ -0,0 +1,154 @@ +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 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; + +/// 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 { + strata_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)) +} + +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); +} + +pub(crate) fn finish_setup(e: &Env) { + 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 { + 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) = + 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: 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 { + TokenChange::Upgrade(hash) => upgradeable::upgrade(e, hash), + TokenChange::GrantRole(role, account) => { + 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) + } + 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/guards.rs b/contracts/share-token/src/guards.rs new file mode 100644 index 00000000..62b07327 --- /dev/null +++ b/contracts/share-token/src/guards.rs @@ -0,0 +1,154 @@ +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, utils::token_binder::TokenBinderClient, RWA, +}; +use strata_bindings::VaultBindingClient; +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); + } +} + +/// 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); + } +} + +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(_))) + }) +} + +/// 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(_) => {} + } +} + +/// The same rule the vault's `finish_setup` applies to compliance contracts. +pub(crate) fn compliance_bound_here(e: &Env, rules: &Address) { + if !bound_here_or_external(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); + } +} + +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 + } + // Either view failing marks an external module; its governance is a trust assumption. + _ => true, + } +} + +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) { + 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); + 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 d4b250f9..3760583c 100644 --- a/contracts/share-token/src/lib.rs +++ b/contracts/share-token/src/lib.rs @@ -1,9 +1,50 @@ +//! # Design +//! +//! 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, `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 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; mod error; +mod event; +mod extend; +mod governance; +mod guards; mod roles; pub use contract::*; pub use error::ShareTokenError; +pub use event::{ + ChangeApplied, ChangeCancelled, ChangeProposed, LegalTransfer, SetupFinished, VaultBound, +}; +pub use governance::{TokenChange, TokenPendingChange, ADMIN_ACCEPT_LEDGERS}; #[cfg(test)] mod test; +#[cfg(test)] +mod test_governance; +#[cfg(test)] +mod test_guards; +#[cfg(test)] +mod test_ttl; diff --git a/contracts/share-token/src/roles.rs b/contracts/share-token/src/roles.rs index 16a31009..1893c792 100644 --- a/contracts/share-token/src/roles.rs +++ b/contracts/share-token/src/roles.rs @@ -1,4 +1,10 @@ -/// 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. +use soroban_sdk::{symbol_short, Symbol}; + +/// 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. +pub const MANAGER_ROLE: Symbol = symbol_short!("manager"); diff --git a/contracts/share-token/src/test.rs b/contracts/share-token/src/test.rs index 8c45c7a6..8d752e8c 100644 --- a/contracts/share-token/src/test.rs +++ b/contracts/share-token/src/test.rs @@ -1,10 +1,16 @@ +extern crate std; + use soroban_sdk::{ symbol_short, - testutils::{Address 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; +use strata_storage::INSTANCE_EXTEND_AMOUNT; use crate::{ShareToken, ShareTokenClient}; use compliance::{Compliance, ComplianceClient}; @@ -40,7 +46,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); @@ -48,7 +54,6 @@ fn manager_mints_to_allowlisted_account() { } struct Fixture<'a> { - #[allow(dead_code)] e: Env, token: ShareTokenClient<'a>, admin: Address, @@ -60,6 +65,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); @@ -280,7 +304,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 @@ -297,8 +321,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.token.legal_transfer( + &holder, + &target, + &250, + &String::from_str(&e, "court-order-2026-117"), + &f.compliance_authority, + ); assert_eq!(f.token.balance(&target), 250); } @@ -308,7 +338,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!( @@ -361,7 +391,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)); @@ -413,3 +443,649 @@ 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); + identity.allow(&other, &true); + 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); +} + +#[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); +} + +#[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(); + 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), + ); + + 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 { + use soroban_sdk::{ + contract, contractimpl, contracttype, + testutils::Events as _, + xdr::{ScErrorCode, ScErrorType}, + Event as _, + }; + use stellar_tokens::rwa::identity_verification; + use strata_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 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, + } + } + } + + 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.token.mint(&holder, &1_000, &f.manager); + f.token.mint(&vault, &500, &f.manager); + f.token.bind_vault(&vault); + (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 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(); + 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 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(); + 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); + } +} diff --git a/contracts/share-token/src/test_governance.rs b/contracts/share-token/src/test_governance.rs new file mode 100644 index 00000000..df254c38 --- /dev/null +++ b/contracts/share-token/src/test_governance.rs @@ -0,0 +1,1067 @@ +extern crate std; + +use soroban_sdk::{ + contract, contractimpl, symbol_short, + testutils::{ + storage::Instance as _, Address as _, AuthorizedFunction, AuthorizedInvocation, + Events as _, Ledger as _, MockAuth, MockAuthInvoke, + }, + vec, Address, BytesN, Env, Event as _, IntoVal, String, Symbol, Vec, +}; +use stellar_access::role_transfer::RoleTransferError; +use strata_storage::INSTANCE_EXTEND_AMOUNT; +use strata_timelock::{Clock, DAY_DURATION, MIN_DELAY}; + +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; + +/// 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 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); + e.storage().instance().get(&symbol_short!("clock")).unwrap() + } +} + +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")); + } + + /// 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() + .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() + } +} + +/// Rules from outside the kit, such as OpenZeppelin's, with 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; + +#[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>, + vault: ClockStubClient<'a>, + admin: Address, +} + +impl Fixture<'_> { + fn clock(&self, f: impl FnOnce(&mut Clock)) { + let mut clock = + strata_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.token.finish_setup(); + } + + fn propose(&self, change: TokenChange) -> u32 { + 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 + } + + /// 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, + &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())); + self.advance(MIN_DELAY); + self.token.apply_change(&id); + self.e.ledger().sequence() + } + + 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); + } + + /// Signs one call to the token as `who` and nothing else, so the call + /// 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, + invoke: &MockAuthInvoke { + contract: &self.token.address, + fn_name, + args, + sub_invokes: &[], + }, + }]); + } + + /// 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> { + 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 = clock_stub(&e); + 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(), 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_bind_vault(&Address::generate(&f.e)), + ShareTokenError::SetupClosed, + ); +} + +#[test] +fn a_second_vault_cannot_be_bound() { + let f = setup(); + f.token.bind_vault(&f.vault.address); + + refused( + f.token.try_bind_vault(&Address::generate(&f.e)), + 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)).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.sign_only_as( + &stranger, + "bind_vault", + (f.vault.address.clone(),).into_val(&f.e), + ); + 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.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()); +} + +fn implements_the_vault_view() {} + +#[test] +fn the_token_answers_the_view_the_vault_reads() { + implements_the_vault_view::(); + let f = setup(); + let view = strata_bindings::VaultBindingClient::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.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.token + .transfer_admin_role(&next, &(f.e.ledger().sequence() + 100)); + + refused( + f.token.try_finish_setup(), + ShareTokenError::AdminTransferPending, + ); + + f.token.transfer_admin_role(&next, &0); + f.token.finish_setup(); + 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.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(); + + 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] +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())), + 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), + ShareTokenError::ChangeNotReady, + ); + + f.advance(1); + f.token.apply_change(&id); + 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); + + 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::GrantRole(manager(), 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), 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); +} + +#[test] +fn nothing_is_proposed_or_applied_while_the_vault_winds_down() { + let f = setup(); + f.finish(); + 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::GrantRole(manager(), Address::generate(&f.e))), + ShareTokenError::WindDownActive, + ); + refused( + f.token.try_apply_change(&id), + ShareTokenError::WindDownActive, + ); + f.token.cancel_change(&id); +} + +#[test] +fn a_change_shorter_than_a_raised_notice_never_lands() { + let f = setup(); + f.finish(); + 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; + }); + f.advance(30 * DAY_DURATION); + + refused( + f.token.try_apply_change(&id), + 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]))), + 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::GrantRole(manager(), Address::generate(&f.e)); + let id = f.propose(change.clone()); + f.advance(MIN_DELAY); + + f.sign_only_as( + &stranger, + "propose_change", + (change.clone(),).into_val(&f.e), + ); + 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); +} + +#[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); + + // 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())); + f.token.accept_admin_transfer(); + assert_eq!(f.token.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.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 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] +fn applied_rule_and_verifier_changes_take_effect() { + let f = setup(); + f.finish(); + 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); + + f.token.apply_change(&first); + f.token.apply_change(&second); + + assert_eq!(f.token.compliance(), rules); + 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 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( + 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 = f.rules_stub_bound_here(); + 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(&None, &false, &listed)); + refused_once_the_rules_turn(&f, |rules| rules.report(&here, &true, &listed)); + 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); +} + +/// 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()]; + + 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] +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::GrantRole(manager(), 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); + 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 proposing_reads_the_vault_clock_once() { + let f = setup(); + f.finish(); + let before = f.vault.reads(); + + f.propose(TokenChange::GrantRole(manager(), 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::GrantRole(manager(), 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(); + + 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.restores_the_instance(|| f.token.finish_setup()); + + let grant = TokenChange::GrantRole(manager(), Address::generate(&f.e)); + let mut id = 0; + f.restores_the_instance(|| id = f.propose(grant.clone())); + f.restores_the_instance(|| f.token.cancel_change(&id)); + + let id = f.propose(grant); + f.advance(MIN_DELAY); + 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(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); + 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 new file mode 100644 index 00000000..6c99d594 --- /dev/null +++ b/contracts/share-token/src/test_guards.rs @@ -0,0 +1,342 @@ +//! 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 _, + xdr::{ScErrorCode, ScErrorType}, + 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 compliance::{Compliance, ComplianceClient}; +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.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> { + 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 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::not_a_second_upgrade(&f.e, &upgrade)); + + 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 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, || { + 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(); + 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(); + 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_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] +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); + 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); + + 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); + + 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) + }); +} diff --git a/contracts/share-token/src/test_ttl.rs b/contracts/share-token/src/test_ttl.rs new file mode 100644 index 00000000..cbbee7c4 --- /dev/null +++ b/contracts/share-token/src/test_ttl.rs @@ -0,0 +1,413 @@ +//! 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, + 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, TokenChange}; +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; +const DAY_IN_LEDGERS: u32 = 17_280; + +/// 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, + ) { + } +} + +/// 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 member-list key: same variant and fields, same encoding. +#[contracttype] +struct RoleAccountKey { + role: Symbol, + index: u32, +} + +#[contracttype] +enum RoleMemberKey { + RoleAccounts(RoleAccountKey), +} + +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())) + } + + /// 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); + 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 age_days(&self, days: u32) { + self.e + .ledger() + .with_mut(|l| l.sequence_number += days * DAY_IN_LEDGERS); + } +} + +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 empty account that sends 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 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 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/crates/bindings/src/lib.rs b/crates/bindings/src/lib.rs deleted file mode 100644 index 80e77826..00000000 --- a/crates/bindings/src/lib.rs +++ /dev/null @@ -1,28 +0,0 @@ -#![no_std] - -use soroban_sdk::{contractclient, contracttype, Address, Env}; - -#[contracttype] -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum OracleState { - Valid, - Stale, - Paused, -} - -#[contractclient(name = "OracleFeedClient")] -pub trait OracleFeed { - fn state(e: &Env) -> OracleState; - fn nav_per_share(e: &Env) -> i128; - 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; -} 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/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 new file mode 100644 index 00000000..1226f12d --- /dev/null +++ b/crates/strata-bindings/src/lib.rs @@ -0,0 +1,50 @@ +//! # Design +//! +//! 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 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: 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. + +#![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 { + Valid, + Stale, + Paused, +} + +/// 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; + /// 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 VaultBinding { + /// 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; +} 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") + ); +} 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 61% rename from crates/storage/src/lib.rs rename to crates/strata-storage/src/lib.rs index 6ad5f337..9c79aec1 100644 --- a/crates/storage/src/lib.rs +++ b/crates/strata-storage/src/lib.rs @@ -1,3 +1,21 @@ +//! # 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 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] use core::fmt::Debug; 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/strata-timelock/Cargo.toml b/crates/strata-timelock/Cargo.toml new file mode 100644 index 00000000..68733c88 --- /dev/null +++ b/crates/strata-timelock/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "strata-timelock" +version = "0.1.0" +edition = { workspace = true } +license = { workspace = true } +publish = false + +[lib] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } +strata-storage = { path = "../strata-storage" } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } +proptest = "=1.11.0" diff --git a/crates/strata-timelock/src/clock.rs b/crates/strata-timelock/src/clock.rs new file mode 100644 index 00000000..020ff893 --- /dev/null +++ b/crates/strata-timelock/src/clock.rs @@ -0,0 +1,40 @@ +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 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, + 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/strata-timelock/src/delay.rs b/crates/strata-timelock/src/delay.rs new file mode 100644 index 00000000..12772b11 --- /dev/null +++ b/crates/strata-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 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; + +pub fn in_bounds(delay: u64) -> bool { + (MIN_DELAY..=MAX_DELAY).contains(&delay) +} diff --git a/crates/strata-timelock/src/keys.rs b/crates/strata-timelock/src/keys.rs new file mode 100644 index 00000000..d5ae78f5 --- /dev/null +++ b/crates/strata-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/strata-timelock/src/lib.rs b/crates/strata-timelock/src/lib.rs new file mode 100644 index 00000000..203ea026 --- /dev/null +++ b/crates/strata-timelock/src/lib.rs @@ -0,0 +1,47 @@ +//! # Design +//! +//! A timelock for privileged changes. One contract owns the clock: its delay, +//! 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 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. + +#![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/strata-timelock/src/pause.rs b/crates/strata-timelock/src/pause.rs new file mode 100644 index 00000000..d9cdc856 --- /dev/null +++ b/crates/strata-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 = strata_storage::get_instance(e, &Key::TimelockPausedSince); + if since.is_none() { + 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 = 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)); + 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 = 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 { + strata_storage::get_instance(e, &Key::TimelockPausedSecs).unwrap_or(0) +} diff --git a/crates/strata-timelock/src/queue.rs b/crates/strata-timelock/src/queue.rs new file mode 100644 index 00000000..31f39d84 --- /dev/null +++ b/crates/strata-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 { + strata_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, +{ + strata_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 = 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, + }; + strata_storage::set_persistent(e, &Key::TimelockPending(id), &(change.clone(), schedule)); + open.push_back(id); + strata_storage::set_instance(e, &Key::TimelockOpen, &open); + strata_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); + strata_storage::set_instance(e, &Key::TimelockOpen, &open); + e.storage().persistent().remove(&Key::TimelockPending(id)); + Ok(()) +} diff --git a/crates/strata-timelock/src/refusal.rs b/crates/strata-timelock/src/refusal.rs new file mode 100644 index 00000000..fa718be7 --- /dev/null +++ b/crates/strata-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 source's notice now. + 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/strata-timelock/src/schedule.rs b/crates/strata-timelock/src/schedule.rs new file mode 100644 index 00000000..ef12d694 --- /dev/null +++ b/crates/strata-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/strata-timelock/src/setup.rs b/crates/strata-timelock/src/setup.rs new file mode 100644 index 00000000..6d165eb2 --- /dev/null +++ b/crates/strata-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 { + !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
{ + strata_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(); + strata_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)?; + strata_storage::set_instance(e, &Key::TimelockSetupDone, &true); + Ok(()) +} diff --git a/crates/strata-timelock/src/test/clock.rs b/crates/strata-timelock/src/test/clock.rs new file mode 100644 index 00000000..cec86539 --- /dev/null +++ b/crates/strata-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/strata-timelock/src/test/delay.rs b/crates/strata-timelock/src/test/delay.rs new file mode 100644 index 00000000..c16a7eb7 --- /dev/null +++ b/crates/strata-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/strata-timelock/src/test/mod.rs b/crates/strata-timelock/src/test/mod.rs new file mode 100644 index 00000000..9d8e2b85 --- /dev/null +++ b/crates/strata-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/strata-timelock/src/test/pause.rs b/crates/strata-timelock/src/test/pause.rs new file mode 100644 index 00000000..bb68ab88 --- /dev/null +++ b/crates/strata-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/strata-timelock/src/test/queue.rs b/crates/strata-timelock/src/test/queue.rs new file mode 100644 index 00000000..179bdd0f --- /dev/null +++ b/crates/strata-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/strata-timelock/src/test/schedule.rs b/crates/strata-timelock/src/test/schedule.rs new file mode 100644 index 00000000..1540842d --- /dev/null +++ b/crates/strata-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/strata-timelock/src/test/setup.rs b/crates/strata-timelock/src/test/setup.rs new file mode 100644 index 00000000..9be8f5a0 --- /dev/null +++ b/crates/strata-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) + ); +} 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/AUTHORIZATION_MATRIX.md b/docs/AUTHORIZATION_MATRIX.md index 69b13459..832a821b 100644 --- a/docs/AUTHORIZATION_MATRIX.md +++ b/docs/AUTHORIZATION_MATRIX.md @@ -1,21 +1,25 @@ # 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()`. -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. - - **Manager (Vault)**: Closes epochs. +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, 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 role, not an authority)**: 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. + - **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. --- @@ -26,26 +30,30 @@ 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, 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 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: - 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 })` 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. 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). --- ## 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. --- @@ -55,84 +63,145 @@ 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` and not winding down. | -| **`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. | -| **`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`** | `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. 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. | --- -## 5. Complete Access Control Matrix Across All 4 Contracts +## 5. Complete Access Control Matrix Across All 5 Contracts ### 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, 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/). [`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()` | 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` | -| `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` | -| `renounce_admin` | Refused always (`VaultError::AdminRequired`) | None | `test::controls::governance_cannot_renounce_itself_out_of_the_vault` | +| `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]` | 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); 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`) | +| `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` | +| `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`, `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) | +| `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`) -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. 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). +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_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(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` | -| `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` | -| `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`. | +| `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` | `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]` | 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` | +| `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`) | +| `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. | +| `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`) -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). 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")]` | 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` | -| `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` | +| `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` | +| `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]` | 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`) | +| `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. | +| `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). +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 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). 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]`; `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` | +| `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::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/COMPLIANCE_MATRIX.md b/docs/COMPLIANCE_MATRIX.md index 101e9cbb..3131cd90 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 does not check freezes today, so a frozen holder's exit still goes through; the kit does not promise it. + - 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. --- @@ -29,48 +31,55 @@ 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. | -| **`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_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. | +| **`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. | +| **`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. | +| **`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. | --- ## 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 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 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: -- 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. -### 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`. -- **`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` 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**, 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. -- The investor is not allowed to re-enter share possession. They remain in the queue and exit into cash upon epoch fulfillment. +- 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 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. --- @@ -83,11 +92,14 @@ 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`. -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. +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 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. --- @@ -95,14 +107,27 @@ 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_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. * `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. +* `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_on_the_allowlist_is_still_minted`: Records that minting does not check freezes. -* `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_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_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. +* `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 219e4951..8a4db064 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -11,34 +11,79 @@ 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, 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 +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 +``` + +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. + +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 -[`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. +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 | | --- | --- | --- | -| 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` | | 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` | +| 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` | 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 manages their TTL itself. +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 @@ -51,22 +96,32 @@ membership is the contract's real state, not a mock. Most of these tests run with `mock_all_auths`, so every signature is granted and a refusal can only come from the role check. -**Whether a signature is required.** A refusal test clears all signatures with -`set_auths(&[])` and checks the call fails and changes nothing, which proves the -entrypoint requires the right address to sign. +**Which signatures a call needs.** A refusal test signs the call as another +account only (`mock_auths`), or clears all signatures with `set_auths(&[])`, +and checks the call fails and changes nothing, which proves the entrypoint +requires the right address to sign. A signature test reads `e.auths()` right +after a call and expects one entry per account the call needs, on that call +only. The per-entrypoint role matrix, with the refusal test for each, is in [AUTHORIZATION_MATRIX.md](./AUTHORIZATION_MATRIX.md). | 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` | | 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` | +| 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` | +| 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` | ## Ledger time and sequence @@ -79,27 +134,71 @@ 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` | +| 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` | ## Events | 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` | -| The oracle publishes the valuation it stored | [`contracts/nav-oracle/src/test.rs`](../contracts/nav-oracle/src/test.rs) | `attesting_publishes_the_stored_valuation` | +| 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`, `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 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 | | --- | --- | --- | | 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 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` | -| 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` | +| 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 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` | +| 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` | + +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 + +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 +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/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/docs/kit-spec.md b/docs/kit-spec.md index 80ce0d84..5fcfa48b 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -37,14 +37,15 @@ 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, 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. - **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). + 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. @@ -52,15 +53,22 @@ 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, 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 -- **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. - - `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`. + - 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 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. ### 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. @@ -72,21 +80,38 @@ 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 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 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. - **Testable Property**: - - Upgrade delay $`\in [7 \text{ days}, 90 \text{ days}]`$ and $`\text{delay} \ge \text{notice}`$. - - 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. + - 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. 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`. + - 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`). + - 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 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`. ### 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. - **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`). --- @@ -103,30 +128,30 @@ 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` | | **`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`** | `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`** | `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 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` | -| **`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` | | **`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` | +| **`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` @@ -138,30 +163,93 @@ 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`). | -| **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}`$. | -| **Oracle Configuration** | `set_config(config: OracleConfig)` | Governance | Updates oracle parameters. Follows same validation rules as constructor. | +| **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. 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. | +| **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 + +`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 | +|---|---| +| `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. 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, 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. + +### 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 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. --- ## 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, 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 | 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. | -| **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. | -| **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. | +| **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, 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. | +| **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 + +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. 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. + +### 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. 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). +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: + +- **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). --- @@ -169,47 +257,80 @@ 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`** | `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 (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 | | **`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`** | `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`; 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 | +| **`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 | | **`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_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`** | `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`** | `set_config` | Governance | `#[only_admin]` | No | -| **`NavOracle`** | `clear_latest` | Governance | `#[only_admin]` (requires ripcord) | No | -| **`ShareToken`** | `pause` | Admin (Governance) | `#[only_admin]`, `caller.require_auth()` | N/A | -| **`ShareToken`** | `unpause` | Admin (Governance) | `#[only_admin]`, `caller.require_auth()` | N/A | +| **`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 | +| **`AsyncVault`** | `pending_changes` | Permissionless | None (view) | 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 | 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 | +| **`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) | `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) 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`** | `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 | `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; 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; 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 | +| **`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 | +| **`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 | +| **`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 | --- @@ -220,10 +341,263 @@ 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 `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 `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, 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. 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** - - 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. + +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. 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 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 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`; 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, 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` +(`PERSISTENT_EXTEND_AMOUNT`, `INSTANCE_EXTEND_AMOUNT`, and thresholds one day +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, +extended to 30 days when it is proposed. The wait itself can run up to 90 days +(`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 +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, 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 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: + +- 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; +- 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 +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 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 +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. 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 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 + 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 +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 +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. 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 +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. + +--- + +## 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/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`, `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 | + +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 +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. + +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`), 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. + +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`. + +--- + +## 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 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 | +|---|---|---|---| +| `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 | + +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. diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index a1dcb650..4b8b0c6b 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -76,12 +76,12 @@ 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, 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 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 @@ -157,16 +157,21 @@ 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 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 @@ -219,7 +224,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 @@ -276,17 +281,17 @@ 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. +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 | 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) | -| **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 | +| **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 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 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 | @@ -303,6 +308,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 @@ -310,18 +319,18 @@ 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 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, 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. +- 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. 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 -- 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. +- 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. -- 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 @@ -331,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 ``` @@ -344,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 @@ -358,25 +367,46 @@ 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 -and exposes it with the liquidity figures it owns: +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, 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 -free_reserve = max(liquid_reserve - committed, 0) +free_reserve = max(liquid_reserve - committed - wind_down_owed, 0) uncovered = max(committed - liquid_reserve, 0) ``` -**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 -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. +`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`. +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 +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 @@ -390,16 +420,18 @@ 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. - -**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 +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: +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. @@ -411,9 +443,13 @@ 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 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). @@ -423,12 +459,16 @@ 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 - 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 @@ -441,12 +481,19 @@ 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; - and symmetrically, a redemption priced at zero assets before the snapshot - returns its shares outside the wind-down distribution. + 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. +- 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 @@ -476,10 +523,11 @@ 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 ripcord has -two entrypoints: `raise_ripcord` for its guardian and `set_ripcord` for its +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 (permissionless actions to any connected address): @@ -487,10 +535,10 @@ 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 | -| 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) | -| 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) | +| 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 | ```mermaid flowchart LR @@ -520,8 +568,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 @@ -530,7 +578,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"] @@ -561,6 +610,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 @@ -581,13 +632,15 @@ 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. | +| 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 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 | -| 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 module | Faulty module blocks transfers | Fail-closed semantics; replaceable by governance without touching the token | +| 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 | | 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/environments.toml b/environments.toml index 1d1e898a..5ba7c522 100644 --- a/environments.toml +++ b/environments.toml @@ -34,13 +34,14 @@ 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\":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 @@ -51,8 +52,18 @@ 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, oracle and compliance bindings, the manager role and the +# allowlist, which only the harness deploy wires, and that the token has no +# 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. ### Staging environment configuration @@ -71,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 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) 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/deploy.ts b/scripts/harness/deploy.ts index 1987170c..9dddbd42 100644 --- a/scripts/harness/deploy.ts +++ b/scripts/harness/deploy.ts @@ -2,14 +2,16 @@ import { existsSync } from "node:fs" import { basename, join, relative } 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" 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 { fund, type Accounts } from "./lib/accounts.js" +import { ACCOUNT_NAMES, fund, type Accounts } from "./lib/accounts.js" import { deployContract, ensureAssetContract, @@ -25,8 +27,17 @@ import { import { ensureKeys } from "./lib/keys.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) @@ -34,8 +45,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`, @@ -58,40 +71,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"), @@ -99,24 +108,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 { @@ -128,9 +132,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, @@ -157,6 +161,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 })).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") + const { Client: IdentityVerifier } = (await import( clientEntry("identity_verifier") )) as typeof IdentityVerifierModule @@ -167,7 +178,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 @@ -191,18 +201,78 @@ 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") -} -async function main(): Promise { - requireWasm() - const net = network() - const server = new rpc.Server(net.rpcUrl, { allowHttp: net.name === "local" }) - console.log(`deploying to ${net.name} (${net.rpcUrl})`) + // Binding the token is instant only during setup, so the compliance + // contract's setup closes after it. + 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") - const { file, accounts, created } = ensureKeys(net.name, REPO_ROOT) - console.log( - `account keys in ${relative(REPO_ROOT, file)} (${created.length} new)`, + 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 })).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") +} + +// The custodian is set while setup is open; afterwards it changes only through +// the timelock. finish_setup refuses until the custodian and every wiring step +// above are in place. +async function finishVaultSetup( + net: Network, + accounts: Accounts, + contracts: Record, +): Promise { + const governance = accounts.governance + 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(), + }) + ).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") + + const health = (await vault.health()).result + console.log("health", JSON.stringify(health)) + const wiring = [ + "token_bound", + "oracle_bound", + "sole_token_manager", + "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) + throw new Error(`the vault is not wired: ${broken.join(", ")}`) +} + +async function freshSet( + net: Network, + wasms: Record, + accounts: Accounts, +): Promise { + const server = new rpc.Server(net.rpcUrl, { allowHttp: net.name === "local" }) const keys = Object.values(accounts) const funded = await fund(net, server, keys) console.log( @@ -218,16 +288,9 @@ async function main(): Promise { ) 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) - - const record: Deployment = { + return { network: net.name, deployedAt: new Date().toISOString(), ledger: (await server.getLatestLedger()).sequence, @@ -244,6 +307,61 @@ async function main(): Promise { ]), ) as Deployment["accounts"], } +} + +// Random accounts, so a throwaway set leaves the kept keys and their funds alone. +const throwawayAccounts = (): Accounts => + Object.fromEntries( + ACCOUNT_NAMES.map((name) => [name, Keypair.random()]), + ) as 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 { + requireWasm(vaultWasm) + const wasms = Object.fromEntries( + CONTRACT_NAMES.map((name) => [name, wasmOf(name)]), + ) as Record + wasms.async_vault = vaultWasm + const record = await freshSet(net, wasms, throwawayAccounts()) + 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 { file, accounts, created } = ensureKeys(net.name, REPO_ROOT) + console.log( + `account keys in ${relative(REPO_ROOT, file)} (${created.length} new)`, + ) + const wasms = Object.fromEntries( + CONTRACT_NAMES.map((name) => [name, wasmOf(name)]), + ) as Record + const record = await freshSet(net, wasms, accounts) + + for (const name of CONTRACT_NAMES) { + await generateClient(wasmOf(name), clientDir(name)) + } + console.log("clients generated") + + await grantAndVerify(net, accounts, record.contracts) + await finishVaultSetup(net, accounts, record.contracts) + const kept = saveDeployment(record) if (kept !== undefined) console.log(`kept the previous record as ${basename(kept)}`) @@ -253,7 +371,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/extend.ts b/scripts/harness/extend.ts new file mode 100644 index 00000000..4194c800 --- /dev/null +++ b/scripts/harness/extend.ts @@ -0,0 +1,146 @@ +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 { rpcArgs, stellar, 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, 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 + asset?: { contract: string } +} + +// Contracts whose storage holds a timelock change queue, by the name they +// deploy under. +const CONTRACTS_WITH_QUEUES = new Set([ + "async_vault", + "share_token", + "nav_oracle", + "compliance", +]) + +/** + * 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/strata-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] +} + +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) + } + // 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)) { + await keepAlive( + rpc, + { id, keyXdr: pendingChangeKey(changeId) }, + `${name} pending change ${changeId}`, + latest, + ) + } + } +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 +}) diff --git a/scripts/harness/guards.test.ts b/scripts/harness/guards.test.ts index 586a7cdb..6ce8f5d7 100644 --- a/scripts/harness/guards.test.ts +++ b/scripts/harness/guards.test.ts @@ -1,9 +1,10 @@ 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" import { + complianceContract, connect, identityVerifier, oracle, @@ -13,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() @@ -61,16 +62,9 @@ beforeAll(async () => { verifier.allow({ account: holder.publicKey(), allowed: true, - caller: compliance.publicKey(), }), ) } - await send( - vault(c, governance).set_custodian({ - custodian: custodian.publicKey(), - caller: governance.publicKey(), - }), - ) }) describe("an account with no role", () => { @@ -90,32 +84,28 @@ 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] }, + }), + ), ).rejects.toThrow(/signatures/) }) 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", ) }) - 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 () => { @@ -125,29 +115,30 @@ 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() })) }) - it("cannot set the ripcord", async () => { + 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_ripcord({ 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).raise_ripcord({ caller: guardian.publicKey() }), + await send(oracle(c, guardian).pause_nav({ caller: guardian.publicKey() })) + await expect(send(oracle(c, outsider).clear_latest())).rejects.toThrow( + /signatures/, ) - 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(), }), ) }) @@ -169,7 +160,6 @@ describe("the compliance authority's reach", () => { identityVerifier(c, governance).allow({ account: outsider.publicKey(), allowed: true, - caller: governance.publicKey(), }), ), ).rejects.toThrow(/signatures/) @@ -203,23 +193,173 @@ 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.identity_verifier], + }, }), ), ).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()] }, + }), + ) + expectRefused(await token.apply_change({ id }), token, "ChangeNotReady") + await send(token.cancel_change({ id })) + expect((await token.pending_changes()).result).toEqual([]) + }) +}) + +describe("the oracle after setup", () => { + 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 }], + }, + }), + ) + expectRefused(await o.apply_change({ id }), o, "ChangeNotReady") + await send(o.cancel_change({ id })) + expect((await o.pending_changes()).result).toEqual([]) + }) +}) + +describe("the compliance contract after setup", () => { + const caller = () => 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: caller(), + }), + rules, + "UseTimelock", + ) + }) + + 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()] }, + }), + ) + expectRefused(await rules.apply_change({ id }), rules, "ChangeNotReady") + await send(rules.cancel_change({ id })) + expect((await rules.pending_changes()).result).toEqual([]) + }) +}) + +describe("the vault after setup", () => { + it("governance cannot change the custodian at once", async () => { + const v = vault(c, governance) + expectRefused( + await v.set_custodian({ + custodian: outsider.publicKey(), + }), + v, + "UseTimelock", + ) + 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( + v.propose_change({ + change: { tag: "Custodian", values: [outsider.publicKey()] }, + }), + ) + expectRefused(await v.apply_change({ id }), v, "ChangeNotReady") + await send(v.cancel_change({ id })) + 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) expectRefused( - await v.request_deposit({ from: first.publicKey(), amount: 0n }), + await v.request_deposit({ holder: first.publicKey(), amount: 0n }), v, "InvalidAmount", ) @@ -228,7 +368,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", ) @@ -237,14 +377,28 @@ 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 })) + }) +}) + +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({ + holder: outsider.publicKey(), + amount: usdc(10), + }), + v, + "NotAllowlisted", + ) }) }) @@ -257,14 +411,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), }), ) @@ -273,7 +427,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), @@ -294,7 +448,7 @@ describe("two epochs pending at once", () => { expect( await send( vault(c, first).claim_deposit({ - caller: first.publicKey(), + holder: first.publicKey(), epoch_id: epochA, }), ), @@ -302,14 +456,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), @@ -317,7 +471,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), @@ -336,13 +490,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, }), ) @@ -364,7 +518,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, }), ) @@ -375,7 +529,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", ) @@ -385,13 +539,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, }), ) @@ -399,7 +553,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), @@ -414,7 +568,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) @@ -422,7 +579,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, }), ), @@ -433,26 +590,25 @@ 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({ account: first.publicKey(), allowed: false, - caller: compliance.publicKey(), }), ) expectRefused( - await vault(c, first).cancel_redeem({ from, epoch_id: epoch }), - shareToken(c, outsider), - "IdentityVerificationFailed", + await vault(c, first).cancel_redeem({ holder, epoch_id: epoch }), + vault(c, first), + "NotAllowlisted", ) }) @@ -463,7 +619,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, }), ) @@ -476,7 +632,6 @@ describe("a de-listed holder", () => { identityVerifier(c, compliance).allow({ account: first.publicKey(), allowed: true, - caller: compliance.publicKey(), }), ) expect( @@ -497,13 +652,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, }), ) @@ -511,21 +666,19 @@ 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", ) }) - 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 }), @@ -539,7 +692,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, }), ), @@ -551,7 +704,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, }), ), @@ -562,7 +715,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, @@ -578,24 +731,13 @@ 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(), }), ) await send( vault(c, governance).set_notice({ secs: noticeSecs, - caller: governance.publicKey(), - }), - ) - }) - - afterAll(async () => { - await send( - vault(c, governance).set_notice({ - secs: 0n, - caller: governance.publicKey(), }), ) }) @@ -603,7 +745,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), }), ) @@ -642,7 +784,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), }), ) @@ -650,7 +792,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", ) @@ -667,4 +809,10 @@ 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 }), 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..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() @@ -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) @@ -79,7 +71,6 @@ describe("setup", () => { verifier.allow({ account: holder.publicKey(), allowed: true, - caller: compliance.publicKey(), }), ) expect( @@ -102,7 +93,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(), }), ) @@ -110,7 +101,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()) @@ -128,7 +119,7 @@ describe("subscription", () => { const v = vault(c, first) expectRefused( await v.cancel_deposit({ - from: first.publicKey(), + holder: first.publicKey(), epoch_id: subscriptionEpoch + 1000n, }), v, @@ -140,12 +131,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, }), ) @@ -180,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 () => { @@ -209,7 +202,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, }), ) @@ -224,7 +217,7 @@ describe("pricing", () => { const v = vault(c, first) expectRefused( await v.claim_deposit({ - caller: first.publicKey(), + holder: first.publicKey(), epoch_id: subscriptionEpoch, }), v, @@ -275,7 +268,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, }), ) @@ -290,7 +283,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", ) @@ -300,7 +293,7 @@ describe("redemption", () => { const v = vault(c, first) expectRefused( await v.claim_redeem({ - caller: first.publicKey(), + holder: first.publicKey(), epoch_id: redemptionEpoch, }), v, @@ -313,14 +306,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 () => { @@ -352,7 +340,7 @@ describe("treasury", () => { const v = vault(c, first) expectRefused( await v.claim_redeem({ - caller: first.publicKey(), + holder: first.publicKey(), epoch_id: redemptionEpoch, }), v, @@ -375,7 +363,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(), }), ) @@ -384,7 +372,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/lib/accounts.test.ts b/scripts/harness/lib/accounts.test.ts index 75d11405..0e20fdb1 100644 --- a/scripts/harness/lib/accounts.test.ts +++ b/scripts/harness/lib/accounts.test.ts @@ -20,7 +20,7 @@ const networkWith = (funding: Funding): Network => ({ freshness_duration: 300, cooldown_secs: 5, max_up_bps: 1000, - max_down_bps: null, + max_down_bps: 10_000, min_answer: "1", max_answer: "2", }, diff --git a/scripts/harness/lib/cli.ts b/scripts/harness/lib/cli.ts index 1e4dbd69..55a4772b 100644 --- a/scripts/harness/lib/cli.ts +++ b/scripts/harness/lib/cli.ts @@ -42,14 +42,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/clients.ts b/scripts/harness/lib/clients.ts index 203db0b0..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" @@ -23,7 +24,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, @@ -58,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 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(), } diff --git a/scripts/harness/lib/oracle.ts b/scripts/harness/lib/oracle.ts index 399ac4f8..7d2a89f9 100644 --- a/scripts/harness/lib/oracle.ts +++ b/scripts/harness/lib/oracle.ts @@ -1,19 +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 } -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 + +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: now + BigInt(freshnessSecs), - timestamp: now, + expires_at: NO_EXPIRY, + proof_hash: createHash("sha256") + .update(readFileSync(SAMPLE_PROOF)) + .digest(), + proof_uri: SAMPLE_PROOF_URI, } } @@ -24,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, c.net.oracle.freshness_duration), + input: navInput(nav), caller: attester.publicKey(), }) const result = outcome(tx, client) diff --git a/scripts/harness/lib/ttl.ts b/scripts/harness/lib/ttl.ts new file mode 100644 index 00000000..d60101b6 --- /dev/null +++ b/scripts/harness/lib/ttl.ts @@ -0,0 +1,99 @@ +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 } | 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[] => + "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 } + } + } + } + }> +} + +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 = + "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", + "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 f43c7724..9c8096b7 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", "keys": "tsx keys.ts", "build:clients": "tsx build-clients.ts", "lint": "eslint .", @@ -13,6 +14,7 @@ "test:unit": "vitest run lib/ --reporter=default", "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/seed.ts b/scripts/harness/seed.ts index a6a546d7..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`) @@ -121,7 +120,6 @@ async function main(): Promise { const c: Connected = connect() const { - governance, manager, treasury, custodian, @@ -162,20 +160,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...") @@ -193,13 +184,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), }), ) @@ -221,13 +212,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, }), ) @@ -274,14 +265,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), }), ) @@ -295,14 +286,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), }), ) @@ -379,13 +370,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/tsconfig.json b/scripts/harness/tsconfig.json index eaef5fec..6892162e 100644 --- a/scripts/harness/tsconfig.json +++ b/scripts/harness/tsconfig.json @@ -15,6 +15,7 @@ }, "include": [ "deploy.ts", + "extend.ts", "keys.ts", "build-clients.ts", "seed.ts", diff --git a/scripts/harness/wind-down.test.ts b/scripts/harness/wind-down.test.ts new file mode 100644 index 00000000..0b354cd1 --- /dev/null +++ b/scripts/harness/wind-down.test.ts @@ -0,0 +1,321 @@ +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, + usdcBalance, + 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, + }), + ) +}) + +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", + { holder: 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", + { holder: holder(), epoch_id: 1n }, + true, + ), + ).toBe("accepted") + }) + + it("the holder asks to redeem part of it", async () => { + expect( + await call( + "request_redeem", + "holder-1", + { holder: holder(), shares: usdc(10) }, + true, + ), + ).toBe("accepted") + }) + + it("governance proposes the wind-down and anyone activates it", async () => { + expect(await call("propose_wind_down", "governance", {}, 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", + ) + + 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 () => { + expect(await call("total_economic_supply", "outsider")).toBe("accepted") + }) + + 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) + 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, 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 }], + ["get_role_admin", { role: "manager" }], + ] + for (const [name, args] of reads) + expect({ [name]: await call(name, "outsider", args) }).toEqual({ + [name]: "accepted", + }) + + // 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 }, "accepted"], + [ + "set_custodian", + "governance", + { custodian: someone }, + "refused: UseTimelock", + ], + ["set_wind_down_delay", "governance", { secs: 1n }, "accepted"], + [ + "cancel_wind_down_proposal", + "governance", + {}, + "refused: WindDownActive", + ], + ["set_notice", "governance", { secs: 1n }, "accepted"], + ["finish_setup", "governance", {}, "refused: SetupClosed"], + [ + "propose_change", + "governance", + { change: { tag: "Custodian", values: [someone] } }, + "refused: WindDownActive", + ], + ["cancel_change", "governance", { id: 1 }, "refused: ChangeNotFound"], + ["apply_change", "governance", { id: 1 }, "refused: ChangeNotFound"], + [ + "deploy_to_custodian", + "treasury", + { caller: c.keys.treasury.publicKey(), assets: 1n }, + "refused: WindDownActive", + ], + ["fund", "holder-1", { caller: holder(), assets: 1n }, "accepted"], + [ + "cancel_deposit", + "holder-1", + { holder: holder(), epoch_id: 1n }, + "refused: AlreadyPriced", + ], + [ + "cancel_redeem", + "holder-1", + { holder: holder(), epoch_id: 2n }, + "accepted", + ], + [ + "claim_redeem", + "holder-1", + { holder: holder(), epoch_id: 2n }, + "refused: EpochNotFulfilled", + ], + [ + "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", + ], + [ + "transfer_admin_role", + "governance", + { new_admin: someone, live_until_ledger: 1 }, + "refused: UseTimelock", + ], + ["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", + ], + ["unpause", "governance", { caller: gov }, "refused: ExpectedPause"], + ] + 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 + .funcs() + .map((f) => f.name().toString()) + .filter((name) => name !== "__constructor") + expect(every.filter((name) => !called.has(name))).toEqual([]) + }) +}) 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 }