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: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ vault holds share_token manager role
share_token bound to the vault, setup finished
vault is allowlisted
share_token bound to compliance
compliance bound to the vault, setup finished
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,"vault_unfrozen":true}
Expand Down
19 changes: 15 additions & 4 deletions contracts/async-vault/src/governance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,20 @@ pub(crate) fn bound_here(e: &Env, contract: &Address) -> bool {
&& matches!(binding.try_setup_open(), Ok(Ok(false)))
}

fn compliance_bound(e: &Env, compliance: &Address, token: &Address) -> bool {
let linked = matches!(
TokenBinderClient::new(e, compliance).try_linked_tokens(),
Ok(Ok(linked)) if linked.contains(token)
);
let binding = VaultBindingClient::new(e, compliance);
let on_this_clock = match (binding.try_vault(), binding.try_setup_open()) {
(Ok(Ok(vault)), Ok(Ok(open))) => vault == Some(e.current_contract_address()) && !open,
// An external module answers no binding view; its governance is a trust assumption.
_ => true,
};
linked && on_this_clock
}

pub(crate) fn wiring(e: &Env) -> Wiring {
let me = e.current_contract_address();
let token = state::get_addr(e, &DataKey::ShareToken);
Expand Down Expand Up @@ -172,10 +186,7 @@ pub(crate) fn wiring(e: &Env) -> Wiring {
},
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(),
Ok(Ok(linked)) if linked.contains(&token)
),
Ok(Ok(compliance)) => compliance_bound(e, &compliance, &token),
_ => false,
},
}
Expand Down
6 changes: 1 addition & 5 deletions contracts/async-vault/src/test/compliance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,11 +101,7 @@ fn a_compliance_module_that_refuses_the_mint_blocks_a_priced_claim() {
f.fulfill_epoch(wad(2));

let module = f.e.register(RefusesEveryMint, ());
ComplianceClient::new(&f.e, &f.share.compliance()).add_module_to(
&ComplianceHook::CanCreate,
&module,
&f.admin,
);
f.add_compliance_module(ComplianceHook::CanCreate, &module);

assert_eq!(
f.vault.try_claim_deposit(&investor, &epoch).err(),
Expand Down
14 changes: 14 additions & 0 deletions contracts/async-vault/src/test/health.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,20 @@ fn each_missing_wiring_step_shows_in_its_own_flag() {
..all_healthy()
},
),
(
Miswire::ComplianceBoundElsewhere,
Health {
compliance_bound: false,
..all_healthy()
},
),
(
Miswire::ComplianceSetupOpen,
Health {
compliance_bound: false,
..all_healthy()
},
),
];
for (miswire, expected) in cases {
let f = miswired(miswire);
Expand Down
28 changes: 27 additions & 1 deletion contracts/async-vault/src/test/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,13 @@ pub(crate) use soroban_sdk::{
Address, BytesN, Env, String,
};

pub(crate) use ::compliance::{Compliance, ComplianceClient};
pub(crate) use ::compliance::{Compliance, ComplianceChange, ComplianceClient};
pub(crate) use identity_verifier::{IdentityVerifier, IdentityVerifierClient};
pub(crate) use nav_oracle::{NavOracleContract, NavOracleContractClient, NavReport, OracleConfig};
pub(crate) use share_token::{ShareToken, ShareTokenClient};

pub(crate) use stellar_contract_utils::math::wad::WAD_SCALE;
use stellar_tokens::rwa::compliance::ComplianceHook;
pub(crate) use timelock::DAY_DURATION;

pub(crate) use crate::{
Expand Down Expand Up @@ -169,6 +170,17 @@ impl Fixture<'_> {
self.vault.deploy_to_custodian(&self.treasury, &free);
}
}

/// After setup, a module lands only through the compliance contract's timelock.
fn add_compliance_module(&self, hook: ComplianceHook, module: &Address) {
let rules = ComplianceClient::new(&self.e, &self.share.compliance());
let id = rules.propose_change(
&ComplianceChange::AddModule(hook, module.clone()),
&self.admin,
);
self.advance(MIN_UPGRADE_DELAY);
rules.apply_change(&id, &self.admin);
}
}

