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
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions contracts/escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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()))
{
Expand Down Expand Up @@ -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) {
Expand Down
87 changes: 87 additions & 0 deletions contracts/escrow/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Loading