Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
3180dd5
docs: open each contract with its design notes
hpmaxi Oct 20, 2026
e34e5b9
docs: open the shared crates with design notes
hpmaxi Oct 20, 2026
77b236e
docs: correct the attester and manager docs
hpmaxi Oct 20, 2026
39b3cdd
docs: call the timelock delay an announcement
hpmaxi Oct 20, 2026
bc7915b
docs: state what refunds and capital figures do
hpmaxi Oct 20, 2026
72c15a5
docs: tighten the kit spec wording
hpmaxi Oct 20, 2026
679f592
docs: tighten the design notes
hpmaxi Oct 20, 2026
2c3b67d
test: pin four claims of the design notes
hpmaxi Oct 20, 2026
f4ba9a4
docs: always clear a compromised attester's report
hpmaxi Oct 20, 2026
ea0d661
docs: drop stale manager and exit window wording
hpmaxi Oct 20, 2026
f07f5f6
docs: correct role, exit and feed claims
hpmaxi Oct 20, 2026
546fece
docs: narrow the token's compliance claims
hpmaxi Oct 20, 2026
d74f5ce
docs: list the spec doc test and its limit
hpmaxi Oct 20, 2026
66b60fc
refactor: name two guards for what they check
hpmaxi Oct 20, 2026
c420f86
Merge branch 'docs/contract-spec' into docs/design-notes
hpmaxi Oct 20, 2026
5ba50d6
Merge branch 'docs/contract-spec' into docs/design-notes
hpmaxi Oct 20, 2026
3d20c03
docs(vault): say deposited capital counts money in and out, not value
hpmaxi Oct 21, 2026
868e458
docs: answer the review notes on the approved stack
hpmaxi Oct 22, 2026
69d69d3
Merge branch 'docs/contract-spec' into docs/design-notes
hpmaxi Oct 22, 2026
c11e148
docs: say what holders can do if the manager or the attester stops
hpmaxi Oct 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,10 @@ contracts. The addresses emitted by the deploy script are authoritative.
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.
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.
Expand Down
4 changes: 3 additions & 1 deletion contracts/async-vault/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,9 @@ strata_bindings::error_enum! {
/// 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.
/// 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,
Expand Down
4 changes: 2 additions & 2 deletions contracts/async-vault/src/governance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ pub(crate) fn set_notice(e: &Env, secs: u64) {
pub(crate) fn propose(e: &Env, change: VaultChange) -> u32 {
guards::not_winding_down(e);
guards::setup_finished(e);
guards::change_in_bounds(e, &change);
guards::valid_change(e, &change);
guards::not_a_second_code_change(e, &change);
let id = queue::propose::<AsyncVault, _>(e, &change).unwrap_or_else(|r| refuse(e, r));
ChangeProposed {
Expand All @@ -238,7 +238,7 @@ pub(crate) fn apply(e: &Env, id: u32) {
let change: VaultChange =
queue::take_ready::<AsyncVault, _>(e, id).unwrap_or_else(|r| refuse(e, r));
// The notice or the delay may have moved since the proposal; this check decides.
guards::change_in_bounds(e, &change);
guards::valid_change(e, &change);
let admin = access_control::get_admin(e)
.unwrap_or_else(|| panic_with_error!(e, VaultError::AdminRequired));
match &change {
Expand Down
14 changes: 8 additions & 6 deletions contracts/async-vault/src/guards.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,8 @@ pub(crate) fn wind_down_delay_in_bounds(e: &Env, secs: u64) {
}
}

/// An exit takes a full notice, so a delay shorter than one is not a window.
/// 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);
Expand All @@ -155,20 +156,21 @@ pub(crate) fn custodian_not_the_vault(e: &Env, custodian: &Address) {
}

/// A feed that cannot answer would stall every epoch after the swap.
pub(crate) fn feed_bound_here(e: &Env, feed: &Address) {
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);
}
}

/// The bounds a change must meet when it is proposed and again when it
/// applies.
pub(crate) fn change_in_bounds(e: &Env, change: &VaultChange) {
/// 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(e, feed),
VaultChange::Oracle(feed) => feed_bound_here_and_answering(e, feed),
VaultChange::Custodian(custodian) => custodian_not_the_vault(e, custodian),
VaultChange::Upgrade(_)
| VaultChange::GrantRole(..)
Expand Down
43 changes: 37 additions & 6 deletions contracts/async-vault/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,40 @@
//! # 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;
Expand Down Expand Up @@ -101,12 +138,6 @@ 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.
guards::roles_distinct(e, &roles);

access_control::set_admin(e, &roles.governance);
Expand Down
4 changes: 2 additions & 2 deletions contracts/async-vault/src/roles.rs
Original file line number Diff line number Diff line change
@@ -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");
Expand Down
54 changes: 53 additions & 1 deletion contracts/async-vault/src/test/compliance.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use soroban_sdk::{contract, contractimpl};
use stellar_tokens::rwa::compliance::ComplianceHook;
use stellar_tokens::rwa::compliance::{ComplianceError, ComplianceHook};

use super::*;

Expand Down Expand Up @@ -113,6 +113,58 @@ fn a_compliance_module_that_refuses_the_mint_blocks_a_priced_claim() {
);
}

#[test]
fn an_unbound_compliance_swap_lands_and_stops_every_move() {
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 = f.e.register(Compliance, (f.admin.clone(),));
let id = f
.share
.propose_change(&share_token::TokenChange::Compliance(unbound.clone()));
f.advance(MIN_UPGRADE_DELAY);
f.share.apply_change(&id);
assert_eq!(f.share.compliance(), unbound);

let token_not_bound = Some(Ok(ComplianceError::TokenNotBound.into()));
assert_eq!(
f.share.try_transfer(&holder, &investor, &1).err(),
token_not_bound
);
assert_eq!(
f.vault.try_claim_deposit(&investor, &epoch).err(),
token_not_bound
);
assert_eq!(
f.vault.try_claim_redeem(&holder, &epoch).err(),
token_not_bound
);
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);

let silent = Address::generate(&f.e);
let id = f
.share
.propose_change(&share_token::TokenChange::IdentityVerifier(silent.clone()));
f.advance(MIN_UPGRADE_DELAY);
f.share.apply_change(&id);
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();
Expand Down
78 changes: 78 additions & 0 deletions contracts/async-vault/src/test/deposit_cap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,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,
);
}
14 changes: 8 additions & 6 deletions contracts/async-vault/src/test/guards.rs
Original file line number Diff line number Diff line change
Expand Up @@ -281,9 +281,11 @@ fn custodian_not_the_vault_refuses_the_vault_itself() {
}

#[test]
fn feed_bound_here_refuses_an_unbound_feed_or_a_contract_that_is_not_a_feed() {
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(&f.e, &f.oracle.address));
holds(&f, || {
guards::feed_bound_here_and_answering(&f.e, &f.oracle.address)
});

let unbound = f.e.register(
NavOracleContract,
Expand All @@ -297,13 +299,13 @@ fn feed_bound_here_refuses_an_unbound_feed_or_a_contract_that_is_not_a_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(&f.e, &not_a_feed_here)
guards::feed_bound_here_and_answering(&f.e, &not_a_feed_here)
});
}
}

