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
4 changes: 4 additions & 0 deletions contracts/async-vault/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,10 @@ pub enum VaultError {
/// The proposed feed does not name this vault as its own, or its setup is
/// still open. Its admin could then change its limits with no delay.
FeedNotBound = 6072,
/// The controller may not hold 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.
Expand Down
5 changes: 3 additions & 2 deletions contracts/async-vault/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -391,8 +391,9 @@ impl AsyncVault {
}

/// Cancels an unpriced redemption. Outside a wind-down the shares come
/// back, so a delisted or frozen controller is refused; in one the escrow
/// joins the controller's entitlement, and a frozen controller is held.
/// back, even while the token is paused, unless the controller is delisted,
/// frozen or refused by the compliance rules; in one the escrow joins the
/// controller's entitlement, and a frozen controller is held.
pub fn cancel_redeem(e: &Env, from: Address, epoch_id: u64) -> i128 {
redeem::cancel(e, &from, epoch_id)
}
Expand Down
29 changes: 21 additions & 8 deletions contracts/async-vault/src/redeem.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
use soroban_sdk::{panic_with_error, token::TokenClient, Address, Env};
use stellar_tokens::{fungible::FungibleTokenClient, rwa::RWATokenClient};
use stellar_tokens::{
fungible::FungibleTokenClient,
rwa::{compliance::ComplianceClient as RulesClient, RWATokenClient},
};

use crate::epoch;
use crate::error::VaultError;
use crate::event::{RedeemCancelled, RedeemClaimed, RedeemRequested, RedemptionSurrendered};
use crate::keys::DataKey;
use crate::lock_policy::{self, Decision, Exit};
use crate::lock_policy::{self, Decision, Exit, Standing};
use crate::pricing::{Pricing, PricingScheme};
use crate::settlement;
use crate::state::{self, EpochStatus, RedeemRequest};
Expand Down Expand Up @@ -182,19 +185,15 @@ pub(crate) fn queue_escrowed(e: &Env, controller: &Address, shares: i128) -> u64
epoch_id
}

/// Moves escrowed shares back to the controller and clears the request. Both
/// callers ask the lock policy first; a delisted controller still meets the
/// token's own checked transfer here.
/// Moves escrowed shares back to the controller and clears the request.
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 = state::reduce(e, epoch.total_shares_redeeming, shares);
state::set_epoch(e, epoch_id, &epoch);
state::remove_redeem_request(e, epoch_id, controller);

let vault = e.current_contract_address();
FungibleTokenClient::new(e, &state::get_addr(e, &DataKey::ShareToken))
.transfer(&vault, controller, &shares);
deliver(e, controller, shares);

RedeemCancelled {
controller: controller.clone(),
Expand All @@ -204,6 +203,20 @@ fn return_shares(e: &Env, controller: &Address, epoch_id: u64, shares: i128) {
.publish(e);
}

/// Checks the receiver, then moves as manager, so the vault's listing and token pause never apply.
fn deliver(e: &Env, controller: &Address, shares: i128) {
let vault = e.current_contract_address();
let token = state::get_addr(e, &DataKey::ShareToken);
let share = RWATokenClient::new(e, &token);
let rules = RulesClient::new(e, &share.compliance());
if lock_policy::standing(e, controller) != Standing::Listed
|| !rules.can_transfer(&vault, controller, &shares, &token)
{
panic_with_error!(e, VaultError::HolderNotEligible);
}
share.forced_transfer(&vault, controller, &shares, &vault);
}

pub(crate) fn cancel(e: &Env, from: &Address, epoch_id: u64) -> i128 {
from.require_auth();

Expand Down
5 changes: 2 additions & 3 deletions contracts/async-vault/src/test/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ mod pricing;
mod redeem;
mod settlement;
mod setup;
mod share_returns;
mod supply;
mod timing;
mod treasury;
Expand Down Expand Up @@ -370,9 +371,7 @@ fn build<'a>(
share.mint(&holder, &100, &admin);
share.revoke_role(&admin, &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.
// Converting a delisted holder's deposit mints into the vault's own escrow.
if miswire != Miswire::NotAllowlisted {
identity.allow(&contract_id, &true, &compliance_authority);
}
Expand Down
6 changes: 3 additions & 3 deletions contracts/async-vault/src/test/settlement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,9 +166,9 @@ fn a_request_that_cannot_settle_keeps_its_epoch_open() {
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)))
refused(
f.vault.try_claim_redeem(&a, &epoch),
VaultError::HolderNotEligible,
);
assert_eq!(f.vault.claim_redeem(&b, &epoch), 1);

Expand Down
138 changes: 138 additions & 0 deletions contracts/async-vault/src/test/share_returns.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
use soroban_sdk::{contract, contractimpl};
use stellar_contract_utils::pausable::PausableError;
use stellar_tokens::rwa::compliance::ComplianceHook;

use super::*;

#[contract]
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, &f.compliance);
assert!(!f.vault.health().allowlisted);

assert_eq!(f.vault.cancel_redeem(&holder, &epoch), 200);
assert_eq!(f.shares(&holder), 500);
}

#[test]
fn a_delisted_controller_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, &f.compliance);

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_controller_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_controller_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, &f.compliance);

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());
}
6 changes: 3 additions & 3 deletions docs/AUTHORIZATION_MATRIX.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ 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 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.
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:
Expand Down Expand Up @@ -107,7 +107,7 @@ Tests: [`controls.rs`](../contracts/async-vault/src/test/controls.rs), [`epochs.

### 5.2 ShareToken (`contracts/share-token`)

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. 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 `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. 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) and [`contracts/share-token/src/test_governance.rs`](../contracts/share-token/src/test_governance.rs).

Expand Down
Loading
Loading