From 413625137faa1f4d069675f2cf613a13b803d184 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:54:05 -0300 Subject: [PATCH 1/5] feat(vault): reconcile an epoch once it settles --- contracts/async-vault/src/deposit.rs | 17 +- contracts/async-vault/src/epoch.rs | 10 +- contracts/async-vault/src/error.rs | 3 + contracts/async-vault/src/event.rs | 8 + contracts/async-vault/src/keys.rs | 1 + contracts/async-vault/src/lib.rs | 13 +- contracts/async-vault/src/redeem.rs | 11 +- contracts/async-vault/src/settlement.rs | 115 +++++++++ contracts/async-vault/src/state.rs | 9 + contracts/async-vault/src/test/events.rs | 26 +- contracts/async-vault/src/test/mod.rs | 1 + contracts/async-vault/src/test/multi_epoch.rs | 16 +- contracts/async-vault/src/test/settlement.rs | 236 ++++++++++++++++++ contracts/async-vault/src/wind_down.rs | 6 +- 14 files changed, 443 insertions(+), 29 deletions(-) create mode 100644 contracts/async-vault/src/settlement.rs create mode 100644 contracts/async-vault/src/test/settlement.rs diff --git a/contracts/async-vault/src/deposit.rs b/contracts/async-vault/src/deposit.rs index 109ac15b..64a3d5bb 100644 --- a/contracts/async-vault/src/deposit.rs +++ b/contracts/async-vault/src/deposit.rs @@ -7,6 +7,7 @@ use crate::event::{DepositCancelled, DepositClaimed, DepositRequested}; use crate::keys::DataKey; use crate::lock_policy::{self, Decision, Exit}; use crate::pricing::{Pricing, PricingScheme}; +use crate::settlement; use crate::state::{self, DepositRequest, EpochStatus}; use crate::treasury; use crate::wind_down; @@ -66,6 +67,7 @@ pub(crate) fn request(e: &Env, from: &Address, amount: i128) -> u64 { ); state::set_epoch(e, epoch_id, &epoch); state::set_cancellable_escrow(e, state::cancellable_escrow(e) + amount); + 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); @@ -107,12 +109,14 @@ pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 { if treasury::free_reserve(e) < request.amount { panic_with_error!(e, VaultError::ClaimNotCovered); } - return refund_deposit(e, caller, epoch_id, request.amount); + refund_deposit(e, caller, 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_pending_mint_shares(e, state::reduce(e, state::pending_mint_shares(e), shares)); let share_token = state::get_addr(e, &DataKey::ShareToken); RWATokenClient::new(e, &share_token).mint(caller, &shares, &e.current_contract_address()); @@ -124,6 +128,7 @@ pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 { shares, } .publish(e); + settlement::on_deposit_claimed(e, epoch_id, shares); shares } @@ -133,7 +138,7 @@ pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 { fn refund_deposit(e: &Env, controller: &Address, epoch_id: u64, amount: i128) -> 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); @@ -171,7 +176,11 @@ pub(crate) fn cancel(e: &Env, from: &Address, epoch_id: u64) -> i128 { let request = state::get_deposit_request(e, epoch_id, from) .unwrap_or_else(|| panic_with_error!(e, VaultError::RequestNotFound)); - state::set_cancellable_escrow(e, state::cancellable_escrow(e) - 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, from, epoch_id, request.amount); request.amount } diff --git a/contracts/async-vault/src/epoch.rs b/contracts/async-vault/src/epoch.rs index ce915f3e..b675e025 100644 --- a/contracts/async-vault/src/epoch.rs +++ b/contracts/async-vault/src/epoch.rs @@ -5,6 +5,7 @@ use crate::error::VaultError; use crate::event::{EpochClosed, EpochFulfilled}; 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; @@ -108,8 +109,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 +126,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 +141,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 ca0624f9..7f84ef75 100644 --- a/contracts/async-vault/src/error.rs +++ b/contracts/async-vault/src/error.rs @@ -118,4 +118,7 @@ pub enum VaultError { /// The depositor fails the share token's identity check, so the vault /// could never deliver the shares. Refused before any cash moves. NotAllowlisted = 6068, + /// A running total would go below zero, which means the vault's books + /// disagree with themselves. Fails loudly instead of clamping to zero. + AccountingMismatch = 6070, } diff --git a/contracts/async-vault/src/event.rs b/contracts/async-vault/src/event.rs index d7bae7b1..3d76cbc5 100644 --- a/contracts/async-vault/src/event.rs +++ b/contracts/async-vault/src/event.rs @@ -149,6 +149,14 @@ pub struct WindDownClaimed { pub assets: 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/keys.rs b/contracts/async-vault/src/keys.rs index e35fed58..0f6d1151 100644 --- a/contracts/async-vault/src/keys.rs +++ b/contracts/async-vault/src/keys.rs @@ -24,4 +24,5 @@ pub(crate) enum DataKey { WindDownOwed, WindDownPosition(Address), DepositCap, + Settlement(u64), } diff --git a/contracts/async-vault/src/lib.rs b/contracts/async-vault/src/lib.rs index 6290ae00..7d3173ee 100644 --- a/contracts/async-vault/src/lib.rs +++ b/contracts/async-vault/src/lib.rs @@ -10,6 +10,7 @@ mod lock_policy; mod pricing; mod redeem; mod roles; +mod settlement; mod state; mod timing; mod treasury; @@ -31,13 +32,14 @@ use state::FIRST_EPOCH; pub use error::VaultError; pub use event::{ ChangeApplied, ChangeCancelled, ChangeProposed, CustodianSet, Deployed, DepositCapUpdated, - DepositClaimed, DepositRequested, EpochClosed, EpochFulfilled, Funded, NoticeSet, - RedeemClaimed, RedeemRequested, WindDownActivated, WindDownClaimed, WindDownDelaySet, - WindDownProposalCancelled, WindDownProposed, WindDownRoundFinalized, + DepositClaimed, DepositRequested, EpochClosed, EpochFulfilled, EpochReconciled, Funded, + NoticeSet, RedeemClaimed, RedeemRequested, WindDownActivated, WindDownClaimed, + WindDownDelaySet, WindDownProposalCancelled, WindDownProposed, WindDownRoundFinalized, }; pub use governance::{VaultChange, VaultPendingChange}; pub use pricing::{DirectUnitPricing, PricingScheme}; pub use roles::VaultRoles; +pub use settlement::EpochSettlement; pub use state::{DepositRequest, EpochInfo, EpochStatus, RedeemRequest}; pub use upgrade::{MAX_UPGRADE_DELAY, MIN_UPGRADE_DELAY}; pub use wind_down::{WindDownInfo, WindDownPosition, WindDownStatus, MAX_WIND_DOWN_DELAY}; @@ -323,6 +325,11 @@ impl AsyncVault { state::get_epoch(e, epoch_id) } + /// What pricing promised the epoch and what has been paid against it. + pub fn get_epoch_settlement(e: &Env, epoch_id: u64) -> Option { + settlement::get(e, epoch_id) + } + pub fn get_deposit_request( e: &Env, epoch_id: u64, diff --git a/contracts/async-vault/src/redeem.rs b/contracts/async-vault/src/redeem.rs index c8b51d14..d2c19699 100644 --- a/contracts/async-vault/src/redeem.rs +++ b/contracts/async-vault/src/redeem.rs @@ -6,6 +6,7 @@ use crate::error::VaultError; use crate::event::{RedeemCancelled, RedeemClaimed, RedeemRequested}; use crate::keys::DataKey; use crate::pricing::{Pricing, PricingScheme}; +use crate::settlement; use crate::state::{self, EpochStatus, RedeemRequest}; use crate::treasury; use crate::wind_down; @@ -42,6 +43,7 @@ pub(crate) fn request(e: &Env, from: &Address, shares: i128) -> u64 { }, ); 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); @@ -79,11 +81,12 @@ pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 { 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); + settlement::on_redeem_claimed(e, epoch_id, 0); return 0; } @@ -98,7 +101,7 @@ pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 { request.claimed = true; state::set_redeem_request(e, epoch_id, caller, &request); - state::set_committed(e, state::committed(e).saturating_sub(assets)); + state::set_committed(e, state::reduce(e, state::committed(e), assets)); TokenClient::new(e, &asset).transfer(&vault, caller, &assets); RWATokenClient::new(e, &state::get_addr(e, &DataKey::ShareToken)).burn( @@ -114,6 +117,7 @@ pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 { assets, } .publish(e); + settlement::on_redeem_claimed(e, epoch_id, assets); assets } @@ -125,7 +129,7 @@ pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 { fn return_shares(e: &Env, controller: &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 -= shares; + 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, controller); @@ -163,5 +167,6 @@ pub(crate) fn cancel(e: &Env, from: &Address, epoch_id: u64) -> i128 { .unwrap_or_else(|| panic_with_error!(e, VaultError::RequestNotFound)); return_shares(e, from, epoch_id, request.shares); + settlement::on_redeem_cancelled(e, epoch_id); request.shares } diff --git a/contracts/async-vault/src/settlement.rs b/contracts/async-vault/src/settlement.rs new file mode 100644 index 00000000..6baeb408 --- /dev/null +++ b/contracts/async-vault/src/settlement.rs @@ -0,0 +1,115 @@ +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 { + 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) { + storage::set_persistent(e, &DataKey::Settlement(epoch), s); +} + +fn close_one(e: &Env, open: u32) -> u32 { + open.checked_sub(1) + .unwrap_or_else(|| panic_with_error!(e, VaultError::AccountingMismatch)) +} + +pub(crate) fn on_deposit_requested(e: &Env, epoch: u64) { + let mut s = load(e, epoch); + s.deposits_open += 1; + 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 += 1; + 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 += 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 += 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..64a1419f 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 { diff --git a/contracts/async-vault/src/test/events.rs b/contracts/async-vault/src/test/events.rs index 38caa3bf..39fdb963 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() { @@ -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 { + controller: 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/mod.rs b/contracts/async-vault/src/test/mod.rs index cac8a8d7..38d48534 100644 --- a/contracts/async-vault/src/test/mod.rs +++ b/contracts/async-vault/src/test/mod.rs @@ -13,6 +13,7 @@ mod notice; mod oracle_pricing; mod pricing; mod redeem; +mod settlement; mod supply; mod timing; mod treasury; diff --git a/contracts/async-vault/src/test/multi_epoch.rs b/contracts/async-vault/src/test/multi_epoch.rs index 3bbe2c0c..a82c70ff 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,8 +72,10 @@ 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 diff --git a/contracts/async-vault/src/test/settlement.rs b/contracts/async-vault/src/test/settlement.rs new file mode 100644 index 00000000..f445598a --- /dev/null +++ b/contracts/async-vault/src/test/settlement.rs @@ -0,0 +1,236 @@ +use proptest::prelude::*; +use soroban_sdk::{testutils::Events as _, Event as _}; + +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 { + controller: 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)); +} + +/// 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, &f.compliance); + + assert_eq!( + f.vault.try_claim_redeem(&a, &epoch).err(), + Some(Ok(soroban_sdk::Error::from_contract_error(304))) + ); + 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); +} + +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(1i128..5_000, 1..5), + cancels in proptest::collection::vec(any::(), 5), + redeems in proptest::collection::vec(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/wind_down.rs b/contracts/async-vault/src/wind_down.rs index 028faf79..ca2aed10 100644 --- a/contracts/async-vault/src/wind_down.rs +++ b/contracts/async-vault/src/wind_down.rs @@ -279,7 +279,11 @@ pub(crate) fn claim(e: &Env, holder: &Address) -> i128 { } if assets > 0 { - storage::set_instance(e, &DataKey::WindDownOwed, &(owed(e) - assets)); + 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); } From e541600436c542351368061d7d76b07df4195cbe Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:56:52 -0300 Subject: [PATCH 2/5] test(vault): pin why wind-down dust stays owed --- contracts/async-vault/src/test/wind_down.rs | 45 +++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/contracts/async-vault/src/test/wind_down.rs b/contracts/async-vault/src/test/wind_down.rs index e141597d..ec14bc30 100644 --- a/contracts/async-vault/src/test/wind_down.rs +++ b/contracts/async-vault/src/test/wind_down.rs @@ -618,3 +618,48 @@ fn propose_wind_down_reports_active_when_active() { .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.admin); + 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.admin); + 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()); +} From a966d6d2ffa2bf5dbdc3f0ec43756884669f037f Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:01:33 -0300 Subject: [PATCH 3/5] docs: describe epoch settlement --- docs/TESTING.md | 3 ++- docs/kit-spec.md | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/TESTING.md b/docs/TESTING.md index efb3799d..893b3e4f 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -80,8 +80,9 @@ so time rules and TTL can be reached in one test. | 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` | +| 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 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` | | The oracle publishes the valuation it stored | [`contracts/nav-oracle/src/test.rs`](../contracts/nav-oracle/src/test.rs) | `attesting_publishes_the_stored_valuation` | ## Calls between contracts diff --git a/docs/kit-spec.md b/docs/kit-spec.md index d1bb1271..e294fd4c 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -68,6 +68,8 @@ 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 that epoch's remainder stays in `pending_mint_shares` and `committed`. Two cases: a zero-asset redemption whose holder the share token refuses, and a priced deposit whose mint a compliance module refuses (`a_request_that_cannot_settle_keeps_its_epoch_open`). The remainder is at most a few units per epoch, and it is still owed to nobody. ### Invariant 8: Bounded Governance Timelocks - **Definition**: Upgrades and wind-downs cannot execute without advance notice, and delays cannot be set to invalid boundaries. @@ -86,6 +88,7 @@ The contracts enforce a set of mathematical and state-machine invariants across - **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. +- Each payout floors per holder, so after every holder has claimed, `wind_down_owed` keeps at most one unit per holder. 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`). --- From 8f3a6c357b9c10fe76869e4d59088857212d9171 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:46:54 -0300 Subject: [PATCH 4/5] fix(vault): address settlement review round 1 --- contracts/async-vault/src/deposit.rs | 4 +- contracts/async-vault/src/test/compliance.rs | 2 +- contracts/async-vault/src/test/settlement.rs | 30 +++++++++++++- contracts/async-vault/src/test/wind_down.rs | 42 ++++++++++++++++++++ docs/TESTING.md | 2 +- docs/kit-spec.md | 6 ++- 6 files changed, 77 insertions(+), 9 deletions(-) diff --git a/contracts/async-vault/src/deposit.rs b/contracts/async-vault/src/deposit.rs index 64a3d5bb..a3b18672 100644 --- a/contracts/async-vault/src/deposit.rs +++ b/contracts/async-vault/src/deposit.rs @@ -135,7 +135,7 @@ pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 { /// 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, controller: &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 = state::reduce(e, epoch.total_deposited, amount); @@ -151,8 +151,6 @@ fn refund_deposit(e: &Env, controller: &Address, epoch_id: u64, amount: i128) -> amount, } .publish(e); - - 0 } pub(crate) fn cancel(e: &Env, from: &Address, epoch_id: u64) -> i128 { diff --git a/contracts/async-vault/src/test/compliance.rs b/contracts/async-vault/src/test/compliance.rs index 16dd192a..ff242647 100644 --- a/contracts/async-vault/src/test/compliance.rs +++ b/contracts/async-vault/src/test/compliance.rs @@ -97,7 +97,7 @@ fn a_holder_delisted_after_pricing_can_only_leave_by_being_relisted() { } #[contract] -struct RefusesEveryMint; +pub(super) struct RefusesEveryMint; #[contractimpl] impl RefusesEveryMint { diff --git a/contracts/async-vault/src/test/settlement.rs b/contracts/async-vault/src/test/settlement.rs index f445598a..fc6beaeb 100644 --- a/contracts/async-vault/src/test/settlement.rs +++ b/contracts/async-vault/src/test/settlement.rs @@ -1,5 +1,6 @@ use proptest::prelude::*; use soroban_sdk::{testutils::Events as _, Event as _}; +use stellar_tokens::rwa::compliance::ComplianceHook; use super::*; use crate::{EpochFulfilled, EpochReconciled, RedeemClaimed}; @@ -177,6 +178,31 @@ fn a_request_that_cannot_settle_keeps_its_epoch_open() { 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. The epoch stays +/// open and its remainder is never reconciled, the same as a refused +/// redemption. +#[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, ()); + ComplianceClient::new(&f.e, &f.share.compliance()).add_module_to( + &ComplianceHook::CanCreate, + &module, + &f.admin, + ); + + assert!(f.vault.try_claim_deposit(&investor, &epoch).is_err()); + + let s = f.vault.get_epoch_settlement(&epoch).unwrap(); + assert_eq!(s.deposits_open, 1); + assert!(!s.reconciled); +} + proptest! { #![proptest_config(ProptestConfig::with_cases(24))] @@ -185,9 +211,9 @@ proptest! { /// every request settles. #[test] fn a_settled_epoch_leaves_no_remainder_in_the_totals( - deposits in proptest::collection::vec(1i128..5_000, 1..5), + 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(1i128..500, 0..4), + 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), ) { diff --git a/contracts/async-vault/src/test/wind_down.rs b/contracts/async-vault/src/test/wind_down.rs index ec14bc30..73056f01 100644 --- a/contracts/async-vault/src/test/wind_down.rs +++ b/contracts/async-vault/src/test/wind_down.rs @@ -1,3 +1,5 @@ +use proptest::prelude::*; + use super::*; #[test] @@ -663,3 +665,43 @@ fn after_every_holder_claims_owed_keeps_at_most_one_unit_per_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.admin); + 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); + } +} diff --git a/docs/TESTING.md b/docs/TESTING.md index 893b3e4f..4d700632 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -82,7 +82,7 @@ so time rules and TTL can be reached in one test. | --- | --- | --- | | 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` | -| 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 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` | +| 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 | [`contracts/nav-oracle/src/test.rs`](../contracts/nav-oracle/src/test.rs) | `attesting_publishes_the_stored_valuation` | ## Calls between contracts diff --git a/docs/kit-spec.md b/docs/kit-spec.md index e294fd4c..fb0b48af 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -69,7 +69,9 @@ The contracts enforce a set of mathematical and state-machine invariants across - **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 that epoch's remainder stays in `pending_mint_shares` and `committed`. Two cases: a zero-asset redemption whose holder the share token refuses, and a priced deposit whose mint a compliance module refuses (`a_request_that_cannot_settle_keeps_its_epoch_open`). The remainder is at most a few units per epoch, and it is still owed to nobody. +- **A request that can never settle** keeps its epoch open, so that epoch's remainder — fewer units than it has open requests of that kind — never leaves `pending_mint_shares` and `committed`. Two cases: a zero-asset redemption whose holder the share token refuses (`a_request_that_cannot_settle_keeps_its_epoch_open`), and a priced deposit whose mint a compliance module refuses (`a_refused_mint_keeps_a_priced_deposit_epoch_open`). It is still owed to nobody. + +**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 must therefore be deployed fresh, or reached by upgrade only once no request is open anywhere in the vault. ### Invariant 8: Bounded Governance Timelocks - **Definition**: Upgrades and wind-downs cannot execute without advance notice, and delays cannot be set to invalid boundaries. @@ -88,7 +90,7 @@ The contracts enforce a set of mathematical and state-machine invariants across - **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. -- Each payout floors per holder, so after every holder has claimed, `wind_down_owed` keeps at most one unit per holder. 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`). +- 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`). --- From 4721abe06026cc697bcad0be745582352f5a197b Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 02:09:24 -0300 Subject: [PATCH 5/5] fix(vault): correct refused-mint settlement wording --- contracts/async-vault/src/test/settlement.rs | 9 ++++++--- docs/kit-spec.md | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/contracts/async-vault/src/test/settlement.rs b/contracts/async-vault/src/test/settlement.rs index fc6beaeb..398b03b5 100644 --- a/contracts/async-vault/src/test/settlement.rs +++ b/contracts/async-vault/src/test/settlement.rs @@ -179,9 +179,10 @@ fn a_request_that_cannot_settle_keeps_its_epoch_open() { } /// The other case that can never settle: a compliance module refuses a -/// priced deposit's mint even for an allowlisted investor. The epoch stays -/// open and its remainder is never reconciled, the same as a refused -/// redemption. +/// 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(); @@ -198,7 +199,9 @@ fn a_refused_mint_keeps_a_priced_deposit_epoch_open() { 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); } diff --git a/docs/kit-spec.md b/docs/kit-spec.md index fb0b48af..583ea7dc 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -69,7 +69,7 @@ The contracts enforce a set of mathematical and state-machine invariants across - **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, so that epoch's remainder — fewer units than it has open requests of that kind — never leaves `pending_mint_shares` and `committed`. Two cases: a zero-asset redemption whose holder the share token refuses (`a_request_that_cannot_settle_keeps_its_epoch_open`), and a priced deposit whose mint a compliance module refuses (`a_refused_mint_keeps_a_priced_deposit_epoch_open`). It is still owed to nobody. +- **A request that can never settle** keeps its epoch open, and the two cases leave different amounts behind. A zero-asset redemption whose holder the share token refuses (`a_request_that_cannot_settle_keeps_its_epoch_open`) leaves only the epoch's ordinary rounding remainder in `committed` — fewer units than the epoch has open requests of that kind — 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. **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 must therefore be deployed fresh, or reached by upgrade only once no request is open anywhere in the vault.