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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ vault is allowlisted
share_token bound to compliance
nav_oracle bound to the vault, setup finished
vault setup finished
health {"allowlisted":true,"compliance_bound":true,"feed_valid":false,"freshness_covers_notice":true,"oracle_bound":true,"setup_open":false,"sole_token_manager":true,"token_bound":true}
health {"allowlisted":true,"compliance_bound":true,"feed_valid":false,"freshness_covers_notice":true,"oracle_bound":true,"setup_open":false,"sole_token_manager":true,"token_bound":true,"vault_unfrozen":true}
wrote deployed.local.json
```

Expand Down
3 changes: 3 additions & 0 deletions contracts/async-vault/src/governance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ pub(crate) struct Wiring {
/// Shares minted before setup would redeem against depositors' money.
pub no_shares_outstanding: bool,
pub allowlisted: bool,
pub vault_unfrozen: bool,
pub compliance_bound: bool,
}

Expand All @@ -78,6 +79,7 @@ impl Wiring {
&& self.sole_token_manager
&& self.no_shares_outstanding
&& self.allowlisted
&& self.vault_unfrozen
&& self.compliance_bound
}
}
Expand Down Expand Up @@ -168,6 +170,7 @@ pub(crate) fn wiring(e: &Env) -> Wiring {
),
_ => false,
},
vault_unfrozen: matches!(rwa.try_is_frozen(&me), Ok(Ok(false))),
compliance_bound: match rwa.try_compliance() {
Ok(Ok(compliance)) => matches!(
TokenBinderClient::new(e, &compliance).try_linked_tokens(),
Expand Down
2 changes: 2 additions & 0 deletions contracts/async-vault/src/health.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ pub struct Health {
pub oracle_bound: bool,
pub sole_token_manager: bool,
pub allowlisted: bool,
pub vault_unfrozen: bool,
pub compliance_bound: bool,
pub feed_valid: bool,
/// A report outlives the notice, so an epoch's cancellation window does
Expand All @@ -34,6 +35,7 @@ pub(crate) fn health(e: &Env) -> Health {
oracle_bound: wiring.oracle_bound,
sole_token_manager: wiring.sole_token_manager,
allowlisted: wiring.allowlisted,
vault_unfrozen: wiring.vault_unfrozen,
compliance_bound: wiring.compliance_bound,
feed_valid: matches!(feed.try_state(), Ok(Ok(OracleState::Valid))),
freshness_covers_notice: matches!(
Expand Down
8 changes: 8 additions & 0 deletions contracts/async-vault/src/test/health.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ fn all_healthy() -> Health {
oracle_bound: true,
sole_token_manager: true,
allowlisted: true,
vault_unfrozen: true,
compliance_bound: true,
feed_valid: true,
freshness_covers_notice: true,
Expand Down Expand Up @@ -74,6 +75,13 @@ fn each_missing_wiring_step_shows_in_its_own_flag() {
..all_healthy()
},
),
(
Miswire::VaultFrozen,
Health {
vault_unfrozen: false,
..all_healthy()
},
),
(
Miswire::ComplianceUnbound,
Health {
Expand Down
5 changes: 5 additions & 0 deletions contracts/async-vault/src/test/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ enum Miswire {
SecondManager,
PreMinted,
NotAllowlisted,
VaultFrozen,
ComplianceUnbound,
}

Expand Down Expand Up @@ -361,6 +362,10 @@ fn build<'a>(
if miswire != Miswire::NotAllowlisted {
identity.allow(&contract_id, &true, &compliance_authority);
}
if miswire == Miswire::VaultFrozen {
// Until `bind_vault`, the token does not know this address is its vault.
share.set_address_frozen(&contract_id, &true, &compliance_authority);
}
match miswire {
Miswire::TokenUnbound => {}
Miswire::TokenBoundElsewhere => {
Expand Down
2 changes: 2 additions & 0 deletions contracts/async-vault/src/test/setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ fn every_wiring_step_is_checked_before_setup_finishes() {
Miswire::SecondManager,
Miswire::PreMinted,
Miswire::NotAllowlisted,
Miswire::VaultFrozen,
Miswire::ComplianceUnbound,
] {
let f = miswired(miswire);
Expand Down Expand Up @@ -131,6 +132,7 @@ fn a_token_that_cannot_answer_fails_its_checks_instead_of_trapping() {
assert!(!health.token_bound);
assert!(!health.sole_token_manager);
assert!(!health.allowlisted);
assert!(!health.vault_unfrozen);
assert!(!health.compliance_bound);

refused(vault.try_finish_setup(&admin), VaultError::SetupIncomplete);
Expand Down
67 changes: 63 additions & 4 deletions contracts/share-token/src/contract.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use crate::error::ShareTokenError;
use crate::event::LegalTransfer;
use crate::governance::{self, TokenChange, TokenPendingChange};
use crate::roles::COMPLIANCE_ROLE;

Expand All @@ -9,12 +10,29 @@ use soroban_sdk::{
use stellar_access::access_control::{self as access_control, AccessControl};
use stellar_contract_utils::pausable::{self as pausable, Pausable};
use stellar_contract_utils::upgradeable;
use stellar_macros::{only_admin, only_any_role, only_role};
use stellar_macros::{only_admin, only_role};
use stellar_tokens::{
fungible::{Base, ContractOverrides, FungibleToken},
rwa::{RWAToken, RWA},
rwa::{identity_verification::IdentityVerifierClient, RWAToken, RWA},
};

/// Longest order reference a legal transfer accepts, in bytes.
pub const MAX_REFERENCE_LEN: u32 = 128;

/// The co-signing admin must be a different account from the operator.
fn require_admin_co_sign(e: &Env, operator: &Address) {
let admin = access_control::enforce_admin_auth(e);
if admin == *operator {
panic_with_error!(e, ShareTokenError::CoSignRequired);
}
}

fn refuse_vault_address(e: &Env, account: &Address) {
if timelock::setup::source(e).as_ref() == Some(account) {
panic_with_error!(e, ShareTokenError::VaultAddress);
}
}

#[contract]
pub struct ShareToken;

Expand Down Expand Up @@ -91,6 +109,36 @@ impl ShareToken {
pub fn pending_changes(e: &Env) -> Vec<TokenPendingChange> {
governance::pending(e)
}

/// Moves shares under a legal order, at once. The compliance authority
/// asks and the admin co-signs the same call. Never out of or into the
/// vault's address, and only to an account the identity verifier accepts.
#[only_role(operator, "compliance")]
pub fn legal_transfer(
e: &Env,
from: Address,
to: Address,
amount: i128,
reference: String,
operator: Address,
) {
storage::bump_instance(e);
require_admin_co_sign(e, &operator);
if reference.is_empty() || reference.len() > MAX_REFERENCE_LEN {
panic_with_error!(e, ShareTokenError::InvalidReference);
}
refuse_vault_address(e, &from);
refuse_vault_address(e, &to);
IdentityVerifierClient::new(e, &RWA::identity_verifier(e)).verify_identity(&to);
RWA::forced_transfer(e, &from, &to, amount);
LegalTransfer {
from,
to,
amount,
reference,
}
.publish(e);
}
}

#[contractimpl(contracttrait)]
Expand Down Expand Up @@ -201,12 +249,16 @@ impl RWAToken for ShareToken {
storage::bump_instance(e);
RWA::burn(e, &user_address, amount);
}
/// The vault escrows redemptions through it; compliance intervenes with it.
#[only_any_role(operator, ["manager", "compliance"])]
/// The vault escrows redemptions through it. Compliance moves shares with
/// `legal_transfer` instead.
#[only_role(operator, "manager")]
fn forced_transfer(e: &Env, from: Address, to: Address, amount: i128, operator: Address) {
storage::bump_instance(e);
RWA::forced_transfer(e, &from, &to, amount);
}
/// Moves a whole balance to the account the identity verifier names, so
/// it needs the admin's co-signature like a legal transfer, and never
/// moves shares out of or into the vault's address.
#[only_role(operator, "compliance")]
fn recover_balance(
e: &Env,
Expand All @@ -215,16 +267,23 @@ impl RWAToken for ShareToken {
operator: Address,
) -> bool {
storage::bump_instance(e);
require_admin_co_sign(e, &operator);
refuse_vault_address(e, &old_account);
refuse_vault_address(e, &new_account);
RWA::recover_balance(e, &old_account, &new_account)
}
#[only_role(operator, "compliance")]
fn set_address_frozen(e: &Env, user_address: Address, freeze: bool, operator: Address) {
storage::bump_instance(e);
if freeze {
refuse_vault_address(e, &user_address);
}
RWA::set_address_frozen(e, &user_address, freeze);
}
#[only_role(operator, "compliance")]
fn freeze_partial_tokens(e: &Env, user_address: Address, amount: i128, operator: Address) {
storage::bump_instance(e);
refuse_vault_address(e, &user_address);
RWA::freeze_partial_tokens(e, &user_address, amount);
}
#[only_role(operator, "compliance")]
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 @@ -35,6 +35,16 @@ pub enum ShareTokenError {
DelayBelowNotice = 7010,
/// An upgrade already waits. Cancel it before proposing another.
UpgradeAlreadyPending = 7011,
/// The vault's own address cannot be frozen, and a legal transfer or a
/// recovery never moves shares out of it or into it: its balance is other
/// investors' escrow.
VaultAddress = 7012,
/// A legal transfer or a recovery needs the compliance authority and the
/// admin as two different accounts, so no single key moves value.
CoSignRequired = 7013,
/// A legal transfer names the order behind it: a reference of 1 to 128
/// bytes.
InvalidReference = 7015,
/// 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
12 changes: 11 additions & 1 deletion contracts/share-token/src/event.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use soroban_sdk::{contractevent, Address};
use soroban_sdk::{contractevent, Address, String};

use crate::governance::TokenChange;

Expand Down Expand Up @@ -31,3 +31,13 @@ pub struct VaultBound {
#[topic]
pub vault: Address,
}

#[contractevent]
pub struct LegalTransfer {
#[topic]
pub from: Address,
#[topic]
pub to: Address,
pub amount: i128,
pub reference: String,
}
4 changes: 3 additions & 1 deletion contracts/share-token/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ mod governance;
mod roles;
pub use contract::*;
pub use error::ShareTokenError;
pub use event::{ChangeApplied, ChangeCancelled, ChangeProposed, SetupFinished, VaultBound};
pub use event::{
ChangeApplied, ChangeCancelled, ChangeProposed, LegalTransfer, SetupFinished, VaultBound,
};
pub use governance::{TokenChange, TokenPendingChange, ADMIN_ACCEPT_LEDGERS};

#[cfg(test)]
Expand Down
7 changes: 4 additions & 3 deletions contracts/share-token/src/roles.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
use soroban_sdk::{symbol_short, Symbol};

/// Role for allowlist-driven interventions: freeze, forced transfer, recovery.
/// Held by the compliance authority. Minting, burning and escrow use the
/// `manager` role, which governance grants to the vault.
/// Role for allowlist-driven interventions: freezes, plus legal transfers and
/// recovery with the admin's co-signature. Held by the compliance authority.
/// Minting, burning and escrow use the `manager` role, which governance grants
/// to the vault.
pub const COMPLIANCE_ROLE: &str = "compliance";

/// The vault's role. Without it the vault cannot mint or burn, so no claim pays.
Expand Down
Loading
Loading