Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 58 additions & 9 deletions contracts/allowlist-token/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ use soroban_sdk::{contract, contracterror, contractevent, contractimpl, contract
enum DataKey {
/// The admin address, set once in `initialize`. Instance storage.
Admin,
PendingAdmin,
ComplianceOfficer,
Token,
/// Whether a given address is on the allowlist. Persistent storage,
/// keyed per address.
Expand Down Expand Up @@ -104,20 +104,46 @@ impl AllowlistToken {
Ok(())
}

/// Add `address` to the allowlist. Admin-only. Blocked while paused.
pub fn add_to_allowlist(env: Env, admin: Address, address: Address) -> Result<(), Error> {
compliance_pausable::require_not_paused(&env, Error::ContractPaused)?;
/// Assign the compliance-officer role to `officer`. Admin-only.
/// A compliance officer may call `add_to_allowlist` and
/// `remove_from_allowlist` but may NOT assign or revoke the role.
pub fn set_compliance_officer(
env: Env,
admin: Address,
officer: Address,
) -> Result<(), Error> {
Self::require_admin(&env, &admin)?;
env.storage()
.instance()
.set(&DataKey::ComplianceOfficer, &officer);
Ok(())
}

/// Revoke the compliance-officer role. Admin-only.
pub fn revoke_compliance_officer(env: Env, admin: Address) -> Result<(), Error> {
Self::require_admin(&env, &admin)?;
env.storage().persistent().set(&DataKey::Allowed(address.clone()), &true);
env.storage()
.instance()
.remove(&DataKey::ComplianceOfficer);
Ok(())
}

/// Add `address` to the allowlist. Admin or compliance-officer.
pub fn add_to_allowlist(env: Env, admin: Address, address: Address) -> Result<(), Error> {
Self::require_compliance_authority(&env, &admin)?;
env.storage()
.persistent()
.set(&DataKey::Allowed(address.clone()), &true);
AllowAdd { address }.publish(&env);
Ok(())
}

/// Remove `address` from the allowlist. Admin-only. Blocked while paused.
/// Remove `address` from the allowlist. Admin or compliance-officer.
pub fn remove_from_allowlist(env: Env, admin: Address, address: Address) -> Result<(), Error> {
compliance_pausable::require_not_paused(&env, Error::ContractPaused)?;
Self::require_admin(&env, &admin)?;
env.storage().persistent().remove(&DataKey::Allowed(address.clone()));
Self::require_compliance_authority(&env, &admin)?;
env.storage()
.persistent()
.remove(&DataKey::Allowed(address.clone()));
AllowRemove { address }.publish(&env);
Ok(())
}
Expand Down Expand Up @@ -248,6 +274,29 @@ impl AllowlistToken {
}
Ok(())
}

/// Checks that `caller` is either the admin or the compliance officer.
fn require_compliance_authority(env: &Env, caller: &Address) -> Result<(), Error> {
caller.require_auth();
let stored_admin: Address = env
.storage()
.instance()
.get(&DataKey::Admin)
.ok_or(Error::NotInitialized)?;
if stored_admin == *caller {
return Ok(());
}
if let Some(officer) = env
.storage()
.instance()
.get(&DataKey::ComplianceOfficer)
{
if officer == *caller {
return Ok(());
}
}
Err(Error::NotAuthorized)
}
}

#[cfg(test)]
Expand Down
105 changes: 64 additions & 41 deletions contracts/allowlist-token/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -300,76 +300,99 @@ fn test_remove_from_allowlist_emits_allow_remove_event() {
);
}

// ── compliance-officer tests ───────────────────────────────────────────