pub(crate) fn distinct_roles(e: &Env) -> VaultRoles {
Expand Down Expand Up @@ -237,6 +249,8 @@ enum Miswire {
NotAllowlisted,
VaultFrozen,
ComplianceUnbound,
ComplianceBoundElsewhere,
ComplianceSetupOpen,
}

/// A vault left in setup, wired except for `miswire`.
Expand Down Expand Up @@ -390,6 +404,18 @@ fn build<'a>(
oracle.finish_setup(&admin);
}
}
match miswire {
Miswire::ComplianceUnbound => {}
Miswire::ComplianceBoundElsewhere => {
compliance.bind_vault(&register_with(&e, distinct_roles(&e)), &admin);
compliance.finish_setup(&admin);
}
Miswire::ComplianceSetupOpen => compliance.bind_vault(&contract_id, &admin),
_ => {
compliance.bind_vault(&contract_id, &admin);
compliance.finish_setup(&admin);
}
}
let vault = AsyncVaultClient::new(&e, &contract_id);
if finish {
vault.set_custodian(&custodian, &admin);
Expand Down
6 changes: 1 addition & 5 deletions contracts/async-vault/src/test/settlement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,11 +191,7 @@ fn a_refused_mint_keeps_a_priced_deposit_epoch_open() {
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,
);
f.add_compliance_module(ComplianceHook::CanCreate, &module);

assert!(f.vault.try_claim_deposit(&investor, &epoch).is_err());

Expand Down
40 changes: 39 additions & 1 deletion contracts/async-vault/src/test/setup.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use soroban_sdk::{testutils::Events as _, Event as _, IntoVal};
use soroban_sdk::{contract, contractimpl, testutils::Events as _, vec, Event as _, IntoVal, Vec};
use stellar_access::role_transfer::RoleTransferError;

use super::*;
Expand Down Expand Up @@ -62,6 +62,8 @@ fn every_wiring_step_is_checked_before_setup_finishes() {
Miswire::NotAllowlisted,
Miswire::VaultFrozen,
Miswire::ComplianceUnbound,
Miswire::ComplianceBoundElsewhere,
Miswire::ComplianceSetupOpen,
] {
let f = miswired(miswire);

Expand All @@ -74,6 +76,42 @@ fn every_wiring_step_is_checked_before_setup_finishes() {
}
}

/// A compliance contract from outside the kit: it lists tokens and reports no vault.
#[contract]
struct ExternalRules;

#[contractimpl]
impl ExternalRules {
pub fn link(e: Env, token: Address) {
e.storage().instance().set(&symbol_short!("token"), &token);
}

pub fn linked_tokens(e: Env) -> Vec<Address> {
let token: Address = e.storage().instance().get(&symbol_short!("token")).unwrap();
vec![&e, token]
}
}

/// Its own governance is a trust assumption the vault cannot check.
#[test]
fn a_compliance_contract_from_outside_the_kit_is_accepted() {
let f = setup_with(
open_feed(),
|e, (name, symbol, admin, authority, _, verifier)| {
let rules = e.register(ExternalRules, ());
let token = e.register(
ShareToken,
(name, symbol, admin, authority, rules.clone(), verifier),
);
ExternalRulesClient::new(e, &rules).link(&token);
token
},
);

assert!(!f.vault.setup_open());
assert!(f.vault.health().compliance_bound);
}

/// A token that cannot answer `wiring`'s calls must not trap the vault: every
/// check it needs the token for reads false, and `finish_setup` refuses
/// cleanly instead of aborting the transaction.
Expand Down
2 changes: 2 additions & 0 deletions contracts/compliance/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ stellar-access = { workspace = true }
stellar-macros = { workspace = true }
stellar-tokens = { workspace = true }
storage = { path = "../../crates/storage" }
timelock = { path = "../../crates/timelock" }
bindings = { path = "../../crates/bindings" }

[dev-dependencies]
soroban-sdk = { workspace = true, features = ["testutils"] }
159 changes: 159 additions & 0 deletions contracts/compliance/src/governance.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
use soroban_sdk::{contracttype, panic_with_error, Address, Env, Vec};
use stellar_access::{
access_control::{self, AccessControlError, AccessControlStorageKey},
role_transfer,
};
use stellar_tokens::rwa::{compliance, compliance::ComplianceHook, utils::token_binder};
use timelock::{queue, setup, Clock, Refusal, TimelockHost};

use crate::{
ChangeApplied, ChangeCancelled, ChangeProposed, Compliance, ComplianceContractError,
SetupFinished, VaultBound,
};

/// Seven days of five-second ledgers: how long the next admin has to accept a
/// hand-over.
pub const ADMIN_ACCEPT_LEDGERS: u32 = 7 * 17_280;

/// A privileged change that waits for the vault's timelock before it lands.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ComplianceChange {
AddModule(ComplianceHook, Address),
RemoveModule(ComplianceHook, Address),
BindToken(Address),
UnbindToken(Address),
Admin(Address),
}

#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CompliancePendingChange {
pub id: u32,
pub change: ComplianceChange,
pub eta: u64,
}

/// The compliance contract keeps no clock of its own: it reads the vault's.
impl TimelockHost for Compliance {
fn clock(e: &Env) -> Result<Clock, Refusal> {
timelock::clock::from_source(e)
}
}

