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
45 changes: 18 additions & 27 deletions 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::{ComplianceError, ComplianceHook};
use stellar_tokens::rwa::{compliance::ComplianceHook, RWA};

use super::*;

Expand Down Expand Up @@ -114,49 +114,40 @@ fn a_compliance_module_that_refuses_the_mint_blocks_a_priced_claim() {
}

#[test]
fn an_unbound_compliance_swap_lands_and_stops_every_move() {
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 = 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
);
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.vault.try_claim_deposit(&investor, &epoch).err(),
token_not_bound
f.share
.try_propose_change(&share_token::TokenChange::Compliance(unbound.address))
.err(),
Some(Ok(share_token::ShareTokenError::ComplianceNotBound.into()))
);
assert_eq!(
f.vault.try_claim_redeem(&holder, &epoch).err(),
token_not_bound
);
assert!(!f.vault.health().compliance_bound);

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);
let id = f
.share
.propose_change(&share_token::TokenChange::IdentityVerifier(silent.clone()));
f.advance(MIN_UPGRADE_DELAY);
f.share.apply_change(&id);
f.e.as_contract(&f.share.address, || {
RWA::set_identity_verifier(&f.e, &silent)
});
assert_eq!(f.share.identity_verifier(), silent);

refused(
Expand Down
5 changes: 5 additions & 0 deletions contracts/share-token/src/contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,9 @@ impl ShareToken {
///
/// # Errors
///
/// * `ComplianceNotBound` - The new compliance contract does not list
/// this token, or is not on the vault's clock 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.
Expand Down Expand Up @@ -182,6 +185,8 @@ impl ShareToken {
/// 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.
Expand Down
10 changes: 10 additions & 0 deletions contracts/share-token/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,5 +54,15 @@ strata_bindings::error_enum! {
/// 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 does not name this token's vault,
/// its setup is still open, or its `linked_tokens` leaves out this
/// token. Every mint, transfer and burn would fail, or its rules could
/// change without the vault's delay.
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,
}
}
3 changes: 3 additions & 0 deletions contracts/share-token/src/governance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ pub(crate) fn finish_setup(e: &Env) {
}

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::<ShareToken, _>(e, &change).unwrap_or_else(|r| refuse(e, r));
let (_, schedule) =
Expand All @@ -102,6 +103,8 @@ pub(crate) fn cancel(e: &Env, id: u32) {
pub(crate) fn apply(e: &Env, id: u32) {
let change: TokenChange =
queue::take_ready::<ShareToken, _>(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 {
Expand Down
53 changes: 52 additions & 1 deletion contracts/share-token/src/guards.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ use stellar_access::{
access_control::{self, AccessControlStorageKey},
role_transfer,
};
use stellar_tokens::rwa::{identity_verification::IdentityVerifierClient, RWA};
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;
Expand Down Expand Up @@ -61,6 +64,54 @@ fn upgrade_pending(e: &Env) -> bool {
})
}

/// 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(_) => {}
}
}

/// Rules that leave out this token stop every mint, transfer and burn; rules
/// off the vault's clock could change with no delay.
pub(crate) fn compliance_bound_here(e: &Env, rules: &Address) {
if !on_the_vaults_clock(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);
}
}

/// `contract` names this token's vault and its own setup has finished.
fn on_the_vaults_clock(e: &Env, contract: &Address) -> bool {
let binding = VaultBindingClient::new(e, contract);
setup::source(e)
.is_some_and(|vault| matches!(binding.try_vault(), Ok(Ok(Some(named))) if named == vault))
&& matches!(binding.try_setup_open(), Ok(Ok(false)))
}

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) {
Expand Down
7 changes: 4 additions & 3 deletions contracts/share-token/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,10 @@
//! - **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 compliance swap is not checked.** The new contract need not list this
//! token. The cost: if it does not, every mint, transfer and burn fails until
//! it binds the token, and the vault's `health()` shows it.
//! - **A swap must fit the vault.** New rules must list this token and 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: after
//! setup, an external module that reports no vault cannot be swapped in.

#![no_std]
mod contract;
Expand Down
Loading
Loading