#[test]
fn test_admin_transfer_propose_and_accept_flow() {
fn test_admin_can_set_and_revoke_compliance_officer() {
let env = Env::default();
let (admin, _token_id, _contract_id, client) = setup(&env);
let new_admin = Address::generate(&env);
let alice = Address::generate(&env);
let officer = Address::generate(&env);

client.propose_admin(&admin, &new_admin);
// Set compliance officer
client.set_compliance_officer(&admin, &officer);

// Existing admin remains effective until the pending admin accepts.
client.add_to_allowlist(&admin, &alice);
let pre_accept_remove = client.try_remove_from_allowlist(&new_admin, &alice);
assert_eq!(pre_accept_remove, Err(Ok(Error::NotAuthorized)));
// Officer can add to allowlist
let alice = Address::generate(&env);
client.add_to_allowlist(&officer, &alice);
assert!(client.is_allowed(&alice));

client.accept_admin(&new_admin);
// Revoke compliance officer
client.revoke_compliance_officer(&admin);

// Now officer cannot add another address
let bob = Address::generate(&env);
let result = client.try_add_to_allowlist(&officer, &bob);
assert_eq!(result, Err(Ok(Error::NotAuthorized)));
assert!(!client.is_allowed(&bob));
}

#[test]
fn test_compliance_officer_can_add_and_remove() {
let env = Env::default();
let (admin, _token_id, _contract_id, client) = setup(&env);
let officer = Address::generate(&env);
client.set_compliance_officer(&admin, &officer);

let alice = Address::generate(&env);

let old_admin_remove = client.try_remove_from_allowlist(&admin, &alice);
assert_eq!(old_admin_remove, Err(Ok(Error::NotAuthorized)));
// Officer can add
client.add_to_allowlist(&officer, &alice);
assert!(client.is_allowed(&alice));

client.remove_from_allowlist(&new_admin, &alice);
// Officer can remove
client.remove_from_allowlist(&officer, &alice);
assert!(!client.is_allowed(&alice));
}

#[test]
fn test_accept_admin_rejects_wrong_address() {
fn test_compliance_officer_cannot_set_or_revoke_role() {
let env = Env::default();
let (admin, _token_id, _contract_id, client) = setup(&env);
let proposed = Address::generate(&env);
let wrong = Address::generate(&env);
let officer = Address::generate(&env);
client.set_compliance_officer(&admin, &officer);

let another = Address::generate(&env);

client.propose_admin(&admin, &proposed);
// Officer cannot set another compliance officer
let result = client.try_set_compliance_officer(&officer, &another);
assert_eq!(result, Err(Ok(Error::NotAuthorized)));

let result = client.try_accept_admin(&wrong);
assert_eq!(result, Err(Ok(Error::PendingAdminMismatch)));
// Officer cannot revoke own role
let result = client.try_revoke_compliance_officer(&officer);
assert_eq!(result, Err(Ok(Error::NotAuthorized)));

// Officer still has their role
let alice = Address::generate(&env);
client.add_to_allowlist(&officer, &alice);
assert!(client.is_allowed(&alice));
}

#[test]
fn test_propose_admin_rejects_non_admin() {
fn test_admin_can_still_perform_compliance_actions() {
let env = Env::default();
let (admin, _token_id, _contract_id, client) = setup(&env);
let impostor = Address::generate(&env);
let new_admin = Address::generate(&env);
let alice = Address::generate(&env);
let officer = Address::generate(&env);
client.set_compliance_officer(&admin, &officer);

let result = client.try_propose_admin(&impostor, &new_admin);
assert_eq!(result, Err(Ok(Error::NotAuthorized)));
let alice = Address::generate(&env);

// Admin can still add/remove directly
client.add_to_allowlist(&admin, &alice);
assert!(client.is_allowed(&alice));

client.remove_from_allowlist(&admin, &alice);
assert!(!client.is_allowed(&alice));
}

#[test]
fn test_accept_admin_emits_admin_transferred_event() {
fn test_unset_officer_rejected_for_compliance_actions() {
let env = Env::default();
let (admin, _token_id, contract_id, client) = setup(&env);
let new_admin = Address::generate(&env);

client.propose_admin(&admin, &new_admin);
client.accept_admin(&new_admin);
let (_admin, _token_id, _contract_id, client) = setup(&env);

assert_eq!(
env.events().all(),
vec![
&env,
(
contract_id.clone(),
(Symbol::new(&env, "admin_transferred"), admin.clone(), new_admin.clone()).into_val(&env),
Map::<Symbol, Val>::new(&env).into_val(&env),
),
]
);
// No compliance officer set — unknown address cannot act
let rando = Address::generate(&env);
let alice = Address::generate(&env);
let result = client.try_add_to_allowlist(&rando, &alice);
assert_eq!(result, Err(Ok(Error::NotAuthorized)));
assert!(!client.is_allowed(&alice));
}
67 changes: 40 additions & 27 deletions contracts/denylist-gate/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,7 @@ const MAX_BATCH_SIZE: u32 = 100;
enum DataKey {
/// The admin address, set once in `initialize`. Instance storage.
Admin,
/// Whether a given address is on the denylist. Persistent storage,
/// keyed per address.
ComplianceOfficer,
Denied(Address),
/// Optional address of an `audit-log` contract to emit structured
/// compliance events to. Not set by default — must be explicitly
Expand Down Expand Up @@ -112,23 +111,33 @@ impl DenylistGate {
Ok(())
}

/// Register an `audit-log` contract address. Admin-only. Once set,
/// state-mutating calls (`add_to_denylist`, `remove_from_denylist`) will
/// additionally call `audit_log.record(...)` via cross-contract
/// invocation so that a single contract instance aggregates compliance
/// events across all primitives.
pub fn set_audit_log(env: Env, admin: Address, audit_log: Address) -> Result<(), Error> {
/// Assign the compliance-officer role to `officer`. Admin-only.
/// A compliance officer may call `add_to_denylist` and
/// `remove_from_denylist` but may NOT assign or revoke the role.
pub fn set_compliance_officer(
env: Env,
admin: Address,
officer: Address,
) -> Result<(), Error> {
Self::require_admin(&env, &admin)?;
env.storage()
.instance()
.set(&DataKey::AuditLog, &audit_log);
.set(&DataKey::ComplianceOfficer, &officer);
Ok(())
}

/// Add `address` to the denylist. Admin-only.
pub fn add_to_denylist(env: Env, admin: Address, address: Address) -> Result<(), Error> {
compliance_pausable::require_not_paused(&env, Error::ContractPaused)?;
/// Revoke the compliance-officer role. Admin-only.
pub fn revoke_compliance_officer(env: Env, admin: Address) -> Result<(), Error> {
Self::require_admin(&env, &admin)?;
env.storage()
.instance()
.remove(&DataKey::ComplianceOfficer);
Ok(())
}

/// Add `address` to the denylist. Admin or compliance-officer.
pub fn add_to_denylist(env: Env, admin: Address, address: Address) -> Result<(), Error> {
Self::require_compliance_authority(&env, &admin)?;
env.storage()
.persistent()
.set(&DataKey::Denied(address.clone()), &true);
Expand All @@ -149,10 +158,9 @@ impl DenylistGate {
Ok(())
}

/// Remove `address` from the denylist. Admin-only. Blocked while paused.
/// Remove `address` from the denylist. Admin or compliance-officer.
pub fn remove_from_denylist(env: Env, admin: Address, address: Address) -> Result<(), Error> {
compliance_pausable::require_not_paused(&env, Error::ContractPaused)?;
Self::require_admin(&env, &admin)?;
Self::require_compliance_authority(&env, &admin)?;
env.storage()
.persistent()
.remove(&DataKey::Denied(address.clone()));
Expand Down Expand Up @@ -213,22 +221,27 @@ impl DenylistGate {
Ok(())
}

/// If an audit-log address has been configured, call `record` on it with
/// the contract's own address as the `source`. This is the opt-in path:
/// if `DataKey::AuditLog` is not set, this function is a no-op.
fn maybe_record(env: &Env, subject: &Address, kind: Symbol, detail: String) {
if let Some(audit_log_address) = env
/// Checks that `caller` is either the admin or the compliance officer.
fn require_compliance_authority(env: &Env, caller: &Address) -> Result<(), Error> {
caller.require_auth();
let stored_admin: Address = env
.storage()
.instance()
.get(&DataKey::Admin)
.ok_or(Error::NotInitialized)?;
if stored_admin == *caller {
return Ok(());
}
if let Some(officer) = env
.storage()
.instance()
.get::<DataKey, Address>(&DataKey::AuditLog)
.get(&DataKey::ComplianceOfficer)
{
let client = AuditLogClient::new(env, &audit_log_address);
// The source is this contract itself — Soroban's auth model
// allows a contract to authorize calls it makes from within its
// own execution context.
let source = env.current_contract_address();
client.record(&source, &kind, subject, &detail);
if officer == *caller {
return Ok(());
}
}
Err(Error::NotAuthorized)
}
}

Expand Down
Loading
Loading