From 86c8669b2a25846744fc3d39ac60aa86acbec4af Mon Sep 17 00:00:00 2001
From: hpmaxi <358059+hpmaxi@users.noreply.github.com>
Date: Sat, 17 Oct 2026 16:01:58 -0300
Subject: [PATCH 1/3] feat(compliance): timelock rule changes
---
Cargo.lock | 2 +
contracts/async-vault/src/governance.rs | 19 +-
contracts/async-vault/src/test/compliance.rs | 6 +-
contracts/async-vault/src/test/health.rs | 14 +
contracts/async-vault/src/test/mod.rs | 28 +-
contracts/async-vault/src/test/settlement.rs | 6 +-
contracts/async-vault/src/test/setup.rs | 40 +-
contracts/compliance/Cargo.toml | 2 +
contracts/compliance/src/governance.rs | 159 ++++++
contracts/compliance/src/lib.rs | 155 ++++-
contracts/compliance/src/test.rs | 2 +
contracts/compliance/src/test/changes.rs | 560 +++++++++++++++++++
scripts/harness/deploy.ts | 8 +
scripts/harness/extend.ts | 1 +
scripts/harness/guards.test.ts | 39 ++
scripts/harness/lib/clients.ts | 3 +
16 files changed, 1026 insertions(+), 18 deletions(-)
create mode 100644 contracts/compliance/src/governance.rs
create mode 100644 contracts/compliance/src/test/changes.rs
diff --git a/Cargo.lock b/Cargo.lock
index 22a9197a..6e8107a6 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -351,11 +351,13 @@ dependencies = [
name = "compliance"
version = "0.1.0"
dependencies = [
+ "bindings",
"soroban-sdk",
"stellar-access",
"stellar-macros",
"stellar-tokens",
"storage",
+ "timelock",
]
[[package]]
diff --git a/contracts/async-vault/src/governance.rs b/contracts/async-vault/src/governance.rs
index b51473d1..8cf8c8cc 100644
--- a/contracts/async-vault/src/governance.rs
+++ b/contracts/async-vault/src/governance.rs
@@ -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);
@@ -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,
},
}
diff --git a/contracts/async-vault/src/test/compliance.rs b/contracts/async-vault/src/test/compliance.rs
index 5d7b3fbe..ffd539a7 100644
--- a/contracts/async-vault/src/test/compliance.rs
+++ b/contracts/async-vault/src/test/compliance.rs
@@ -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(),
diff --git a/contracts/async-vault/src/test/health.rs b/contracts/async-vault/src/test/health.rs
index 7a7ce66d..829c4f53 100644
--- a/contracts/async-vault/src/test/health.rs
+++ b/contracts/async-vault/src/test/health.rs
@@ -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);
diff --git a/contracts/async-vault/src/test/mod.rs b/contracts/async-vault/src/test/mod.rs
index 7e92e114..6f3c899a 100644
--- a/contracts/async-vault/src/test/mod.rs
+++ b/contracts/async-vault/src/test/mod.rs
@@ -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::{
@@ -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 {
@@ -237,6 +249,8 @@ enum Miswire {
NotAllowlisted,
VaultFrozen,
ComplianceUnbound,
+ ComplianceBoundElsewhere,
+ ComplianceSetupOpen,
}
/// A vault left in setup, wired except for `miswire`.
@@ -390,6 +404,18 @@ fn build<'a>(
oracle.finish_setup(&admin);
}
}
+ match miswire {
+ Miswire::ComplianceUnbound => {}
+ Miswire::ComplianceBoundElsewhere => {
+ compliance.bind_vault(®ister_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);
diff --git a/contracts/async-vault/src/test/settlement.rs b/contracts/async-vault/src/test/settlement.rs
index 398b03b5..71e44408 100644
--- a/contracts/async-vault/src/test/settlement.rs
+++ b/contracts/async-vault/src/test/settlement.rs
@@ -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());
diff --git a/contracts/async-vault/src/test/setup.rs b/contracts/async-vault/src/test/setup.rs
index 2890fb70..3dad44f7 100644
--- a/contracts/async-vault/src/test/setup.rs
+++ b/contracts/async-vault/src/test/setup.rs
@@ -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::*;
@@ -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);
@@ -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
{
+ 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.
diff --git a/contracts/compliance/Cargo.toml b/contracts/compliance/Cargo.toml
index dbc70e3f..6ebfdf51 100644
--- a/contracts/compliance/Cargo.toml
+++ b/contracts/compliance/Cargo.toml
@@ -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"] }
diff --git a/contracts/compliance/src/governance.rs b/contracts/compliance/src/governance.rs
new file mode 100644
index 00000000..ee70a2a1
--- /dev/null
+++ b/contracts/compliance/src/governance.rs
@@ -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 {
+ 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 {
+ ::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::(e).unwrap_or_else(|r| refuse(e, r));
+ SetupFinished {}.publish(e);
+}
+
+pub(crate) fn propose(e: &Env, change: ComplianceChange) -> u32 {
+ let id = queue::propose::(e, &change).unwrap_or_else(|r| refuse(e, r));
+ let (_, schedule) =
+ queue::pending::(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::(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 {
+ 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::(e, id) {
+ out.push_back(CompliancePendingChange {
+ id,
+ change,
+ eta: schedule.eta(&clock),
+ });
+ }
+ }
+ out
+}
diff --git a/contracts/compliance/src/lib.rs b/contracts/compliance/src/lib.rs
index 7f59fdad..f738634f 100644
--- a/contracts/compliance/src/lib.rs
+++ b/contracts/compliance/src/lib.rs
@@ -2,16 +2,87 @@
//! module (ERC-3643), not part of this product. Do not deploy to mainnet.
#![no_std]
-use soroban_sdk::{contract, contractimpl, contractmeta, Address, Env, Vec};
+mod governance;
+
+use soroban_sdk::{
+ contract, contracterror, contractevent, contractimpl, contractmeta, Address, Env, Vec,
+};
contractmeta!(
key = "stub",
val = "testnet-only; prod = external compliance module"
);
+use bindings::VaultBinding;
use stellar_access::access_control;
use stellar_macros::only_admin;
use stellar_tokens::rwa::{compliance, compliance::ComplianceHook, utils::token_binder};
+pub use governance::{ComplianceChange, CompliancePendingChange, ADMIN_ACCEPT_LEDGERS};
+
+/// Compliance contract failures. Codes are stable once assigned and are never
+/// reused; a removed variant leaves its number retired.
+#[contracterror]
+#[derive(Copy, Clone, Debug, Eq, PartialEq)]
+#[repr(u32)]
+pub enum ComplianceContractError {
+ /// No pending change has this id.
+ ChangeNotFound = 8001,
+ /// The change's delay has not elapsed.
+ ChangeNotReady = 8002,
+ /// As many changes as the timelock holds already wait.
+ TooManyPendingChanges = 8003,
+ /// Changes are instant until setup finishes; there is nothing to propose yet.
+ SetupOpen = 8004,
+ /// A direct call cannot make this change after setup: it goes through
+ /// `propose_change`.
+ UseTimelock = 8005,
+ /// Only possible while setup is open, and only once.
+ SetupClosed = 8006,
+ /// Setup cannot finish before the contract is bound to its vault.
+ VaultNotBound = 8007,
+ /// The vault is winding down, so no change may be proposed or applied.
+ WindDownActive = 8008,
+ /// The vault is paused, which holds every timelocked change.
+ VaultPaused = 8009,
+ /// The change's delay is shorter than the vault's notice, so an investor
+ /// could not complete an exit before it lands.
+ DelayBelowNotice = 8010,
+ /// 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.
+ AdminTransferPending = 8011,
+}
+
+#[contractevent]
+pub struct ChangeProposed {
+ #[topic]
+ pub id: u32,
+ pub change: ComplianceChange,
+ pub eta: u64,
+}
+
+#[contractevent]
+pub struct ChangeCancelled {
+ #[topic]
+ pub id: u32,
+}
+
+#[contractevent]
+pub struct ChangeApplied {
+ #[topic]
+ pub id: u32,
+ pub change: ComplianceChange,
+}
+
+#[contractevent]
+pub struct SetupFinished {}
+
+#[contractevent]
+pub struct VaultBound {
+ #[topic]
+ pub vault: Address,
+}
+
#[contract]
pub struct Compliance;
@@ -21,35 +92,115 @@ impl Compliance {
access_control::set_admin(e, &admin);
storage::bump_instance(e);
}
+
+ /// Names the vault whose clock this contract's timelock reads. Once, and
+ /// only during setup.
+ #[only_admin]
+ pub fn bind_vault(e: &Env, vault: Address, _operator: Address) {
+ storage::bump_instance(e);
+ governance::bind_vault(e, &vault);
+ }
+
+ /// Ends the window in which module and binding changes are instant. It
+ /// never reopens.
+ #[only_admin]
+ pub fn finish_setup(e: &Env, _operator: Address) {
+ storage::bump_instance(e);
+ governance::finish_setup(e);
+ }
+
+ /// Queues a change behind the vault's timelock and returns its id.
+ #[only_admin]
+ pub fn propose_change(e: &Env, change: ComplianceChange, _operator: Address) -> u32 {
+ storage::bump_instance(e);
+ governance::propose(e, change)
+ }
+
+ #[only_admin]
+ pub fn cancel_change(e: &Env, id: u32, _operator: Address) {
+ storage::bump_instance(e);
+ governance::cancel(e, id);
+ }
+
+ /// Lands a change whose delay has run. Refused while the vault is paused
+ /// or winding down.
+ #[only_admin]
+ pub fn apply_change(e: &Env, id: u32, _operator: Address) {
+ storage::bump_instance(e);
+ governance::apply(e, id);
+ }
+
+ pub fn pending_changes(e: &Env) -> Vec {
+ governance::pending(e)
+ }
+
+ /// Instant during setup; after it, propose an `Admin` change. Cancelling a
+ /// pending hand-over (`live_until_ledger == 0`) keeps the current admin,
+ /// so it stays instant.
+ pub fn transfer_admin_role(e: &Env, new_admin: Address, live_until_ledger: u32) {
+ if live_until_ledger != 0 {
+ governance::require_setup_open(e);
+ }
+ storage::bump_instance(e);
+ access_control::transfer_admin_role(e, &new_admin, live_until_ledger);
+ }
+
+ pub fn accept_admin_transfer(e: &Env) {
+ storage::bump_instance(e);
+ access_control::accept_admin_transfer(e);
+ }
+
+ pub fn get_admin(e: &Env) -> Option {
+ access_control::get_admin(e)
+ }
+}
+
+#[contractimpl(contracttrait)]
+impl VaultBinding for Compliance {
+ fn vault(e: &Env) -> Option {
+ timelock::setup::source(e)
+ }
+
+ fn setup_open(e: &Env) -> bool {
+ timelock::setup::is_open(e)
+ }
}
+/// Binding and unbinding are instant during setup; after it, propose a
+/// `BindToken` or `UnbindToken` change.
#[contractimpl(contracttrait)]
impl token_binder::TokenBinder for Compliance {
#[only_admin]
fn bind_token(e: &Env, token: Address, _operator: Address) {
+ governance::require_setup_open(e);
storage::bump_instance(e);
token_binder::bind_token(e, &token);
}
#[only_admin]
fn unbind_token(e: &Env, token: Address, _operator: Address) {
+ governance::require_setup_open(e);
storage::bump_instance(e);
token_binder::unbind_token(e, &token);
}
}
/// The token calls the check before and the hook after every mint, burn and
-/// transfer, which is what keeps this instance alive.
+/// transfer, which is what keeps this instance alive. Adding or removing a
+/// module is instant during setup; after it, propose an `AddModule` or
+/// `RemoveModule` change.
#[contractimpl(contracttrait)]
impl compliance::Compliance for Compliance {
#[only_admin]
fn add_module_to(e: &Env, hook: ComplianceHook, module: Address, _operator: Address) {
+ governance::require_setup_open(e);
storage::bump_instance(e);
compliance::storage::add_module_to(e, hook, module);
}
#[only_admin]
fn remove_module_from(e: &Env, hook: ComplianceHook, module: Address, _operator: Address) {
+ governance::require_setup_open(e);
storage::bump_instance(e);
compliance::storage::remove_module_from(e, hook, module);
}
diff --git a/contracts/compliance/src/test.rs b/contracts/compliance/src/test.rs
index 8e479e6c..14200161 100644
--- a/contracts/compliance/src/test.rs
+++ b/contracts/compliance/src/test.rs
@@ -1,3 +1,5 @@
+mod changes;
+
use soroban_sdk::{
testutils::{storage::Instance as _, Address as _, Ledger as _},
Address, Env,
diff --git a/contracts/compliance/src/test/changes.rs b/contracts/compliance/src/test/changes.rs
new file mode 100644
index 00000000..2c5bc55f
--- /dev/null
+++ b/contracts/compliance/src/test/changes.rs
@@ -0,0 +1,560 @@
+use soroban_sdk::{
+ contract, contractimpl, symbol_short,
+ testutils::{Address as _, Events as _, Ledger as _},
+ vec, Address, Env, Event as _,
+};
+use stellar_access::role_transfer::RoleTransferError;
+use stellar_tokens::rwa::compliance::ComplianceHook;
+use storage::INSTANCE_EXTEND_AMOUNT;
+use timelock::{Clock, DAY_DURATION, MIN_DELAY};
+
+use super::{age, instance_ttl};
+use crate::{
+ ChangeApplied, ChangeProposed, Compliance, ComplianceChange, ComplianceClient,
+ ComplianceContractError, CompliancePendingChange, ADMIN_ACCEPT_LEDGERS,
+};
+
+/// Plays the vault: reports whatever clock a test sets, and counts the reads.
+#[contract]
+struct ClockStub;
+
+#[contractimpl]
+impl ClockStub {
+ pub fn set(e: Env, clock: Clock) {
+ e.storage().instance().set(&symbol_short!("clock"), &clock);
+ }
+
+ pub fn reads(e: Env) -> u32 {
+ e.storage()
+ .instance()
+ .get(&symbol_short!("reads"))
+ .unwrap_or(0)
+ }
+}
+
+#[contractimpl(contracttrait)]
+impl timelock::ClockSource for ClockStub {
+ fn timelock_clock(e: &Env) -> Clock {
+ let reads = Self::reads(e.clone()) + 1;
+ e.storage().instance().set(&symbol_short!("reads"), &reads);
+ e.storage().instance().get(&symbol_short!("clock")).unwrap()
+ }
+}
+
+fn running() -> Clock {
+ Clock {
+ delay: MIN_DELAY,
+ exit_window: 0,
+ paused_secs: 0,
+ paused: false,
+ winding_down: false,
+ }
+}
+
+struct Fixture<'a> {
+ e: Env,
+ rules: ComplianceClient<'a>,
+ clock: ClockStubClient<'a>,
+ admin: Address,
+}
+
+impl Fixture<'_> {
+ fn propose(&self, change: &ComplianceChange) -> u32 {
+ self.rules.propose_change(change, &self.admin)
+ }
+
+ fn land(&self, change: &ComplianceChange) {
+ let id = self.propose(change);
+ self.e.ledger().with_mut(|l| l.timestamp += MIN_DELAY);
+ self.rules.apply_change(&id, &self.admin);
+ }
+
+ /// Lets the instance decay past its extend threshold, then checks the call
+ /// extends it back to the full TTL.
+ fn restores_the_instance(&self, call: impl FnOnce()) {
+ age(&self.e);
+ assert!(instance_ttl(&self.e, &self.rules.address) < INSTANCE_EXTEND_AMOUNT);
+ call();
+ assert_eq!(
+ instance_ttl(&self.e, &self.rules.address),
+ INSTANCE_EXTEND_AMOUNT
+ );
+ }
+}
+
+/// A compliance contract in setup, and a vault clock it has not bound yet.
+fn unbound<'a>() -> Fixture<'a> {
+ let e = Env::default();
+ e.mock_all_auths();
+ e.ledger().set_timestamp(10_000);
+ let admin = Address::generate(&e);
+ let rules = ComplianceClient::new(&e, &e.register(Compliance, (admin.clone(),)));
+ let clock = ClockStubClient::new(&e, &e.register(ClockStub, ()));
+ clock.set(&running());
+ Fixture {
+ e,
+ rules,
+ clock,
+ admin,
+ }
+}
+
+/// A compliance contract bound to a vault clock, with setup still open.
+fn bound<'a>() -> Fixture<'a> {
+ let f = unbound();
+ f.rules.bind_vault(&f.clock.address, &f.admin);
+ f
+}
+
+fn finished<'a>() -> Fixture<'a> {
+ let f = bound();
+ f.rules.finish_setup(&f.admin);
+ f
+}
+
+fn refused(
+ result: Result, Result>,
+ expected: ComplianceContractError,
+) {
+ assert_eq!(result, Err(Ok(expected.into())));
+}
+
+#[test]
+fn bindings_are_instant_during_setup() {
+ let f = bound();
+ let token = Address::generate(&f.e);
+
+ f.rules.bind_token(&token, &f.admin);
+
+ assert_eq!(f.rules.linked_tokens(), vec![&f.e, token]);
+}
+
+#[test]
+fn setup_cannot_finish_without_a_vault() {
+ let f = unbound();
+
+ refused(
+ f.rules.try_finish_setup(&f.admin),
+ ComplianceContractError::VaultNotBound,
+ );
+ assert!(f.rules.setup_open());
+}
+
+#[test]
+fn a_second_vault_or_a_late_vault_is_refused() {
+ let f = bound();
+ refused(
+ f.rules.try_bind_vault(&Address::generate(&f.e), &f.admin),
+ ComplianceContractError::SetupClosed,
+ );
+
+ f.rules.finish_setup(&f.admin);
+
+ refused(
+ f.rules.try_bind_vault(&Address::generate(&f.e), &f.admin),
+ ComplianceContractError::SetupClosed,
+ );
+ refused(
+ f.rules.try_finish_setup(&f.admin),
+ ComplianceContractError::SetupClosed,
+ );
+ assert_eq!(f.rules.vault(), Some(f.clock.address.clone()));
+}
+
+#[test]
+fn an_address_that_reports_no_clock_cannot_be_bound() {
+ let f = unbound();
+
+ assert!(f
+ .rules
+ .try_bind_vault(&Address::generate(&f.e), &f.admin)
+ .is_err());
+ assert_eq!(f.rules.vault(), None);
+}
+
+fn implements_the_vault_view() {}
+
+#[test]
+fn the_contract_answers_the_view_the_vault_reads() {
+ implements_the_vault_view::();
+ let f = unbound();
+ let view = bindings::VaultBindingClient::new(&f.e, &f.rules.address);
+ assert_eq!(view.vault(), None);
+ assert!(view.setup_open());
+
+ f.rules.bind_vault(&f.clock.address, &f.admin);
+ f.rules.finish_setup(&f.admin);
+
+ assert_eq!(view.vault(), Some(f.clock.address.clone()));
+ assert!(!view.setup_open());
+}
+
+#[test]
+fn only_the_admin_binds_and_finishes() {
+ let f = unbound();
+ let stranger = Address::generate(&f.e);
+ f.e.set_auths(&[]);
+
+ assert!(f.rules.try_bind_vault(&f.clock.address, &stranger).is_err());
+ assert!(f.rules.try_finish_setup(&stranger).is_err());
+ assert_eq!(f.rules.vault(), None);
+}
+
+#[test]
+fn after_setup_rule_changes_wait_for_the_timelock() {
+ let f = finished();
+ let token = Address::generate(&f.e);
+ let module = Address::generate(&f.e);
+
+ refused(
+ f.rules.try_bind_token(&token, &f.admin),
+ ComplianceContractError::UseTimelock,
+ );
+ refused(
+ f.rules.try_unbind_token(&token, &f.admin),
+ ComplianceContractError::UseTimelock,
+ );
+ refused(
+ f.rules
+ .try_add_module_to(&ComplianceHook::CanTransfer, &module, &f.admin),
+ ComplianceContractError::UseTimelock,
+ );
+ refused(
+ f.rules
+ .try_remove_module_from(&ComplianceHook::CanTransfer, &module, &f.admin),
+ ComplianceContractError::UseTimelock,
+ );
+}
+
+#[test]
+fn proposals_wait_until_setup_is_finished() {
+ let f = bound();
+ refused(
+ f.rules.try_propose_change(
+ &ComplianceChange::BindToken(Address::generate(&f.e)),
+ &f.admin,
+ ),
+ ComplianceContractError::SetupOpen,
+ );
+ assert_eq!(f.rules.pending_changes().len(), 0);
+}
+
+#[test]
+fn a_proposed_binding_lands_after_the_delay() {
+ let f = finished();
+ let token = Address::generate(&f.e);
+ let change = ComplianceChange::BindToken(token.clone());
+
+ let id = f.rules.propose_change(&change, &f.admin);
+
+ assert_eq!(
+ f.rules.pending_changes(),
+ vec![
+ &f.e,
+ CompliancePendingChange {
+ id,
+ change,
+ eta: 10_000 + MIN_DELAY,
+ }
+ ]
+ );
+ refused(
+ f.rules.try_apply_change(&id, &f.admin),
+ ComplianceContractError::ChangeNotReady,
+ );
+
+ f.e.ledger().set_timestamp(10_000 + MIN_DELAY);
+ f.rules.apply_change(&id, &f.admin);
+
+ assert_eq!(f.rules.linked_tokens(), vec![&f.e, token]);
+ assert_eq!(f.rules.pending_changes().len(), 0);
+}
+
+#[test]
+fn every_rule_change_lands_through_the_timelock() {
+ let f = bound();
+ let token = Address::generate(&f.e);
+ let early = Address::generate(&f.e);
+ let late = Address::generate(&f.e);
+ f.rules.bind_token(&token, &f.admin);
+ f.rules
+ .add_module_to(&ComplianceHook::CanTransfer, &early, &f.admin);
+ f.rules.finish_setup(&f.admin);
+
+ f.land(&ComplianceChange::RemoveModule(
+ ComplianceHook::CanTransfer,
+ early.clone(),
+ ));
+ f.land(&ComplianceChange::AddModule(
+ ComplianceHook::CanCreate,
+ late.clone(),
+ ));
+ f.land(&ComplianceChange::UnbindToken(token));
+
+ assert!(!f
+ .rules
+ .is_module_registered(&ComplianceHook::CanTransfer, &early));
+ assert!(f
+ .rules
+ .is_module_registered(&ComplianceHook::CanCreate, &late));
+ assert_eq!(f.rules.linked_tokens().len(), 0);
+}
+
+#[test]
+fn nothing_lands_while_the_vault_is_paused_or_winding_down() {
+ let f = finished();
+ let module = Address::generate(&f.e);
+ let id = f.rules.propose_change(
+ &ComplianceChange::AddModule(ComplianceHook::CanCreate, module),
+ &f.admin,
+ );
+ f.e.ledger().set_timestamp(10_000 + MIN_DELAY);
+
+ let mut paused = running();
+ paused.paused = true;
+ f.clock.set(&paused);
+ refused(
+ f.rules.try_apply_change(&id, &f.admin),
+ ComplianceContractError::VaultPaused,
+ );
+
+ let mut winding = running();
+ winding.winding_down = true;
+ f.clock.set(&winding);
+ refused(
+ f.rules.try_apply_change(&id, &f.admin),
+ ComplianceContractError::WindDownActive,
+ );
+ refused(
+ f.rules.try_propose_change(
+ &ComplianceChange::BindToken(Address::generate(&f.e)),
+ &f.admin,
+ ),
+ ComplianceContractError::WindDownActive,
+ );
+}
+
+#[test]
+fn a_vault_pause_pushes_the_change_back() {
+ let f = finished();
+ let id = f.propose(&ComplianceChange::BindToken(Address::generate(&f.e)));
+ let mut resumed = running();
+ resumed.paused_secs = 3 * DAY_DURATION;
+ f.clock.set(&resumed);
+ let eta = 10_000 + MIN_DELAY + 3 * DAY_DURATION;
+
+ assert_eq!(f.rules.pending_changes().get(0).unwrap().eta, eta);
+ f.e.ledger().set_timestamp(eta - 1);
+ refused(
+ f.rules.try_apply_change(&id, &f.admin),
+ ComplianceContractError::ChangeNotReady,
+ );
+
+ f.e.ledger().set_timestamp(eta);
+ f.rules.apply_change(&id, &f.admin);
+}
+
+#[test]
+fn a_change_shorter_than_a_raised_notice_never_lands() {
+ let f = finished();
+ let id = f.propose(&ComplianceChange::BindToken(Address::generate(&f.e)));
+ let mut raised = running();
+ raised.delay = 30 * DAY_DURATION;
+ raised.exit_window = 20 * DAY_DURATION;
+ f.clock.set(&raised);
+ f.e.ledger().set_timestamp(10_000 + 30 * DAY_DURATION);
+
+ refused(
+ f.rules.try_apply_change(&id, &f.admin),
+ ComplianceContractError::DelayBelowNotice,
+ );
+}
+
+#[test]
+fn a_cancelled_change_never_lands() {
+ let f = finished();
+ let id = f.rules.propose_change(
+ &ComplianceChange::BindToken(Address::generate(&f.e)),
+ &f.admin,
+ );
+
+ f.rules.cancel_change(&id, &f.admin);
+
+ refused(
+ f.rules.try_apply_change(&id, &f.admin),
+ ComplianceContractError::ChangeNotFound,
+ );
+ assert_eq!(f.rules.linked_tokens().len(), 0);
+}
+
+#[test]
+fn only_the_admin_proposes_cancels_and_applies() {
+ let f = finished();
+ let stranger = Address::generate(&f.e);
+ let change = ComplianceChange::BindToken(Address::generate(&f.e));
+ let id = f.propose(&change);
+ f.e.ledger().set_timestamp(10_000 + MIN_DELAY);
+
+ f.e.set_auths(&[]);
+ assert!(f.rules.try_propose_change(&change, &stranger).is_err());
+ assert!(f.rules.try_cancel_change(&id, &stranger).is_err());
+ assert!(f.rules.try_apply_change(&id, &stranger).is_err());
+ assert_eq!(f.rules.pending_changes().len(), 1);
+}
+
+#[test]
+fn proposing_and_applying_read_the_vault_clock_once() {
+ let f = finished();
+ let before = f.clock.reads();
+
+ let id = f.propose(&ComplianceChange::BindToken(Address::generate(&f.e)));
+ assert_eq!(f.clock.reads(), before + 1);
+
+ f.e.ledger().set_timestamp(10_000 + MIN_DELAY);
+ f.rules.apply_change(&id, &f.admin);
+ assert_eq!(f.clock.reads(), before + 2);
+}
+
+#[test]
+fn proposing_and_applying_publish_the_change() {
+ let f = finished();
+ let change = ComplianceChange::BindToken(Address::generate(&f.e));
+
+ let id = f.propose(&change);
+ assert_eq!(
+ f.e.events().all().filter_by_contract(&f.rules.address),
+ [ChangeProposed {
+ id,
+ change: change.clone(),
+ eta: 10_000 + MIN_DELAY,
+ }
+ .to_xdr(&f.e, &f.rules.address)]
+ );
+
+ f.e.ledger().set_timestamp(10_000 + MIN_DELAY);
+ f.rules.apply_change(&id, &f.admin);
+ let events = f.e.events().all().filter_by_contract(&f.rules.address);
+ assert_eq!(
+ events.events().last(),
+ Some(&ChangeApplied { id, change }.to_xdr(&f.e, &f.rules.address))
+ );
+}
+
+#[test]
+fn setup_cannot_finish_while_a_hand_over_is_pending() {
+ let f = bound();
+ let next = Address::generate(&f.e);
+ f.rules
+ .transfer_admin_role(&next, &(f.e.ledger().sequence() + 100));
+
+ refused(
+ f.rules.try_finish_setup(&f.admin),
+ ComplianceContractError::AdminTransferPending,
+ );
+
+ f.rules.accept_admin_transfer();
+ assert_eq!(f.rules.get_admin(), Some(next.clone()));
+ f.rules.finish_setup(&next);
+ assert!(!f.rules.setup_open());
+}
+
+#[test]
+fn a_cancelled_hand_over_no_longer_holds_setup_open() {
+ let f = bound();
+ let next = Address::generate(&f.e);
+ f.rules
+ .transfer_admin_role(&next, &(f.e.ledger().sequence() + 100));
+ f.rules.transfer_admin_role(&next, &0);
+
+ f.rules.finish_setup(&f.admin);
+
+ assert_eq!(f.rules.get_admin(), Some(f.admin.clone()));
+}
+
+#[test]
+fn after_setup_the_admin_hands_over_through_the_timelock() {
+ let f = finished();
+ let next = Address::generate(&f.e);
+ refused(
+ f.rules
+ .try_transfer_admin_role(&next, &(f.e.ledger().sequence() + 100)),
+ ComplianceContractError::UseTimelock,
+ );
+
+ let id = f
+ .rules
+ .propose_change(&ComplianceChange::Admin(next.clone()), &f.admin);
+ f.e.ledger().set_timestamp(10_000 + MIN_DELAY);
+ f.rules.apply_change(&id, &f.admin);
+ assert_eq!(f.rules.get_admin(), Some(f.admin.clone()));
+
+ f.rules.accept_admin_transfer();
+
+ assert_eq!(f.rules.get_admin(), Some(next));
+}
+
+#[test]
+fn a_hand_over_after_setup_is_cancelled_at_once() {
+ let f = finished();
+ let next = Address::generate(&f.e);
+ f.land(&ComplianceChange::Admin(next.clone()));
+
+ f.rules.transfer_admin_role(&next, &0);
+
+ assert!(f.rules.try_accept_admin_transfer().is_err());
+ assert_eq!(f.rules.get_admin(), Some(f.admin.clone()));
+}
+
+#[test]
+fn the_hand_over_offer_is_open_on_its_last_ledger() {
+ let f = finished();
+ let next = Address::generate(&f.e);
+ f.land(&ComplianceChange::Admin(next.clone()));
+ let offered_at = f.e.ledger().sequence();
+
+ f.e.ledger()
+ .with_mut(|l| l.sequence_number = offered_at + ADMIN_ACCEPT_LEDGERS);
+ f.rules.accept_admin_transfer();
+
+ assert_eq!(f.rules.get_admin(), Some(next));
+}
+
+#[test]
+fn the_hand_over_offer_lasts_seven_days_of_ledgers() {
+ let f = finished();
+ f.land(&ComplianceChange::Admin(Address::generate(&f.e)));
+ let offered_at = f.e.ledger().sequence();
+
+ f.e.ledger()
+ .with_mut(|l| l.sequence_number = offered_at + ADMIN_ACCEPT_LEDGERS + 1);
+
+ assert_eq!(
+ f.rules.try_accept_admin_transfer(),
+ Err(Ok(RoleTransferError::NoPendingTransfer.into()))
+ );
+ assert_eq!(f.rules.get_admin(), Some(f.admin.clone()));
+}
+
+#[test]
+fn every_admin_call_restores_the_instance_ttl() {
+ let f = unbound();
+ let next = Address::generate(&f.e);
+
+ f.restores_the_instance(|| {
+ f.rules
+ .transfer_admin_role(&next, &(f.e.ledger().sequence() + ADMIN_ACCEPT_LEDGERS))
+ });
+ f.restores_the_instance(|| f.rules.transfer_admin_role(&next, &0));
+ f.restores_the_instance(|| f.rules.bind_vault(&f.clock.address, &f.admin));
+ f.restores_the_instance(|| f.rules.finish_setup(&f.admin));
+
+ let change = ComplianceChange::BindToken(Address::generate(&f.e));
+ let mut id = 0;
+ f.restores_the_instance(|| id = f.propose(&change));
+ f.restores_the_instance(|| f.rules.cancel_change(&id, &f.admin));
+
+ let id = f.propose(&ComplianceChange::Admin(next));
+ f.e.ledger().with_mut(|l| l.timestamp += MIN_DELAY);
+ f.restores_the_instance(|| f.rules.apply_change(&id, &f.admin));
+ f.restores_the_instance(|| f.rules.accept_admin_transfer());
+}
diff --git a/scripts/harness/deploy.ts b/scripts/harness/deploy.ts
index 5398ad19..820611bb 100644
--- a/scripts/harness/deploy.ts
+++ b/scripts/harness/deploy.ts
@@ -202,6 +202,14 @@ async function grantAndVerify(
throw new Error("the share token was not bound to the compliance contract")
console.log("share_token bound to compliance")
+ // Binding the token is instant only during setup, so the compliance
+ // contract's setup closes after it.
+ await (await compliance.bind_vault({ vault, operator: caller })).signAndSend()
+ await (await compliance.finish_setup({ operator: caller })).signAndSend()
+ if ((await compliance.setup_open()).result)
+ throw new Error("the compliance contract's setup did not finish")
+ console.log("compliance bound to the vault, setup finished")
+
const { Client: NavOracle } = (await import(
clientEntry("nav_oracle")
)) as typeof NavOracleModule
diff --git a/scripts/harness/extend.ts b/scripts/harness/extend.ts
index 78cc4d1f..aa4bdaf6 100644
--- a/scripts/harness/extend.ts
+++ b/scripts/harness/extend.ts
@@ -35,6 +35,7 @@ const CONTRACTS_WITH_QUEUES = new Set([
"async_vault",
"share_token",
"nav_oracle",
+ "compliance",
])
/**
diff --git a/scripts/harness/guards.test.ts b/scripts/harness/guards.test.ts
index 36dbeef3..da66313e 100644
--- a/scripts/harness/guards.test.ts
+++ b/scripts/harness/guards.test.ts
@@ -4,6 +4,7 @@ import { beforeAll, describe, expect, it } from "vitest"
import { pay, trustline } from "./lib/accounts.js"
import { assetsFor, price, sharesFor, units } from "./lib/amounts.js"
import {
+ complianceContract,
connect,
identityVerifier,
oracle,
@@ -301,6 +302,44 @@ describe("the oracle after setup", () => {
})
})
+describe("the compliance contract after setup", () => {
+ const operator = () => governance.publicKey()
+ const canCreate = { tag: "CanCreate", values: undefined } as const
+
+ it("governance cannot add a module at once", async () => {
+ const rules = complianceContract(c, governance)
+ expectRefused(
+ await rules.add_module_to({
+ hook: canCreate,
+ module: outsider.publicKey(),
+ operator: operator(),
+ }),
+ rules,
+ "UseTimelock",
+ )
+ expect(
+ (await rules.get_modules_for_hook({ hook: canCreate })).result,
+ ).toEqual([])
+ })
+
+ it("queues a module behind the vault's delay", async () => {
+ const rules = complianceContract(c, governance)
+ const id = await send(
+ rules.propose_change({
+ change: { tag: "AddModule", values: [canCreate, outsider.publicKey()] },
+ operator: operator(),
+ }),
+ )
+ expectRefused(
+ await rules.apply_change({ id, operator: operator() }),
+ rules,
+ "ChangeNotReady",
+ )
+ await send(rules.cancel_change({ id, operator: operator() }))
+ expect((await rules.pending_changes()).result).toEqual([])
+ })
+})
+
describe("the vault after setup", () => {
const caller = () => governance.publicKey()
diff --git a/scripts/harness/lib/clients.ts b/scripts/harness/lib/clients.ts
index d7e0963b..e011f846 100644
--- a/scripts/harness/lib/clients.ts
+++ b/scripts/harness/lib/clients.ts
@@ -2,6 +2,7 @@ import { Asset, Keypair, rpc } from "@stellar/stellar-sdk"
import { basicNodeSigner } from "@stellar/stellar-sdk/contract"
import { Client as VaultClient } from "../../../app-lib/clients/async_vault/dist/index.js"
+import { Client as ComplianceClient } from "../../../app-lib/clients/compliance/dist/index.js"
import { Client as IdentityVerifierClient } from "../../../app-lib/clients/identity_verifier/dist/index.js"
import { Client as OracleClient } from "../../../app-lib/clients/nav_oracle/dist/index.js"
import { Client as ShareTokenClient } from "../../../app-lib/clients/share_token/dist/index.js"
@@ -61,6 +62,8 @@ export const shareToken = (c: Connected, as: Keypair) =>
new ShareTokenClient(options(c, "share_token", as))
export const identityVerifier = (c: Connected, as: Keypair) =>
new IdentityVerifierClient(options(c, "identity_verifier", as))
+export const complianceContract = (c: Connected, as: Keypair) =>
+ new ComplianceClient(options(c, "compliance", as))
interface Sendable {
simulation?: unknown
From 78cfda3e474549cae9eb4772a4b919ae9c1d1a41 Mon Sep 17 00:00:00 2001
From: hpmaxi <358059+hpmaxi@users.noreply.github.com>
Date: Sat, 17 Oct 2026 16:02:11 -0300
Subject: [PATCH 2/3] docs: put compliance rules behind the timelock
---
README.md | 1 +
docs/AUTHORIZATION_MATRIX.md | 29 ++++++++++++++---
docs/COMPLIANCE_MATRIX.md | 2 +-
docs/TESTING.md | 6 ++--
docs/kit-spec.md | 42 ++++++++++++++++---------
docs/strata-product-and-architecture.md | 2 +-
environments.toml | 10 +++---
7 files changed, 64 insertions(+), 28 deletions(-)
diff --git a/README.md b/README.md
index 24a766b2..11c655b8 100644
--- a/README.md
+++ b/README.md
@@ -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}
diff --git a/docs/AUTHORIZATION_MATRIX.md b/docs/AUTHORIZATION_MATRIX.md
index e7645aa2..4661fc24 100644
--- a/docs/AUTHORIZATION_MATRIX.md
+++ b/docs/AUTHORIZATION_MATRIX.md
@@ -1,13 +1,13 @@
# Authorization & Access Control Matrix
-This document provides the complete authorization and role model specification for the Strata Vault Kit across all four contracts (`AsyncVault`, `ShareToken`, `NavOracle`, and `IdentityVerifier`).
+This document provides the complete authorization and role model specification for the Strata Vault Kit across all five contracts (`AsyncVault`, `ShareToken`, `NavOracle`, `IdentityVerifier` and `Compliance`).
---
## 1. Architectural Principles
1. **Direct Role Membership Checks**: Role membership is checked directly via `has_role(e, &caller, &role)` or `stellar_macros::only_role` / `only_admin`, never by enumerating members.
-2. **Strict Caller Authentication**: No entrypoint takes a caller argument that it does not authenticate. Every function accepting a `caller: Address` executes `caller.require_auth()`.
+2. **Strict Caller Authentication**: No entrypoint takes a caller argument that it does not authenticate. Every function accepting a `caller: Address` executes `caller.require_auth()`. The testnet compliance stub is the exception: its admin calls check the stored admin's signature and ignore their `operator` argument (§5.5).
3. **Exit guarantee, with one hold**: A covered redemption claim pays out cash regardless of pause, staleness or allowlist status. A delisted investor exits through the cash path and cannot re-enter share circulation. A frozen investor is held: the vault refuses their requests, cancellations and claims, and pays them only after the freeze lifts.
4. **Five Separate Authorities**:
- **Governance (Admin)**: Root authority; upgrades, timelock parameters, custodian settings, unpause, oracle parameters, emergency NAV pause reset. As the share token's admin, it co-signs compliance's `legal_transfer` and `recover_balance`.
@@ -37,8 +37,9 @@ In `scripts/harness/deploy.ts` and automated deployment scripts, the separation
- The share token role is granted to the deployed vault contract: `token.grant_role({ account: asyncVault.address, role: "manager", caller: governance })`.
- After granting, the script checks `token.has_role({ account: vault, role: "manager" })` and stops if the vault does not hold the role. That no other account holds it is checked by the vault's `finish_setup` below.
- It then calls `token.bind_vault({ vault, caller: governance })` and `token.finish_setup({ caller: governance })`, and stops if `token.setup_open()` still reads true. From then on the token's admin changes wait for the vault's timelock (§5.2).
+- It calls `compliance.bind_token({ token, operator: governance })`, then `compliance.bind_vault({ vault, operator: governance })` and `compliance.finish_setup({ operator: governance })`, and stops if `compliance.setup_open()` still reads true. From then on the compliance contract's module, token-binding and admin changes wait for the vault's timelock (§5.5).
- It calls `oracle.bind_vault({ vault, caller: governance })` and `oracle.finish_setup({ caller: governance })`, and stops if `oracle.setup_open()` still reads true. From then on the oracle's admin changes wait for the vault's timelock (§5.3).
-- Last, it calls `vault.set_custodian({ custodian, caller: governance })` and `vault.finish_setup({ caller: governance })`, and stops if `vault.setup_open()` still reads true. `finish_setup` refuses with `SetupIncomplete` unless the token and the oracle report this vault with their setup closed, the vault is the token's only `manager`, the token has no shares outstanding, the vault passes the identity check, its address is not frozen on the token and the token is bound to its compliance contract. From then on the vault takes deposits and its admin changes wait for its timelock (§5.1).
+- Last, it calls `vault.set_custodian({ custodian, caller: governance })` and `vault.finish_setup({ caller: governance })`, and stops if `vault.setup_open()` still reads true. `finish_setup` refuses with `SetupIncomplete` unless the token and the oracle report this vault with their setup closed, the vault is the token's only `manager`, the token has no shares outstanding, the vault passes the identity check, its address is not frozen on the token and the token is bound to its compliance contract. A compliance contract that answers `vault()` and `setup_open()` must report this vault with its setup closed; one that does not answer them is an external module and is accepted. From then on the vault takes deposits and its admin changes wait for its timelock (§5.1).
---
@@ -72,7 +73,7 @@ The following entrypoints are open to any caller on purpose:
---
-## 5. Complete Access Control Matrix Across All 4 Contracts
+## 5. Complete Access Control Matrix Across All 5 Contracts
### 5.1 AsyncVault (`contracts/async-vault`)
@@ -90,7 +91,7 @@ Tests: [`controls.rs`](../contracts/async-vault/src/test/controls.rs), [`epochs.
| `set_notice` | `#[only_admin]`, `caller.require_auth()` | `secs <= MAX_NOTICE_SECS`, `secs <= upgrade_delay`. Raising is instant; lowering is `UseTimelock` after setup | `test::governance::raising_the_notice_is_instant_and_lowering_waits`, `test::notice::setting_the_notice_needs_governance_authorisation` |
| `set_wind_down_delay` | `#[only_admin]`, `caller.require_auth()` | `MIN_WIND_DOWN_DELAY <= secs <= MAX_WIND_DOWN_DELAY`. Raising is instant; lowering is `UseTimelock` after setup | `test::governance::raising_the_wind_down_delay_is_instant_and_lowering_waits`, `test::governance::a_wind_down_delay_below_the_minimum_cannot_be_proposed`, `test::wind_down::only_governance_sets_the_delay` |
| `set_deposit_cap` | `#[only_admin]`, `caller.require_auth()` | Not negative. Instant both ways, also after setup | `test::governance::the_deposit_cap_stays_instant_both_ways` |
-| `finish_setup` | `#[only_admin]`, `caller.require_auth()` | Once. The token and the oracle report this vault with their setup closed, the vault is the token's only `manager`, the token's `total_supply` is zero, the vault passes `verify_identity`, the token's `is_frozen` is false for the vault, and the token is in its compliance contract's `linked_tokens`; no admin hand-over offered during setup is pending | `test::setup::every_wiring_step_is_checked_before_setup_finishes`, `test::setup::setup_cannot_finish_while_a_hand_over_is_pending`, `test::setup::setup_never_finishes_twice`, `test::setup::only_governance_finishes_setup` |
+| `finish_setup` | `#[only_admin]`, `caller.require_auth()` | Once. The token and the oracle report this vault with their setup closed, the vault is the token's only `manager`, the token's `total_supply` is zero, the vault passes `verify_identity`, the token's `is_frozen` is false for the vault, and the token is in its compliance contract's `linked_tokens`; a compliance contract that answers `vault()` and `setup_open()` reports this vault with its setup closed (one that does not answer them is an external module and is accepted); no admin hand-over offered during setup is pending | `test::setup::every_wiring_step_is_checked_before_setup_finishes`, `test::setup::a_compliance_contract_from_outside_the_kit_is_accepted`, `test::setup::setup_cannot_finish_while_a_hand_over_is_pending`, `test::setup::setup_never_finishes_twice`, `test::setup::only_governance_finishes_setup` |
| `grant_role` / `transfer_admin_role` | Admin | Instant during setup, `UseTimelock` after. A zero `live_until_ledger` cancels instantly | `test::governance::grants_and_manager_revokes_wait_for_the_timelock`, `test::governance::an_admin_hand_over_waits_but_cancelling_one_does_not` |
| `revoke_role` | Admin | `treasury`, `guardian`: instant. `manager`: instant during setup, `UseTimelock` after | `test::governance::grants_and_manager_revokes_wait_for_the_timelock` (instant case: `test::governance::revoking_treasury_or_guardian_stays_instant`; caller check: `test::governance::only_the_admin_revokes_a_role`) |
| `set_role_admin` | — | Always refused (`UseTimelock`) | `test::governance::no_role_ever_gets_a_role_admin` |
@@ -173,3 +174,21 @@ Tests: [`contracts/identity-verifier/src/test.rs`](../contracts/identity-verifie
|---|---|---|---|
| `allow` | `#[only_admin]` (the compliance key), `caller.require_auth()` | None | `test::only_admin_can_allow` |
| `verify_identity` | Open check (Internal hook) | `is_allowed(account) == true` | `test::verify_identity_checks_allowlist` |
+
+### 5.5 Compliance (`contracts/compliance`)
+
+A testnet-only stub. Its admin is governance. Module changes, token bindings and admin hand-overs are instant during setup and wait for the vault's timelock after `finish_setup`. Each admin call checks the stored admin's signature; its `operator` argument is not checked.
+
+Tests: [`contracts/compliance/src/test/changes.rs`](../contracts/compliance/src/test/changes.rs).
+
+| Entrypoint | Access Control / Caller Auth | State & Precondition Guards | Tested Refusal (Test Name) |
+|---|---|---|---|
+| `bind_vault` | `#[only_admin]` | During setup, once; the vault must answer `timelock_clock` | `test::changes::a_second_vault_or_a_late_vault_is_refused`, `test::changes::an_address_that_reports_no_clock_cannot_be_bound`, `test::changes::only_the_admin_binds_and_finishes` |
+| `finish_setup` | `#[only_admin]` | Needs a bound vault and no pending admin hand-over; once | `test::changes::setup_cannot_finish_without_a_vault`, `test::changes::setup_cannot_finish_while_a_hand_over_is_pending`, `test::changes::a_second_vault_or_a_late_vault_is_refused` |
+| `bind_token`, `unbind_token`, `add_module_to`, `remove_module_from` | `#[only_admin]` | Instant during setup; `UseTimelock` after | `test::changes::after_setup_rule_changes_wait_for_the_timelock` |
+| `propose_change` | `#[only_admin]` | After setup; vault not winding down | `test::changes::proposals_wait_until_setup_is_finished`, `test::changes::only_the_admin_proposes_cancels_and_applies` |
+| `cancel_change` | `#[only_admin]` | The change must be pending | `test::changes::a_cancelled_change_never_lands` |
+| `apply_change` | `#[only_admin]` | Delay run on the vault's clock, vault unpaused and not winding down, delay `>=` the vault's notice | `test::changes::a_proposed_binding_lands_after_the_delay`, `test::changes::nothing_lands_while_the_vault_is_paused_or_winding_down`, `test::changes::a_vault_pause_pushes_the_change_back`, `test::changes::a_change_shorter_than_a_raised_notice_never_lands` |
+| `pending_changes` | Open view | None | None (view) |
+| `transfer_admin_role` | Admin | Instant during setup; `UseTimelock` after. A zero `live_until_ledger` cancels at once | `test::changes::after_setup_the_admin_hands_over_through_the_timelock`, `test::changes::a_cancelled_hand_over_no_longer_holds_setup_open`, `test::changes::a_hand_over_after_setup_is_cancelled_at_once` |
+| `accept_admin_transfer` | The pending admin's auth | A hand-over is pending; an applied `Admin` change stays open for seven days of ledgers | `test::changes::setup_cannot_finish_while_a_hand_over_is_pending`, `test::changes::the_hand_over_offer_lasts_seven_days_of_ledgers` |
diff --git a/docs/COMPLIANCE_MATRIX.md b/docs/COMPLIANCE_MATRIX.md
index 2be50f78..e408cb02 100644
--- a/docs/COMPLIANCE_MATRIX.md
+++ b/docs/COMPLIANCE_MATRIX.md
@@ -98,7 +98,7 @@ The share token is OpenZeppelin's RWA token. The allowlist and freeze checks sit
0.7.2 has five compliance hooks: `can_transfer` and `can_create` are asked before the state change, and `transferred`, `created` and `destroyed` are told after it. There is no `can_destroy`.
-The kit's compliance contract registers no modules, so `can_transfer` and `can_create` always allow today. Until rule modules are added, the allowlist and freeze rules are exactly the token checks in the table above.
+The kit's compliance contract registers no modules, so `can_transfer` and `can_create` always allow today. Until rule modules are added, the allowlist and freeze rules are exactly the token checks in the table above. Adding or removing a module waits for the vault's timelock once its setup is finished, so a rule change never lands sooner than an exit completes.
---
diff --git a/docs/TESTING.md b/docs/TESTING.md
index c10d67e3..1a6a2d47 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -47,7 +47,7 @@ contract extends, who pays, and how to restore an archived one.
| Persistent entries round-trip and get the full TTL on write | [`crates/storage/src/test.rs`](../crates/storage/src/test.rs) | `persistent_round_trips`, `writing_sets_the_full_ttl` |
| A read restores a decayed entry; a missing entry is not created | [`crates/storage/src/test.rs`](../crates/storage/src/test.rs) | `reading_a_decayed_entry_restores_the_full_ttl`, `reading_a_missing_entry_creates_nothing` |
| Instance writes and reads extend the contract instance | [`crates/storage/src/test.rs`](../crates/storage/src/test.rs) | `instance_writes_bump_the_instance`, `reading_the_instance_restores_its_ttl` |
-| The share token, identity verifier and compliance contract extend their own instance on every call that does work | [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.rs), [`contracts/share-token/src/test_governance.rs`](../contracts/share-token/src/test_governance.rs), [`contracts/identity-verifier/src/test.rs`](../contracts/identity-verifier/src/test.rs), [`contracts/compliance/src/test.rs`](../contracts/compliance/src/test.rs) | `a_transfer_restores_the_instance_ttl`, `the_vaults_mint_burn_and_escrow_restore_the_instance_ttl`, `every_admin_call_restores_the_instance_ttl`, `allowing_and_checking_restore_the_instance_ttl`, `the_hooks_a_token_calls_after_moving_shares_restore_the_instance_ttl` |
+| The share token, identity verifier and compliance contract extend their own instance on every call that does work | [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.rs), [`contracts/share-token/src/test_governance.rs`](../contracts/share-token/src/test_governance.rs), [`contracts/identity-verifier/src/test.rs`](../contracts/identity-verifier/src/test.rs), [`contracts/compliance/src/test.rs`](../contracts/compliance/src/test.rs), [`contracts/compliance/src/test/changes.rs`](../contracts/compliance/src/test/changes.rs) | `a_transfer_restores_the_instance_ttl`, `the_vaults_mint_burn_and_escrow_restore_the_instance_ttl`, `every_admin_call_restores_the_instance_ttl`, `allowing_and_checking_restore_the_instance_ttl`, `the_hooks_a_token_calls_after_moving_shares_restore_the_instance_ttl` |
| Vault requests and epochs are written with the full TTL | [`contracts/async-vault/src/test/ttl.rs`](../contracts/async-vault/src/test/ttl.rs) | `a_request_and_its_epoch_are_written_with_the_full_ttl`, `a_redemption_request_is_written_with_the_full_ttl` |
| Reading a vault request, epoch or the instance restores its TTL | [`contracts/async-vault/src/test/ttl.rs`](../contracts/async-vault/src/test/ttl.rs) | `reading_a_decayed_request_restores_its_full_ttl`, `reading_a_decayed_epoch_restores_its_full_ttl`, `any_vault_read_restores_the_instance_ttl` |
| A request still claims, and a wind-down position still pays, after most of a month | [`contracts/async-vault/src/test/ttl.rs`](../contracts/async-vault/src/test/ttl.rs), [`contracts/async-vault/src/test/wind_down.rs`](../contracts/async-vault/src/test/wind_down.rs) | `a_pending_request_still_claims_after_most_of_a_month`, `a_position_survives_the_ledger_advancing` |
@@ -82,6 +82,7 @@ nothing.
| Share token: only the admin binds the vault, finishes setup and runs the timelock | [`contracts/share-token/src/test_governance.rs`](../contracts/share-token/src/test_governance.rs) | `only_the_admin_binds_and_finishes`, `only_the_admin_proposes_cancels_and_applies` |
| Oracle: only the attester posts, only the guardian pauses the NAV | [`contracts/nav-oracle/src/test.rs`](../contracts/nav-oracle/src/test.rs) | `attest_is_role_gated`, `pausing_the_nav_is_limited_to_the_guardian`, `only_admin_can_set_config` |
| Oracle: only the admin binds the vault, finishes setup, runs the timelock and revokes a role | [`contracts/nav-oracle/src/test_governance.rs`](../contracts/nav-oracle/src/test_governance.rs) | `only_the_admin_binds_and_finishes`, `only_the_admin_proposes_cancels_and_applies`, `only_the_admin_revokes_a_role` |
+| Compliance contract: only the admin binds the vault, finishes setup and runs the timelock | [`contracts/compliance/src/test/changes.rs`](../contracts/compliance/src/test/changes.rs) | `only_the_admin_binds_and_finishes`, `only_the_admin_proposes_cancels_and_applies` |
| Vault: only the admin finishes setup; after it, the custodian, grants, `manager` revokes, hand-overs and a lower notice or wind-down delay go through the timelock, while revoking the treasury or the guardian stays instant | [`contracts/async-vault/src/test/setup.rs`](../contracts/async-vault/src/test/setup.rs), [`contracts/async-vault/src/test/governance.rs`](../contracts/async-vault/src/test/governance.rs) | `only_governance_finishes_setup`, `the_custodian_changes_through_the_timelock`, `grants_and_manager_revokes_wait_for_the_timelock`, `revoking_treasury_or_guardian_stays_instant`, `only_the_admin_revokes_a_role` |
| Identity verifier: only the admin edits the allowlist | [`contracts/identity-verifier/src/test.rs`](../contracts/identity-verifier/src/test.rs) | `only_admin_can_allow` |
@@ -99,6 +100,7 @@ so time rules and TTL can be reached in one test.
| Upgrade and wind-down timelocks follow the ledger clock | [`contracts/async-vault/src/test/upgrade.rs`](../contracts/async-vault/src/test/upgrade.rs), [`contracts/async-vault/src/test/wind_down.rs`](../contracts/async-vault/src/test/wind_down.rs) | `applying_before_the_eta_is_refused`, `a_pause_freezes_the_upgrade_timelock_clock`, `governance_proposes_and_anyone_activates_after_the_delay` |
| The share token's changes wait on the vault's clock: a vault pause moves the eta, a wind-down refuses them | [`contracts/share-token/src/test_governance.rs`](../contracts/share-token/src/test_governance.rs) | `a_grant_lands_one_vault_delay_after_it_was_proposed`, `a_vault_pause_moves_the_eta_and_holds_the_change`, `nothing_is_proposed_or_applied_while_the_vault_winds_down` |
| The oracle's changes wait on the vault's clock the same way | [`contracts/nav-oracle/src/test_governance.rs`](../contracts/nav-oracle/src/test_governance.rs) | `a_loosened_config_lands_through_the_timelock`, `a_vault_pause_moves_the_eta_and_holds_the_change`, `nothing_is_proposed_or_applied_while_the_vault_winds_down` |
+| So do the compliance contract's module, token-binding and admin changes, once its setup is finished | [`contracts/compliance/src/test/changes.rs`](../contracts/compliance/src/test/changes.rs) | `after_setup_rule_changes_wait_for_the_timelock`, `a_proposed_binding_lands_after_the_delay`, `a_vault_pause_pushes_the_change_back`, `nothing_lands_while_the_vault_is_paused_or_winding_down` |
| The vault's own changes wait on its clock: a raised notice blocks a shorter change, a pause holds one, a wind-down refuses them, and a wind-down waits at least seven days | [`contracts/async-vault/src/test/governance.rs`](../contracts/async-vault/src/test/governance.rs), [`contracts/async-vault/src/test/wind_down.rs`](../contracts/async-vault/src/test/wind_down.rs) | `a_change_shorter_than_a_raised_notice_never_lands`, `a_pending_role_change_lands_after_a_pause`, `nothing_timelocked_moves_during_a_wind_down_a_manager_revoke_included`, `an_unset_delay_still_waits_the_minimum` |
| Timing rules hold for any clock values (property tests) | [`contracts/async-vault/src/test/timing.rs`](../contracts/async-vault/src/test/timing.rs) | `standard_priceable_at_never_precedes_the_close`, `a_notice_that_would_overflow_saturates` |
@@ -125,7 +127,7 @@ fixture wires them as the deploy does and finishes the vault's setup;
| The vault mints and burns shares through its role on the share token | [`contracts/async-vault/src/test/constructor.rs`](../contracts/async-vault/src/test/constructor.rs), [`contracts/async-vault/src/test/redeem.rs`](../contracts/async-vault/src/test/redeem.rs) | `the_vault_holds_the_manager_role_on_the_share_token`, `claim_redeem_pays_assets_and_burns_the_shares` |
| The vault checks the identity verifier at request; the share token checks it on mint and transfer | [`contracts/async-vault/src/test/compliance.rs`](../contracts/async-vault/src/test/compliance.rs), [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.rs) | `an_investor_who_was_never_allowlisted_is_refused_before_any_cash_moves`, `a_frozen_or_delisted_holder_cannot_transfer_shares`, `manager_mints_to_allowlisted_account` |
| The vault reads the oracle and refuses a stale or paused feed | [`contracts/async-vault/src/test/oracle_pricing.rs`](../contracts/async-vault/src/test/oracle_pricing.rs) | `the_epoch_is_priced_at_the_attestation`, `fulfill_is_rejected_once_the_feed_goes_stale`, `a_paused_feed_is_refused_by_the_vault_not_the_oracle` |
-| The vault finishes setup only once the token and the oracle report it with their own setup closed, it is the token's only `manager`, the token has no shares outstanding, it passes the identity check, its address is not frozen and the token is bound to compliance; before that it takes no deposit | [`contracts/async-vault/src/test/setup.rs`](../contracts/async-vault/src/test/setup.rs) | `every_wiring_step_is_checked_before_setup_finishes`, `a_deposit_before_setup_finishes_is_refused`, `setup_cannot_finish_while_a_hand_over_is_pending` |
+| The vault finishes setup only once the token and the oracle report it with their own setup closed, it is the token's only `manager`, the token has no shares outstanding, it passes the identity check, its address is not frozen and the token is bound to compliance, which runs on this vault's clock with its setup closed unless it is an external module that does not answer `VaultBinding`; before that it takes no deposit | [`contracts/async-vault/src/test/setup.rs`](../contracts/async-vault/src/test/setup.rs) | `every_wiring_step_is_checked_before_setup_finishes`, `a_compliance_contract_from_outside_the_kit_is_accepted`, `a_deposit_before_setup_finishes_is_refused`, `setup_cannot_finish_while_a_hand_over_is_pending` |
| Upgrading replaces the vault's code and migrates once | [`contracts/async-vault/src/test/upgrade.rs`](../contracts/async-vault/src/test/upgrade.rs) (needs `upgrade_wasm`) | `applying_a_wasm_change_replaces_the_code`, `a_migration_refuses_a_second_run` |
| The share token upgrades through the vault's timelock under live balances, an open deposit and an open redemption, and migrates once; called directly, `upgrade` does not exist | [`contracts/async-vault/src/test/upgrade.rs`](../contracts/async-vault/src/test/upgrade.rs) (needs `upgrade_wasm`) | `the_token_upgrades_under_live_balances_and_open_requests`, `the_token_exposes_no_immediate_upgrade` |
| The oracle upgrades through the vault's timelock under a live valuation, keeps its config, roles and vault, and migrates once; called directly, `upgrade` does not exist | [`contracts/nav-oracle/src/test_governance.rs`](../contracts/nav-oracle/src/test_governance.rs) (needs `upgrade_wasm`) | `the_oracle_upgrades_under_a_live_valuation`, `the_oracle_exposes_no_immediate_upgrade` |
diff --git a/docs/kit-spec.md b/docs/kit-spec.md
index 043c490d..e14f9e7c 100644
--- a/docs/kit-spec.md
+++ b/docs/kit-spec.md
@@ -85,7 +85,7 @@ The contracts enforce a set of mathematical and state-machine invariants across
- **Testable Property**:
- Upgrade delay $\in [7 \text{ days}, 90 \text{ days}]$ and $\text{delay} \ge \text{notice}$, checked when a delay change is proposed and again when it applies.
- Wind-down delay $\in [7 \text{ days}, 90 \text{ days}]$ (`WindDownDelayTooShort`, `WindDownDelayTooLong`); 7 days when none was set.
- - The vault takes no deposit until its one-time setup closes (`SetupOpen`). `finish_setup` refuses with `SetupIncomplete` until the share token and the oracle report this vault with their own setup closed, the vault is the token's only `manager`, the token has no shares outstanding, the vault passes the token's identity check, the token has not frozen the vault's address, and the token is bound to its compliance contract. Shares minted before setup closes would redeem against depositors' money. It refuses with `AdminTransferPending` while an admin hand-over offered during setup is pending. Setup never reopens (`SetupClosed`).
+ - The vault takes no deposit until its one-time setup closes (`SetupOpen`). `finish_setup` refuses with `SetupIncomplete` until the share token and the oracle report this vault with their own setup closed, the vault is the token's only `manager`, the token has no shares outstanding, the vault passes the token's identity check, the token has not frozen the vault's address, and the token is bound to its compliance contract. A compliance contract that answers `vault()` and `setup_open()` must report this vault with its own setup closed; one that does not answer them is an external module and is accepted. Shares minted before setup closes would redeem against depositors' money. It refuses with `AdminTransferPending` while an admin hand-over offered during setup is pending. Setup never reopens (`SetupClosed`).
- After setup, the vault's custodian, role grants, revoking `manager`, admin hand-overs and lowering the notice or the wind-down delay go through the queue; a direct call is refused with `UseTimelock`. Raising the notice or the wind-down delay, the deposit cap, `pause`/`unpause` and revoking any other role stay instant. A `Notice` or `WindDownDelay` change is checked against its bounds when proposed and again when it applies.
- Every change waits in the queue (`propose_change` → `apply_change`). Applying before its eta reverts with `ChangeNotReady`.
- A change whose delay is shorter than the notice standing at apply time reverts with `UpgradeDelayBelowNotice`.
@@ -95,6 +95,7 @@ The contracts enforce a set of mathematical and state-machine invariants across
- Renouncing admin is permanently refused (`AdminRequired`).
- Once its one-time setup closes, the share token's upgrades, grants, `manager` revocations, admin hand-overs and compliance or verifier swaps wait on the vault's clock under the same rules; a direct call is refused with `UseTimelock`. Revoking `compliance` stays instant.
- Once its one-time setup closes, the oracle's upgrades, grants, admin hand-overs and every config change except a shorter `freshness_duration` wait on the vault's clock under the same rules; a direct call is refused with `UseTimelock`. Revoking `attester` or `guardian`, the NAV pause and `clear_latest` stay instant.
+ - Once its one-time setup closes, the compliance contract's module changes, token bindings and admin hand-overs wait on the vault's clock under the same rules; a direct call is refused with `UseTimelock`.
### Invariant 9: Wind-Down Pro-Rata Solvency
- **Definition**: In an active wind-down, total round payouts across all share claims can never exceed the capital credited to that round.
@@ -175,7 +176,7 @@ The `async_vault` constructor strictly verifies pairwise separation across autho
| `sole_token_manager` | This vault is the share token's only `manager`. |
| `allowlisted` | This vault passes the share token's identity verifier, so it can hold escrowed shares. A delisted holder's deposit converts to an exit by minting the shares to the vault's own address, so that conversion needs this flag true. |
| `vault_unfrozen` | The share token has not frozen this vault's address. A frozen vault could not hand escrowed shares back when a holder cancels a redemption. |
-| `compliance_bound` | The share token is in its compliance contract's `linked_tokens`. |
+| `compliance_bound` | The share token is in its compliance contract's `linked_tokens`, and a compliance contract that answers `vault()` and `setup_open()` names this vault with its own setup finished. One that does not answer them is an external module and passes; its own governance is a trust assumption. |
| `feed_valid` | The oracle reports `Valid`: a report exists, it has not expired and the NAV is not paused. |
| `freshness_covers_notice` | The oracle's `freshness_duration` is at least the vault's notice. |
@@ -191,7 +192,7 @@ Strata distributes control across five native Stellar multisig authorities and p
| Authority | Quorum / Profile | Authorized Capabilities | Anti-Capabilities (What It Cannot Do) |
|---|---|---|---|
-| **Governance** | High-quorum deliberative multisig | Set parameters (`notice`, `custodian`, `deposit_cap`, `wind_down_delay`), propose/apply upgrades, propose/cancel wind-down, unpause the vault, pause/unpause the share token, transfer admin, finish each contract's one-time setup, co-sign compliance's `legal_transfer` and `recover_balance` as the share token's admin. After setup, these wait for the timelock: role grants, revoking the manager, admin hand-over, the custodian, lowering the notice or the wind-down delay, upgrades, the token's compliance and verifier swaps, and oracle config changes other than a shorter freshness window. Revoking the treasury or the guardian stays instant, because taking a power away moves no value. | **Cannot pause the vault directly**; cannot move funds; cannot initiate custodian transfers; cannot attest prices; cannot bypass timelocks. |
+| **Governance** | High-quorum deliberative multisig | Set parameters (`notice`, `custodian`, `deposit_cap`, `wind_down_delay`), propose/apply upgrades, propose/cancel wind-down, unpause the vault, pause/unpause the share token, transfer admin, finish each contract's one-time setup, co-sign compliance's `legal_transfer` and `recover_balance` as the share token's admin. After setup, these wait for the timelock: role grants, revoking the manager, admin hand-over, the custodian, lowering the notice or the wind-down delay, upgrades, the token's compliance and verifier swaps, the compliance contract's module and token-binding changes, and oracle config changes other than a shorter freshness window. Revoking the treasury or the guardian stays instant, because taking a power away moves no value. | **Cannot pause the vault directly**; cannot move funds; cannot initiate custodian transfers; cannot attest prices; cannot bypass timelocks. |
| **Guardian** | Low-threshold fast-reaction multisig | `pause` (vault); `pause_nav` (oracle emergency price freeze). | **Cannot unpause**; cannot move funds; cannot block redemption requests; cannot block payable claims or cancellations. |
| **Treasury** | Operational capital multisig | `deploy_to_custodian` (moves free reserve); `fund` (returns assets from custodian). | **Cannot deploy while anything is uncovered**; cannot touch escrowed subscription assets; cannot alter parameters or pause. |
| **Attester** | High-security attestation multisig | `attest` (submits NAV report with proof reference). | **Cannot price outside deviation band**; cannot violate cooldown; cannot operate on the vault directly. |
@@ -270,6 +271,15 @@ Strata distributes control across five native Stellar multisig authorities and p
| **`ShareToken`** | `set_role_admin` | None | Always refused (`UseTimelock`) | N/A |
| **`IdentityVerifier`** | `allow` | Admin (Compliance) | `#[only_admin]` | No |
| **`IdentityVerifier`** | `set_claim_topics_and_issuers`| Admin (Compliance) | `#[only_admin]` | No |
+| **`Compliance`** | `bind_token`, `unbind_token`, `add_module_to`, `remove_module_from` | Governance | `#[only_admin]`; instant during setup, then `propose_change` | No |
+| **`Compliance`** | `bind_vault` | Governance (compliance admin) | `#[only_admin]`; during setup, once | No |
+| **`Compliance`** | `finish_setup` | Governance | `#[only_admin]`; needs a bound vault and no pending admin hand-over | No |
+| **`Compliance`** | `propose_change` | Governance | `#[only_admin]`; after setup | No |
+| **`Compliance`** | `cancel_change` | Governance | `#[only_admin]` | No |
+| **`Compliance`** | `apply_change` | Governance | `#[only_admin]`; after the vault's delay | Refused while the vault is paused or winding down |
+| **`Compliance`** | `pending_changes` | Permissionless | None (view) | No |
+| **`Compliance`** | `transfer_admin_role` | Governance | Instant during setup, then `propose_change` (a zero `live_until_ledger` cancels at once) | No |
+| **`Compliance`** | `accept_admin_transfer` | Proposed Admin | The pending admin's `require_auth()` | No |
---
@@ -283,7 +293,7 @@ A clean deployment executes in three sequenced phases:
- Call `ShareToken::grant_role("manager", vault_address)` to authorize lazy share minting and burning.
- Call `ShareToken::bind_vault(vault_address)`, then `ShareToken::finish_setup()`. The token's admin changes are instant until then and wait for the vault's timelock afterwards.
- Call `IdentityVerifier::allow(vault_address, true)`, signed by the compliance key, so the vault can return escrowed shares on cancelled redemptions.
- - Call `Compliance::bind_token(share_token_address)` to link SEP-57 rule verification.
+ - Call `Compliance::bind_token(share_token_address)` to link SEP-57 rule verification, then `Compliance::bind_vault(vault_address)` and `Compliance::finish_setup()`. Its module and binding changes are instant until then and wait for the vault's timelock afterwards.
- Call `NavOracle::bind_vault(vault_address)`, then `NavOracle::finish_setup()`. The oracle's admin changes are instant until then and wait for the vault's timelock afterwards.
- On the vault, call `set_custodian(custodian)`, `set_notice(secs)` and, if initial capital intake is bounded, `set_deposit_cap(cap)`. All three are instant during setup, so deposits never open without the cap.
- Then call `finish_setup()` on the vault. It refuses with `SetupIncomplete` until every wiring step above is in place, the token has no shares outstanding and the vault's address is not frozen on it, and with `AdminTransferPending` while an admin hand-over offered during setup is pending. The vault takes no deposit before it.
@@ -304,7 +314,7 @@ instance is archived refuses every call.
| `nav_oracle` | Its instance, the latest report, and each pending governance change it reads or writes | `attest` extends the instance and the report; `bind_vault`, `finish_setup`, `grant_role`, `transfer_admin_role`, `propose_change`, `cancel_change` and `apply_change` extend the instance; every other call extends whichever entry it reads or writes | Same |
| `share_token` | Its instance, and each pending governance change it reads or writes | Every call that moves shares or changes token settings: `transfer`, `transfer_from`, `approve`, `mint`, `burn`, `forced_transfer`, `legal_transfer`, freezes, recovery, `set_compliance`, `set_identity_verifier`, `pause`, `unpause`, `grant_role`, `revoke_role`, `transfer_admin_role`, `bind_vault`, `finish_setup`, `propose_change`, `cancel_change`, `apply_change` | Same |
| `identity_verifier` | Its instance and each allowlist entry | `allow`, `is_allowed`, `verify_identity` | Same |
-| `compliance` | Its instance | Every hook the token calls (`can_create`, `can_transfer`, `created`, `transferred`, `destroyed`) and its admin calls | Same |
+| `compliance` | Its instance, and each pending governance change it reads or writes | Every hook the token calls (`can_create`, `can_transfer`, `created`, `transferred`, `destroyed`) and its admin calls | Same |
The lengths are the constants in `crates/storage`
(`PERSISTENT_EXTEND_AMOUNT`, `INSTANCE_EXTEND_AMOUNT`, and thresholds one day
@@ -312,7 +322,7 @@ shorter). They are fixed in code: a governance setting would add an admin power
for little gain.
**Pending governance changes.** A change waiting in the timelock of
-`async_vault`, `share_token` or `nav_oracle` lives as a persistent entry,
+`async_vault`, `share_token`, `nav_oracle` or `compliance` lives as a persistent entry,
extended to 30 days when it is proposed. The wait itself can run up to 90 days
(`timelock::MAX_DELAY`), so a change nobody touches can archive before it is
due. Nothing is lost: the call that applies it restores the archived entry
@@ -372,14 +382,18 @@ A feed that implements `OracleFeed` can replace `nav-oracle`; it must report
`Stale` once its report outlives `freshness_duration`, and `attested_at` must
be the ledger time the report was accepted, not a time the reporter supplies.
-Two more traits connect the share token and the oracle to the vault's
-timelock. The vault implements `ClockSource` (`crates/timelock`), and the share
-token and the oracle read its `timelock_clock` to time their own changes. Both
-implement `VaultBinding` (`crates/bindings`): `vault` names the vault each is
-bound to, and `setup_open` says whether its admin changes are still instant.
-
-The vault's `finish_setup` reads both through `VaultBinding` and refuses until
-each reports this vault with its own setup closed. It also reads the token's
+Two more traits connect the share token, the oracle and the kit's compliance
+contract to the vault's timelock. The vault implements `ClockSource`
+(`crates/timelock`), and the three read its `timelock_clock` to time their own
+changes. Each implements `VaultBinding` (`crates/bindings`): `vault` names the
+vault it is bound to, and `setup_open` says whether its admin changes are still
+instant.
+
+The vault's `finish_setup` reads the token and the oracle through
+`VaultBinding` and refuses until each reports this vault with its own setup
+closed. It reads the compliance contract the same way, but accepts one that
+does not answer `VaultBinding`: such an external module keeps its own
+governance, which is a trust assumption. It also reads the token's
`manager` members (OpenZeppelin `AccessControl`) and its `total_supply`
(`FungibleToken`), runs `verify_identity` on its own address, and reads
`linked_tokens` from the token's compliance contract (OpenZeppelin
diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md
index 8228d95d..cd2caba9 100644
--- a/docs/strata-product-and-architecture.md
+++ b/docs/strata-product-and-architecture.md
@@ -598,7 +598,7 @@ public surface given the vault address.
| Boundary | Risk | Mitigation |
| ---------------------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Attestation authority | Wrong or compromised reports misprice requests | Multisig reporter; the deviation cap bounds a single report in both directions, and a downward cap of 100% leaves `min_answer` as the only floor; the cooldown bounds frequency; a sustained sequence of biased reports within the cap remains possible, is bounded in speed, and is the monitoring plan's primary alert, with the guardian pause as the reactive control. Residual risk: value transfer between entry and exit cohorts |
-| Governance keys | Malicious upgrade | Timelock on every code change and on the delay itself, bounded by a minimum no shorter than the standing notice and a maximum ceiling (90 days). A pause freezes the timelock clock and pushes the ETA by the duration paused, guaranteeing that investors have an unpaused exit window before any code change applies; applying while paused is refused; nothing is proposed or applied while the vault winds down. The vault's own custodian, role grants, `manager` revocations, admin handovers and lowering of the notice or the wind-down delay wait on that clock once its one-time setup closes, and it takes no deposit before then; revoking its treasury or guardian stays instant. Setup closes only once the token and the oracle run on the vault's clock with their own setup closed, the vault is the token's only minter, no shares exist yet (shares minted earlier would redeem against depositors' money), the vault passes the identity check, its address is not frozen on the token and the token is bound to its compliance contract. The share token's upgrades, grants, `manager` revocations, admin handovers and compliance or verifier swaps wait on the same clock once its setup closes. So do the oracle's upgrades, grants, admin handovers and every guardrail change except a shorter freshness window; its NAV pause, `clear_latest` and revoking its attester or guardian stay instant. |
+| Governance keys | Malicious upgrade | Timelock on every code change and on the delay itself, bounded by a minimum no shorter than the standing notice and a maximum ceiling (90 days). A pause freezes the timelock clock and pushes the ETA by the duration paused, guaranteeing that investors have an unpaused exit window before any code change applies; applying while paused is refused; nothing is proposed or applied while the vault winds down. The vault's own custodian, role grants, `manager` revocations, admin handovers and lowering of the notice or the wind-down delay wait on that clock once its one-time setup closes, and it takes no deposit before then; revoking its treasury or guardian stays instant. Setup closes only once the token and the oracle run on the vault's clock with their own setup closed, the vault is the token's only minter, no shares exist yet (shares minted earlier would redeem against depositors' money), the vault passes the identity check, its address is not frozen on the token and the token is bound to its compliance contract; the kit's own compliance contract must also run on the vault's clock with its setup closed. The share token's upgrades, grants, `manager` revocations, admin handovers and compliance or verifier swaps wait on the same clock once its setup closes. So do the oracle's upgrades, grants, admin handovers and every guardrail change except a shorter freshness window; its NAV pause, `clear_latest` and revoking its attester or guardian stay instant. On the kit's compliance contract, adding or removing a module, binding or unbinding a token and handing over the admin wait on the same clock once its setup closes. |
| Mis-wired deployment | A vault goes live without every binding wired | `health()` reports each wiring step; `finish_setup` refuses until all hold, and the vault takes no deposit before that |
| Treasury keys | Reserve drained | Only free reserve is movable, only to the genesis-configured custodian, verified on-chain; outbound transfers are blocked while anything is uncovered, and escrowed subscriptions never leave the vault |
| Guardian keys | Griefing via pause | Guardian can only pause the vault, which stops deposit requests, epoch pricing and upgrade application and freezes the upgrade timelock; it cannot halt redemptions, custodian deployments, or payable claims, nor move funds; governance unpauses and rotates the vault role. It holds no role on the share token: the token's transfer pause is admin-gated (governance by deploy), not a guardian lever. No contract keeps governance and guardian apart |
diff --git a/environments.toml b/environments.toml
index 72e244f7..8049cff7 100644
--- a/environments.toml
+++ b/environments.toml
@@ -54,11 +54,11 @@ constructor_args = '--asset share_token --share_token share_token --oracle nav_o
# scripts/harness performs and verifies both.
#
# Setup cannot finish from this file either. The vault's `finish_setup` checks
-# the token and oracle bindings, the manager role, the allowlist and the
-# compliance binding, which only the harness deploy wires, and that the token
-# has no shares outstanding. A deploy made only from this file leaves the
-# token's, the oracle's and the vault's setup open, so its vault refuses
-# deposits with `SetupOpen`. The harness deploy closes all three.
+# the token, oracle and compliance bindings, the manager role and the
+# allowlist, which only the harness deploy wires, and that the token has no
+# shares outstanding. A deploy made only from this file leaves the compliance
+# contract's, the token's, the oracle's and the vault's setup open, so its
+# vault refuses deposits with `SetupOpen`. The harness deploy closes all four.
### Staging environment configuration
From 89c3f7d40dd25d4e774c885dac8a9f1af08c7cf4 Mon Sep 17 00:00:00 2001
From: hpmaxi <358059+hpmaxi@users.noreply.github.com>
Date: Sat, 17 Oct 2026 16:31:59 -0300
Subject: [PATCH 3/3] fix(compliance): address timelock review round 1
---
contracts/compliance/src/test/changes.rs | 78 ++++++++++++++++++++++--
docs/AUTHORIZATION_MATRIX.md | 4 +-
docs/TESTING.md | 2 +-
docs/strata-product-and-architecture.md | 9 ++-
scripts/harness/guards.test.ts | 3 -
5 files changed, 82 insertions(+), 14 deletions(-)
diff --git a/contracts/compliance/src/test/changes.rs b/contracts/compliance/src/test/changes.rs
index 2c5bc55f..31b6e9d1 100644
--- a/contracts/compliance/src/test/changes.rs
+++ b/contracts/compliance/src/test/changes.rs
@@ -1,7 +1,7 @@
use soroban_sdk::{
contract, contractimpl, symbol_short,
- testutils::{Address as _, Events as _, Ledger as _},
- vec, Address, Env, Event as _,
+ testutils::{Address as _, Events as _, Ledger as _, MockAuth, MockAuthInvoke},
+ vec, Address, Env, Event as _, IntoVal, Val, Vec,
};
use stellar_access::role_transfer::RoleTransferError;
use stellar_tokens::rwa::compliance::ComplianceHook;
@@ -69,6 +69,20 @@ impl Fixture<'_> {
self.rules.apply_change(&id, &self.admin);
}
+ /// Signs the next call to the compliance contract as `who` and nothing
+ /// else, so the call's own authorisation check is what refuses it.
+ fn sign_only_as(&self, who: &Address, fn_name: &str, args: Vec) {
+ self.e.mock_auths(&[MockAuth {
+ address: who,
+ invoke: &MockAuthInvoke {
+ contract: &self.rules.address,
+ fn_name,
+ args,
+ sub_invokes: &[],
+ },
+ }]);
+ }
+
/// Lets the instance decay past its extend threshold, then checks the call
/// extends it back to the full TTL.
fn restores_the_instance(&self, call: impl FnOnce()) {
@@ -192,12 +206,17 @@ fn the_contract_answers_the_view_the_vault_reads() {
#[test]
fn only_the_admin_binds_and_finishes() {
let f = unbound();
- let stranger = Address::generate(&f.e);
f.e.set_auths(&[]);
-
- assert!(f.rules.try_bind_vault(&f.clock.address, &stranger).is_err());
- assert!(f.rules.try_finish_setup(&stranger).is_err());
+ assert!(f
+ .rules
+ .try_bind_vault(&f.clock.address, &Address::generate(&f.e))
+ .is_err());
assert_eq!(f.rules.vault(), None);
+
+ let f = bound();
+ f.e.set_auths(&[]);
+ assert!(f.rules.try_finish_setup(&Address::generate(&f.e)).is_err());
+ assert!(f.rules.setup_open());
}
#[test]
@@ -458,6 +477,53 @@ fn setup_cannot_finish_while_a_hand_over_is_pending() {
assert!(!f.rules.setup_open());
}
+#[test]
+fn only_the_admin_offers_or_cancels_a_hand_over() {
+ let f = bound();
+ let next = Address::generate(&f.e);
+ let stranger = Address::generate(&f.e);
+ let live_until = f.e.ledger().sequence() + 100;
+
+ f.sign_only_as(
+ &stranger,
+ "transfer_admin_role",
+ (next.clone(), live_until).into_val(&f.e),
+ );
+ assert!(f.rules.try_transfer_admin_role(&next, &live_until).is_err());
+ f.e.mock_all_auths();
+ assert!(f.rules.try_accept_admin_transfer().is_err());
+
+ f.rules.transfer_admin_role(&next, &live_until);
+ f.sign_only_as(
+ &stranger,
+ "transfer_admin_role",
+ (next.clone(), 0_u32).into_val(&f.e),
+ );
+ assert!(f.rules.try_transfer_admin_role(&next, &0).is_err());
+
+ f.e.mock_all_auths();
+ f.rules.accept_admin_transfer();
+ assert_eq!(f.rules.get_admin(), Some(next));
+}
+
+#[test]
+fn only_the_nominee_accepts_a_hand_over() {
+ let f = bound();
+ let next = Address::generate(&f.e);
+ f.rules
+ .transfer_admin_role(&next, &(f.e.ledger().sequence() + 100));
+
+ for other in [Address::generate(&f.e), f.admin.clone()] {
+ f.sign_only_as(&other, "accept_admin_transfer", Vec::new(&f.e));
+ assert!(f.rules.try_accept_admin_transfer().is_err());
+ }
+ assert_eq!(f.rules.get_admin(), Some(f.admin.clone()));
+
+ f.sign_only_as(&next, "accept_admin_transfer", Vec::new(&f.e));
+ f.rules.accept_admin_transfer();
+ assert_eq!(f.rules.get_admin(), Some(next));
+}
+
#[test]
fn a_cancelled_hand_over_no_longer_holds_setup_open() {
let f = bound();
diff --git a/docs/AUTHORIZATION_MATRIX.md b/docs/AUTHORIZATION_MATRIX.md
index 4661fc24..2bbbc499 100644
--- a/docs/AUTHORIZATION_MATRIX.md
+++ b/docs/AUTHORIZATION_MATRIX.md
@@ -190,5 +190,5 @@ Tests: [`contracts/compliance/src/test/changes.rs`](../contracts/compliance/src/
| `cancel_change` | `#[only_admin]` | The change must be pending | `test::changes::a_cancelled_change_never_lands` |
| `apply_change` | `#[only_admin]` | Delay run on the vault's clock, vault unpaused and not winding down, delay `>=` the vault's notice | `test::changes::a_proposed_binding_lands_after_the_delay`, `test::changes::nothing_lands_while_the_vault_is_paused_or_winding_down`, `test::changes::a_vault_pause_pushes_the_change_back`, `test::changes::a_change_shorter_than_a_raised_notice_never_lands` |
| `pending_changes` | Open view | None | None (view) |
-| `transfer_admin_role` | Admin | Instant during setup; `UseTimelock` after. A zero `live_until_ledger` cancels at once | `test::changes::after_setup_the_admin_hands_over_through_the_timelock`, `test::changes::a_cancelled_hand_over_no_longer_holds_setup_open`, `test::changes::a_hand_over_after_setup_is_cancelled_at_once` |
-| `accept_admin_transfer` | The pending admin's auth | A hand-over is pending; an applied `Admin` change stays open for seven days of ledgers | `test::changes::setup_cannot_finish_while_a_hand_over_is_pending`, `test::changes::the_hand_over_offer_lasts_seven_days_of_ledgers` |
+| `transfer_admin_role` | Admin | Instant during setup; `UseTimelock` after. A zero `live_until_ledger` cancels at once | `test::changes::only_the_admin_offers_or_cancels_a_hand_over`, `test::changes::after_setup_the_admin_hands_over_through_the_timelock`, `test::changes::a_cancelled_hand_over_no_longer_holds_setup_open`, `test::changes::a_hand_over_after_setup_is_cancelled_at_once` |
+| `accept_admin_transfer` | The pending admin's auth | A hand-over is pending; an applied `Admin` change stays open for seven days of ledgers | `test::changes::only_the_nominee_accepts_a_hand_over`, `test::changes::setup_cannot_finish_while_a_hand_over_is_pending`, `test::changes::the_hand_over_offer_lasts_seven_days_of_ledgers` |
diff --git a/docs/TESTING.md b/docs/TESTING.md
index 1a6a2d47..310827f4 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -82,7 +82,7 @@ nothing.
| Share token: only the admin binds the vault, finishes setup and runs the timelock | [`contracts/share-token/src/test_governance.rs`](../contracts/share-token/src/test_governance.rs) | `only_the_admin_binds_and_finishes`, `only_the_admin_proposes_cancels_and_applies` |
| Oracle: only the attester posts, only the guardian pauses the NAV | [`contracts/nav-oracle/src/test.rs`](../contracts/nav-oracle/src/test.rs) | `attest_is_role_gated`, `pausing_the_nav_is_limited_to_the_guardian`, `only_admin_can_set_config` |
| Oracle: only the admin binds the vault, finishes setup, runs the timelock and revokes a role | [`contracts/nav-oracle/src/test_governance.rs`](../contracts/nav-oracle/src/test_governance.rs) | `only_the_admin_binds_and_finishes`, `only_the_admin_proposes_cancels_and_applies`, `only_the_admin_revokes_a_role` |
-| Compliance contract: only the admin binds the vault, finishes setup and runs the timelock | [`contracts/compliance/src/test/changes.rs`](../contracts/compliance/src/test/changes.rs) | `only_the_admin_binds_and_finishes`, `only_the_admin_proposes_cancels_and_applies` |
+| Compliance contract: only the admin binds the vault, finishes setup, runs the timelock and offers or cancels a hand-over; only the nominee accepts it | [`contracts/compliance/src/test/changes.rs`](../contracts/compliance/src/test/changes.rs) | `only_the_admin_binds_and_finishes`, `only_the_admin_proposes_cancels_and_applies`, `only_the_admin_offers_or_cancels_a_hand_over`, `only_the_nominee_accepts_a_hand_over` |
| Vault: only the admin finishes setup; after it, the custodian, grants, `manager` revokes, hand-overs and a lower notice or wind-down delay go through the timelock, while revoking the treasury or the guardian stays instant | [`contracts/async-vault/src/test/setup.rs`](../contracts/async-vault/src/test/setup.rs), [`contracts/async-vault/src/test/governance.rs`](../contracts/async-vault/src/test/governance.rs) | `only_governance_finishes_setup`, `the_custodian_changes_through_the_timelock`, `grants_and_manager_revokes_wait_for_the_timelock`, `revoking_treasury_or_guardian_stays_instant`, `only_the_admin_revokes_a_role` |
| Identity verifier: only the admin edits the allowlist | [`contracts/identity-verifier/src/test.rs`](../contracts/identity-verifier/src/test.rs) | `only_admin_can_allow` |
diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md
index cd2caba9..7a8aaaab 100644
--- a/docs/strata-product-and-architecture.md
+++ b/docs/strata-product-and-architecture.md
@@ -289,7 +289,7 @@ number with a proof reference.
| **Vault** | Request lifecycle, epoch sealing by its manager role, pricing, split reserve accounting, custodian transfers, upgrade control | One authority per privileged entrypoint; upgrades behind a governance timelock no shorter than the notice, bounded between 7 and 90 days. The share token and the oracle upgrade under the same timelock. Administrative handover follows a strict two-step transfer pattern; renouncing admin is permanently refused (`AdminRequired`) |
| **Share token** | OZ SEP-41 + SEP-57 RWA extensions: freeze, forced transfer, recovery, identity and compliance checks on every transfer, independent transfer pause (admin-gated; governance by deploy) | Manager role held only by the vault, for mint, burn and escrow; compliance role held by the compliance authority, for freezes, plus legal transfers and recovery co-signed by the token admin; swapping the compliance or identity-verifier contract needs the admin and, after a one-time setup, waits for the vault's timelock |
| **Token roles** | Access-control roles on the share token, checked directly via native access control: `manager` mints, burns and escrows; `compliance` freezes, and makes legal transfers and recoveries co-signed by the token admin | Granted and revoked by the token admin (governance by deploy). After a one-time setup, a grant, revoking `manager` and an admin handover wait for the vault's timelock; revoking `compliance` stays instant |
-| **Compliance module** | Implements the SEP-57 identity and rules interfaces the share token consults, with the allowlist as its only rule | Managed by the compliance authority via direct role access; replaceable by OZ's identity verifier and compliance contracts (with RWA Wizard modules) without touching the token |
+| **Compliance module** | Implements the SEP-57 identity and rules interfaces the share token consults, with the allowlist as its only rule | The compliance authority writes the allowlist on the identity verifier. Governance runs the compliance contract as its admin: adding or removing a module and binding or unbinding a token are instant only during its one-time setup, and after it wait for the vault's timelock, cannot land while the vault is paused and never land during a wind-down. Replaceable by OZ's identity verifier and compliance contracts (with RWA Wizard modules); after setup, the token's swap to them waits for the vault's timelock |
| **Authorities** | governance (parameters, roles, timelocked upgrades), compliance (allowlist, token interventions), attestation (valuation only), treasury (reserve movements only), guardian (pause; never payable claims) | Each contract grants its own roles; the oracle's, share token's and verifier's admins and the oracle's attester and guardian are constructor parameters independent of the vault's roles, equal to governance, compliance and guardian by deploy only; treasury and guardian distinct, treasury and governance distinct, compliance distinct from governance and treasury; governance and guardian may coincide; the vault's manager role is unconstrained |
| **Custodian** | Off-chain party holding the real-world structure; a genesis-configured slot rotatable only by governance | Not an on-chain authority |
@@ -459,6 +459,11 @@ interface today.
and symmetrically, a redemption priced at zero assets pays nothing, and once
the wind-down is active its claim burns the escrowed shares instead of
returning them.
+- A compliance module can block wind-down claims. A claim that surrenders shares
+ burns them through the token, which calls the compliance contract's
+ `destroyed` hook, so a module that starts failing during a wind-down blocks
+ those claims; it cannot be removed then, because removing a module waits for
+ the timelock and no timelocked change applies during a wind-down.
- Funding a vault that is winding down cannot be undone. What arrives first
covers priced exits and refundable escrow; the surplus is taken by the next
round and credited to holders, and no entrypoint returns it. Treasury sends
@@ -604,7 +609,7 @@ public surface given the vault address.
| Guardian keys | Griefing via pause | Guardian can only pause the vault, which stops deposit requests, epoch pricing and upgrade application and freezes the upgrade timelock; it cannot halt redemptions, custodian deployments, or payable claims, nor move funds; governance unpauses and rotates the vault role. It holds no role on the share token: the token's transfer pause is admin-gated (governance by deploy), not a guardian lever. No contract keeps governance and guardian apart |
| Share-token pause | Cancellations and secondary transfers halted | Admin-gated (governance by deploy; the token has no guardian role); it refuses SEP-41 transfers and escrowed-share returns only, never pricing, claims, mint, burn or forced transfer; only the token admin lifts it (§8.5) |
| Compliance keys | Wrongful delisting, freeze or transfer | Delisted investors keep the exit-only cash path; freezes require the share token's compliance role and are auditable per operation; moving a holder's shares also needs governance's signature in the same call, lands only on an allowlisted account and never moves shares out of or into the vault's address, which cannot be frozen once the token knows its vault |
-| Compliance module | Faulty module blocks transfers | Fail-closed semantics; replaceable by governance without touching the token |
+| Compliance module | Faulty module blocks transfers | Fail-closed semantics. Governance, the compliance contract's admin, adds and removes modules: instantly during its one-time setup, and after it only through the vault's timelock, so a change cannot land while the vault is paused and never lands during a wind-down. Removing a faulty module waits the same way, and during a wind-down it cannot be removed at all (§8.7) |
| Deposit asset issuer | Freeze or clawback of the vault's reserve | Not mitigated by the kit; declared risk of the chosen asset, verified and reported at genesis (auth flags) |
| Custodian / real world | Underlying loss or delay | Reflected through attested NAV; the kit constrains what reaches the chain, it does not verify the world |
| Wind-down distribution | Unfinalized rounds stall distribution | Finalizing a round is permissionless: once treasury returns capital, anyone can finalize the round to credit holders; the operator cannot stall payouts after funding |
diff --git a/scripts/harness/guards.test.ts b/scripts/harness/guards.test.ts
index da66313e..a4c4ffa4 100644
--- a/scripts/harness/guards.test.ts
+++ b/scripts/harness/guards.test.ts
@@ -317,9 +317,6 @@ describe("the compliance contract after setup", () => {
rules,
"UseTimelock",
)
- expect(
- (await rules.get_modules_for_hook({ hook: canCreate })).result,
- ).toEqual([])
})
it("queues a module behind the vault's delay", async () => {