From ddd93f89281cb78e1e2685da0ea3fae371e142b5 Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 24 Jun 2026 16:37:37 +0100 Subject: [PATCH] feat(compliance): add revoke_allow entrypoint for soft de-listing --- PR_DESCRIPTION.md | 37 +++++++++++ contracts/compliance/src/lib.rs | 18 ++++++ contracts/compliance/tests/compliance_test.rs | 62 +++++++++++++++++++ 3 files changed, 117 insertions(+) create mode 100644 PR_DESCRIPTION.md diff --git a/PR_DESCRIPTION.md b/PR_DESCRIPTION.md new file mode 100644 index 0000000..cdd5fef --- /dev/null +++ b/PR_DESCRIPTION.md @@ -0,0 +1,37 @@ +# PR: feat(compliance): add revoke_allow entrypoint for soft de-listing + +## Summary +Adds `revoke_allow(env, admin, address)` — a new entrypoint that removes the `Allowed` flag and any `AllowedUntil` expiry from an address without setting the `Blocked` flag. This enables operators to de-list an address from the allowlist without placing it on the blocklist. + +## Motivation +Previously, the only way to remove an address from the allowlist was: +- `block_address` — sets `Blocked = true`, marking the address as malicious +- `clear_address` — sets `Blocked = false, Allowed = true`, which re-allows + +Neither provides a clean "remove from allowlist but don't accuse" path. `revoke_allow` fills this gap for addresses that are no longer active but not suspected of wrongdoing. + +## Implementation +- **`contracts/compliance/src/lib.rs`**: New `revoke_allow` function in `ComplianceContract`: + - Authenticates the admin via `require_admin` + - Checks the contract is not paused via `require_not_paused` + - Removes `DataKey::Allowed` and `DataKey::AllowedUntil` from persistent storage + - Tracks the address (so `export_snapshot` captures the state transition) + - Emits an `address_revoked` event + +## Tests added +All in `contracts/compliance/tests/compliance_test.rs`: + +| Test | Description | +|------|-------------| +| `revoke_allow_removes_allowed_status` | Allow → revoke → `is_allowed` returns `false` | +| `revoke_allow_does_not_block` | After revoke, re-allow succeeds (blocked flag was not set) | +| `revoke_allow_removes_expiry` | Temp allow → revoke → `is_allowed` returns `false`, can re-allow | +| `revoke_allow_returns_unauthorized_for_non_admin` | Non-admin gets `ContractError::Unauthorized` | +| `revoke_allow_returns_contract_paused_when_paused` | When paused, returns `ContractError::ContractPaused` | + +## Verification +All 40 tests pass: `cargo test --package comebackhere-compliance` + +--- + +Closes #76 diff --git a/contracts/compliance/src/lib.rs b/contracts/compliance/src/lib.rs index 8e63a9c..6d72d82 100644 --- a/contracts/compliance/src/lib.rs +++ b/contracts/compliance/src/lib.rs @@ -157,6 +157,24 @@ impl ComplianceContract { Ok(()) } + /// Remove the allowed status for an address without blocking it. + /// This is a soft de-listing: the address is removed from the allowlist + /// but not placed on the blocklist, so it can be re-allowed later. + pub fn revoke_allow(env: Env, admin: Address, address: Address) -> Result<(), ContractError> { + Self::require_admin(&env, &admin)?; + Self::require_not_paused(&env)?; + env.storage() + .persistent() + .remove(&DataKey::Allowed(address.clone())); + env.storage() + .persistent() + .remove(&DataKey::AllowedUntil(address.clone())); + Self::track_address(&env, &address); + env.events() + .publish((Symbol::new(&env, "address_revoked"),), address); + Ok(()) + } + pub fn pause(env: Env, admin: Address) -> Result<(), ContractError> { Self::require_admin(&env, &admin)?; env.storage().instance().set(&DataKey::Paused, &true); diff --git a/contracts/compliance/tests/compliance_test.rs b/contracts/compliance/tests/compliance_test.rs index ca72736..f13d00f 100644 --- a/contracts/compliance/tests/compliance_test.rs +++ b/contracts/compliance/tests/compliance_test.rs @@ -132,6 +132,68 @@ fn clear_address_mutation_succeeds_after_unpause() { assert!(client.is_allowed(&address)); } +#[test] +fn revoke_allow_removes_allowed_status() { + let (_env, admin, subject, client) = setup(); + client.allow_address(&admin, &subject); + assert!(client.is_allowed(&subject)); + client.revoke_allow(&admin, &subject); + assert!(!client.is_allowed(&subject)); +} + +#[test] +fn revoke_allow_does_not_block() { + let (_env, admin, subject, client) = setup(); + client.allow_address(&admin, &subject); + client.revoke_allow(&admin, &subject); + // Not allowed, but also not blocked — re-allow should work + client.allow_address(&admin, &subject); + assert!(client.is_allowed(&subject)); +} + +#[test] +fn revoke_allow_removes_expiry() { + let (env, admin, subject, client) = setup(); + let now = env.ledger().timestamp(); + client.allow_address_until(&admin, &subject, &(now + 1000)); + assert!(client.is_allowed(&subject)); + client.revoke_allow(&admin, &subject); + assert!(!client.is_allowed(&subject)); + // Re-allow permanently + client.allow_address(&admin, &subject); + assert!(client.is_allowed(&subject)); +} + +#[test] +fn revoke_allow_returns_unauthorized_for_non_admin() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let non_admin = Address::generate(&env); + let address = Address::generate(&env); + let id = env.register_contract(None, ComplianceContract); + let client = ComplianceContractClient::new(&env, &id); + client.initialize(&admin); + + let result = client.try_revoke_allow(&non_admin, &address); + assert_eq!(result, Err(Ok(ContractError::Unauthorized))); +} + +#[test] +fn revoke_allow_returns_contract_paused_when_paused() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let address = Address::generate(&env); + let id = env.register_contract(None, ComplianceContract); + let client = ComplianceContractClient::new(&env, &id); + client.initialize(&admin); + client.pause(&admin); + + let result = client.try_revoke_allow(&admin, &address); + assert_eq!(result, Err(Ok(ContractError::ContractPaused))); +} + #[test] fn read_only_queries_not_blocked_by_pause() { let env = Env::default();