#[test]
fn change_in_bounds_checks_the_bounds_of_each_change() {
fn valid_change_checks_each_kind_of_change() {
let f = setup();
let stranger = Address::generate(&f.e);
for change in [
Expand All @@ -317,7 +319,7 @@ fn change_in_bounds_checks_the_bounds_of_each_change() {
VaultChange::Admin(stranger.clone()),
VaultChange::Oracle(f.oracle.address.clone()),
] {
holds(&f, || guards::change_in_bounds(&f.e, &change));
holds(&f, || guards::valid_change(&f.e, &change));
}

for (change, error) in [
Expand Down Expand Up @@ -346,7 +348,7 @@ fn change_in_bounds_checks_the_bounds_of_each_change() {
VaultError::InvalidCustodian,
),
] {
refuses(&f, error, || guards::change_in_bounds(&f.e, &change));
refuses(&f, error, || guards::valid_change(&f.e, &change));
}
}

Expand Down
47 changes: 46 additions & 1 deletion contracts/async-vault/src/test/wind_down.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use proptest::prelude::*;
use soroban_sdk::IntoVal;
use soroban_sdk::{contract, contracterror, contractimpl, panic_with_error, IntoVal};

use super::*;

Expand Down Expand Up @@ -409,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();
Expand Down
Loading
Loading