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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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 @@ -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 {
Expand Down
97 changes: 97 additions & 0 deletions contracts/escrow/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<soroban_sdk::Val> =
(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);
}
Loading