From df60fb055bfc5ca56ce99758f58f61c4f2377a39 Mon Sep 17 00:00:00 2001 From: Baskarayelu Date: Wed, 24 Jun 2026 10:05:57 +0530 Subject: [PATCH] feat: add per-agent blocklist with precedence over the allowlist Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 22 +++++++++ contracts/escrow/src/lib.rs | 31 +++++++++++++ contracts/escrow/src/test.rs | 87 ++++++++++++++++++++++++++++++++++++ 3 files changed, 140 insertions(+) diff --git a/README.md b/README.md index 5d82d8d..882be12 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,28 @@ history are untouched. `propose_admin_transfer` rejects proposing the current admin as the new admin (panics with `InvalidAdminProposal`). This surfaces no-op handovers as caller mistakes rather than silently storing a pending entry equal to the active admin. +### Per-agent blocklist (deny list) + +A per-agent blocklist lets the admin deny specific agents independent of the +allowlist. `set_agent_blocked(agent, blocked)` (admin-gated) toggles an agent's +entry and `is_agent_blocked(agent)` reads it back (defaulting to `false` / +not blocked when never set, so existing behaviour is unchanged when unused). + +When a blocked agent calls `record_usage`, the call panics with `AgentBlocked` +(error `#15`). The blocklist takes **precedence over the allowlist**: an agent +that is both allow-listed and blocked is still rejected with `AgentBlocked`. + +The full `record_usage` rejection precedence is: + +1. paused (`ContractPaused`, #4) +2. zero requests (`RequestsMustBePositive`, #2) +3. above per-call max (`RequestsExceedsMaxPerCall`, #8) +4. below per-call min (`RequestsBelowMinPerCall`, #9) +5. unregistered service under strict registration (`ServiceNotRegistered`, #7) +6. disabled service (`ServiceDisabled`, #12) +7. blocked agent (`AgentBlocked`, #15) +8. agent not on allowlist while enabled (`AgentNotAllowed`, #10) + ### Schema version: fresh v2 init vs. legacy v1→v2 migration `init` stamps the current storage schema version (v2) directly, so a freshly diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index 82ccfaf..6de8224 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -81,6 +81,11 @@ pub enum DataKey { /// disabled without unregistering, preserving the metadata and the /// per-(agent, service) usage history. ServiceDisabled(Symbol), + /// Per-agent blocklist (deny list) flag. When `true`, `record_usage` + /// rejects the agent with `AgentBlocked`, independent of and taking + /// precedence over the allowlist. Absent entry defaults to `false` + /// (not blocked), so existing behaviour is unchanged when unused. + AgentBlocked(Address), } /// Typed contract errors. Codes are append-only to keep client SDKs stable. @@ -124,6 +129,10 @@ pub enum EscrowError { /// proposed new admin — a no-op handover that is rejected to surface /// caller mistakes early. InvalidAdminProposal = 14, + /// `record_usage` was called by/for an agent on the per-agent + /// blocklist. Takes precedence over the allowlist: a blocked agent is + /// rejected even when it is also allow-listed. + AgentBlocked = 15, } #[contracttype] @@ -225,6 +234,9 @@ impl Escrow { if read_flag(&env, &DataKey::ServiceDisabled(service_id.clone())) { panic_with_error!(&env, EscrowError::ServiceDisabled); } + if read_flag(&env, &DataKey::AgentBlocked(agent.clone())) { + panic_with_error!(&env, EscrowError::AgentBlocked); + } if read_flag(&env, &DataKey::AllowlistEnabled) && !read_flag(&env, &DataKey::AgentAllowed(agent.clone())) { @@ -431,6 +443,25 @@ impl Escrow { write_flag(&env, &DataKey::AgentAllowed(agent), allowed); } + /// Read whether an agent is on the blocklist (false for never-set). + pub fn is_agent_blocked(env: Env, agent: Address) -> bool { + read_flag(&env, &DataKey::AgentBlocked(agent)) + } + + /// Admin sets the blocklist status for a specific agent. A blocked + /// agent is rejected by `record_usage` with `AgentBlocked`, + /// independent of the allowlist and taking precedence over it: an + /// agent that is both allow-listed and blocked is still rejected. + pub fn set_agent_blocked(env: Env, agent: Address, blocked: bool) { + let admin: Address = env + .storage() + .persistent() + .get(&DataKey::Admin) + .unwrap_or_else(|| panic_with_error!(&env, EscrowError::NotInitialized)); + admin.require_auth(); + write_flag(&env, &DataKey::AgentBlocked(agent), blocked); + } + /// Admin sets the per-call lower bound on `requests` for batched /// writes. Pass `0` to disable the floor. pub fn set_min_requests_per_call(env: Env, min_requests: u32) { diff --git a/contracts/escrow/src/test.rs b/contracts/escrow/src/test.rs index 2d924db..745e371 100644 --- a/contracts/escrow/src/test.rs +++ b/contracts/escrow/src/test.rs @@ -697,3 +697,90 @@ fn test_pause_pause_unpause_ends_unpaused() { assert!(!client.is_paused()); } + +#[test] +#[should_panic(expected = "Error(Contract, #15)")] +fn test_record_usage_rejects_blocked_agent() { + let env = Env::default(); + let (client, _admin) = setup_initialized(&env); + let agent = Address::generate(&env); + let svc = Symbol::new(&env, "infer"); + + client.set_agent_blocked(&agent, &true); + client.record_usage(&agent, &svc, &1u32); +} + +#[test] +#[should_panic(expected = "Error(Contract, #15)")] +fn test_blocklist_takes_precedence_over_allowlist() { + let env = Env::default(); + let (client, _admin) = setup_initialized(&env); + let agent = Address::generate(&env); + let svc = Symbol::new(&env, "infer"); + + // Enable the allowlist and explicitly allow the agent... + client.set_allowlist_enabled(&true); + client.set_agent_allowed(&agent, &true); + // ...but also block it: the block must win. + client.set_agent_blocked(&agent, &true); + client.record_usage(&agent, &svc, &1u32); +} + +#[test] +#[should_panic(expected = "Error(Contract, #15)")] +fn test_blocked_agent_rejected_while_allowlist_disabled() { + let env = Env::default(); + let (client, _admin) = setup_initialized(&env); + let agent = Address::generate(&env); + let svc = Symbol::new(&env, "infer"); + + // Allowlist stays disabled (its default); the block alone rejects. + assert!(!client.is_allowlist_enabled()); + client.set_agent_blocked(&agent, &true); + client.record_usage(&agent, &svc, &1u32); +} + +#[test] +fn test_unblock_then_record_succeeds() { + let env = Env::default(); + let (client, _admin) = setup_initialized(&env); + let agent = Address::generate(&env); + let svc = Symbol::new(&env, "infer"); + + client.set_agent_blocked(&agent, &true); + client.set_agent_blocked(&agent, &false); + + let record = client.record_usage(&agent, &svc, &5u32); + assert_eq!(record.requests, 5); + assert_eq!(client.get_usage(&agent, &svc), 5); +} + +#[test] +fn test_is_agent_blocked_round_trip() { + let env = Env::default(); + let (client, _admin) = setup_initialized(&env); + let agent = Address::generate(&env); + + // Defaults to false when never set. + assert!(!client.is_agent_blocked(&agent)); + client.set_agent_blocked(&agent, &true); + assert!(client.is_agent_blocked(&agent)); + client.set_agent_blocked(&agent, &false); + assert!(!client.is_agent_blocked(&agent)); +} + +#[test] +#[should_panic(expected = "Unauthorized")] +fn test_set_agent_blocked_requires_admin_auth() { + let env = Env::default(); + let contract_id = env.register_contract(None, Escrow); + let client = EscrowClient::new(&env, &contract_id); + let admin = Address::generate(&env); + env.mock_all_auths(); + client.init(&admin); + + // Drop the mocked auths so the admin require_auth is enforced. + env.set_auths(&[]); + let agent = Address::generate(&env); + client.set_agent_blocked(&agent, &true); +}