fn refuse(e: &Env, refusal: Refusal) -> ! {
match refusal {
Refusal::WindingDown => panic_with_error!(e, ComplianceContractError::WindDownActive),
Refusal::Paused => panic_with_error!(e, ComplianceContractError::VaultPaused),
Refusal::NotElapsed => panic_with_error!(e, ComplianceContractError::ChangeNotReady),
Refusal::BelowExitWindow => {
panic_with_error!(e, ComplianceContractError::DelayBelowNotice)
}
Refusal::NotFound => panic_with_error!(e, ComplianceContractError::ChangeNotFound),
Refusal::TooMany => panic_with_error!(e, ComplianceContractError::TooManyPendingChanges),
Refusal::SetupOpen => panic_with_error!(e, ComplianceContractError::SetupOpen),
Refusal::SetupClosed | Refusal::AlreadyBound => {
panic_with_error!(e, ComplianceContractError::SetupClosed)
}
Refusal::Unbound => panic_with_error!(e, ComplianceContractError::VaultNotBound),
}
}

fn vault_clock(e: &Env) -> Clock {
<Compliance as TimelockHost>::clock(e).unwrap_or_else(|r| refuse(e, r))
}

/// Direct rule changes are instant only before setup finishes.
pub(crate) fn require_setup_open(e: &Env) {
if !setup::is_open(e) {
panic_with_error!(e, ComplianceContractError::UseTimelock);
}
}

pub(crate) fn bind_vault(e: &Env, vault: &Address) {
setup::bind_source(e, vault).unwrap_or_else(|r| refuse(e, r));
VaultBound {
vault: vault.clone(),
}
.publish(e);
}

/// No nominee from setup may accept once changes wait, so a hand-over offered
/// during setup holds it open.
pub(crate) fn finish_setup(e: &Env) {
if setup::is_open(e)
&& role_transfer::has_active_pending_transfer(e, &AccessControlStorageKey::PendingAdmin)
{
panic_with_error!(e, ComplianceContractError::AdminTransferPending);
}
setup::finish::<Compliance>(e).unwrap_or_else(|r| refuse(e, r));
SetupFinished {}.publish(e);
}

pub(crate) fn propose(e: &Env, change: ComplianceChange) -> u32 {
let id = queue::propose::<Compliance, _>(e, &change).unwrap_or_else(|r| refuse(e, r));
let (_, schedule) =
queue::pending::<ComplianceChange>(e, id).unwrap_or_else(|| refuse(e, Refusal::NotFound));
// No pause has run since the proposal, so the eta needs no second clock read.
ChangeProposed {
id,
change,
eta: schedule.proposed_at.saturating_add(schedule.delay),
}
.publish(e);
id
}

pub(crate) fn cancel(e: &Env, id: u32) {
queue::cancel(e, id).unwrap_or_else(|r| refuse(e, r));
ChangeCancelled { id }.publish(e);
}

pub(crate) fn apply(e: &Env, id: u32) {
let change: ComplianceChange =
queue::take_ready::<Compliance, _>(e, id).unwrap_or_else(|r| refuse(e, r));
match &change {
ComplianceChange::AddModule(hook, module) => {
compliance::storage::add_module_to(e, hook.clone(), module.clone())
}
ComplianceChange::RemoveModule(hook, module) => {
compliance::storage::remove_module_from(e, hook.clone(), module.clone())
}
ComplianceChange::BindToken(token) => token_binder::bind_token(e, token),
ComplianceChange::UnbindToken(token) => token_binder::unbind_token(e, token),
ComplianceChange::Admin(next) => offer_admin(e, next),
}
ChangeApplied { id, change }.publish(e);
}

/// The first step of the two-step hand-over, without the admin authorisation
/// `transfer_admin_role` would ask for a second time inside `apply_change`.
fn offer_admin(e: &Env, next: &Address) {
let admin = access_control::get_admin(e)
.unwrap_or_else(|| panic_with_error!(e, AccessControlError::AdminNotSet));
let live_until = e.ledger().sequence().saturating_add(ADMIN_ACCEPT_LEDGERS);
role_transfer::transfer_role(e, next, &AccessControlStorageKey::PendingAdmin, live_until);
access_control::emit_admin_transfer_initiated(e, &admin, next, live_until);
}

/// Every change still waiting, with the moment it may land on the vault's
/// clock as it reads now.
pub(crate) fn pending(e: &Env) -> Vec<CompliancePendingChange> {
let mut out = Vec::new(e);
let open = queue::open_ids(e);
if open.is_empty() {
// Skips the vault, which is not bound yet during setup.
return out;
}
let clock = vault_clock(e);
for id in open.iter() {
if let Some((change, schedule)) = queue::pending::<ComplianceChange>(e, id) {
out.push_back(CompliancePendingChange {
id,
change,
eta: schedule.eta(&clock),
});
}
}
out
}
Loading
Loading