diff --git a/README.md b/README.md index 5d82d8d..cc3e715 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,17 @@ A service's metadata (`description` + `owner`) and its registration flag live in independent storage slots. `clear_service_metadata` (admin-gated, idempotent) removes only the metadata; the registration flag and per-(agent, service) usage history are untouched. +### Service pricing: removed vs. set-to-zero + +`set_service_price` stores a per-request price under +`DataKey::ServicePrice(service_id)`. `remove_service_price` (admin-gated, +honours the pause gate, idempotent) deletes that slot and emits `price_rm`. +After removal, `get_service_price` and `compute_billing` read back `0`, exactly +as for a service that was never priced. The zero-vs-removed distinction is about +storage, not the read value: removal frees the storage slot (and emits +`price_rm`), whereas `set_service_price(service_id, 0)` leaves a stored slot +holding `0`. Both cases bill to zero, but only removal reclaims the slot. + ### Admin proposal validation `propose_admin_transfer` rejects proposing the current admin as the new admin diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index 82ccfaf..7a9aef5 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -323,6 +323,37 @@ impl Escrow { .set(&DataKey::ServicePrice(service_id), &price_stroops); } + /// Remove the configured per-request price for a service, freeing the + /// `DataKey::ServicePrice(service_id)` storage slot. + /// + /// Admin-gated and honours the pause gate (panics with + /// [`EscrowError::ContractPaused`] when paused, consistent with other + /// admin mutations). Idempotent — removing the price of a service that + /// was never priced is a no-op. + /// + /// After removal, `get_service_price` and `compute_billing` read back + /// `0`, exactly as for a service that was never priced. Note the + /// zero-vs-removed distinction: removal frees the underlying storage + /// slot and emits a `price_rm` event, whereas `set_service_price(_, 0)` + /// leaves a stored slot holding `0`. Both read back as `0`, but only + /// removal reclaims the slot. Emits `price_rm(service_id)`. + pub fn remove_service_price(env: Env, service_id: Symbol) { + if read_flag(&env, &DataKey::Paused) { + panic_with_error!(&env, EscrowError::ContractPaused); + } + let admin: Address = env + .storage() + .persistent() + .get(&DataKey::Admin) + .unwrap_or_else(|| panic_with_error!(&env, EscrowError::NotInitialized)); + admin.require_auth(); + env.storage() + .persistent() + .remove(&DataKey::ServicePrice(service_id.clone())); + env.events() + .publish((symbol_short!("price_rm"),), service_id); + } + /// Get the per-request price (in stroops) for a service, or 0 if /// no price has been configured (the service is free / unset). pub fn get_service_price(env: Env, service_id: Symbol) -> i128 { diff --git a/contracts/escrow/src/test.rs b/contracts/escrow/src/test.rs index 2d924db..9c0a22b 100644 --- a/contracts/escrow/src/test.rs +++ b/contracts/escrow/src/test.rs @@ -697,3 +697,100 @@ fn test_pause_pause_unpause_ends_unpaused() { assert!(!client.is_paused()); } + +#[test] +fn test_remove_service_price_clears_price() { + let env = Env::default(); + let (client, _admin) = setup_initialized(&env); + let svc = Symbol::new(&env, "infer"); + client.set_service_price(&svc, &500i128); + assert_eq!(client.get_service_price(&svc), 500i128); + + client.remove_service_price(&svc); + + // Reads back 0, same as a never-priced service. + assert_eq!(client.get_service_price(&svc), 0i128); +} + +#[test] +fn test_remove_service_price_is_idempotent() { + let env = Env::default(); + let (client, _admin) = setup_initialized(&env); + let svc = Symbol::new(&env, "never_set"); + // Removing the price of a never-priced service is a no-op (no panic). + client.remove_service_price(&svc); + assert_eq!(client.get_service_price(&svc), 0i128); +} + +#[test] +fn test_remove_service_price_then_reset_works() { + let env = Env::default(); + let (client, _admin) = setup_initialized(&env); + let svc = Symbol::new(&env, "infer"); + client.set_service_price(&svc, &500i128); + client.remove_service_price(&svc); + assert_eq!(client.get_service_price(&svc), 0i128); + + // Re-setting after removal works and round-trips. + client.set_service_price(&svc, &750i128); + assert_eq!(client.get_service_price(&svc), 750i128); +} + +#[test] +fn test_compute_billing_zero_after_price_removed() { + let env = Env::default(); + let (client, _admin) = setup_initialized(&env); + let agent = Address::generate(&env); + let svc = Symbol::new(&env, "infer"); + client.set_service_price(&svc, &10i128); + client.record_usage(&agent, &svc, &42u32); + assert_eq!(client.compute_billing(&agent, &svc), 420i128); + + client.remove_service_price(&svc); + + // Usage is untouched, but with no price the bill is zero. + assert_eq!(client.compute_billing(&agent, &svc), 0i128); +} + +#[test] +fn test_remove_service_price_emits_price_rm_event() { + let env = Env::default(); + let (client, _admin) = setup_initialized(&env); + let svc = Symbol::new(&env, "infer"); + client.set_service_price(&svc, &500i128); + + client.remove_service_price(&svc); + + let events = env.events().all(); + assert!(!events.is_empty()); + let (_addr, topics, data) = events.last().unwrap(); + let expected_topics: soroban_sdk::Vec = + (symbol_short!("price_rm"),).into_val(&env); + assert_eq!(topics, expected_topics); + let decoded: Symbol = data.into_val(&env); + assert_eq!(decoded, svc); +} + +#[test] +#[should_panic(expected = "Error(Contract, #4)")] +fn test_remove_service_price_rejected_while_paused() { + let env = Env::default(); + let (client, _admin) = setup_initialized(&env); + let svc = Symbol::new(&env, "infer"); + client.set_service_price(&svc, &500i128); + client.pause(); + client.remove_service_price(&svc); +} + +#[test] +#[should_panic(expected = "Unauthorized")] +fn test_remove_service_price_non_admin_panics() { + let env = Env::default(); + let (client, _admin) = setup_initialized(&env); + let svc = Symbol::new(&env, "infer"); + client.set_service_price(&svc, &500i128); + // Drop the mocked auths so the admin's require_auth() is unsatisfied, + // simulating a caller without the admin signature. + env.set_auths(&[]); + client.remove_service_price(&svc); +}