Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions contracts/async-vault/src/epoch.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use bindings::{OracleFeedClient, OracleState};
use soroban_sdk::{panic_with_error, Env};
use stellar_contract_utils::pausable;

use crate::error::VaultError;
use crate::event::{EpochClosed, EpochFulfilled};
Expand Down Expand Up @@ -49,11 +50,12 @@ 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 {
Expand Down
45 changes: 45 additions & 0 deletions contracts/async-vault/src/test/cancel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -293,3 +293,48 @@ fn a_sealed_epoch_cancels_while_the_nav_is_paused() {
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);
}
12 changes: 9 additions & 3 deletions docs/kit-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,20 @@ The contracts enforce a set of mathematical and state-machine invariants across
- `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_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.
- 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`).
- `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`.
- 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.
Expand Down
2 changes: 1 addition & 1 deletion docs/strata-product-and-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ batch boundary, though not the price it receives.

- Request: moves the deposit asset into escrow; at most one active request per controller. The vault first asks the share token's identity verifier and refuses an address 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.
- 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.
- 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.

#### Redemption
Expand Down
Loading