diff --git a/README.md b/README.md index 5d82d8d..1b4c7da 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,13 @@ 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. + +`register_service_with_metadata(service_id, description, owner)` is an +admin-gated convenience that does both in one atomic call: it sets the +registration flag and persists the metadata, with a single auth check and a +single `svc_reg(service_id, owner)` event. It is equivalent to calling +`register_service` then `set_service_metadata`. Re-registering an existing id +overwrites its metadata idempotently, and an empty `description` is accepted. ### 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..8e6a9e3 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -149,6 +149,17 @@ fn write_flag(env: &Env, key: &DataKey, value: bool) { env.storage().persistent().set(key, &value); } +/// Persist a service's metadata (`description`, `owner`) under +/// `DataKey::ServiceMetadata(service_id)`. Shared by `set_service_metadata` +/// and `register_service_with_metadata` so the storage shape stays in one +/// place. Performs no auth — callers are responsible for admin gating. +fn write_service_metadata(env: &Env, service_id: &Symbol, description: String, owner: Address) { + env.storage().persistent().set( + &DataKey::ServiceMetadata(service_id.clone()), + &ServiceMetadata { description, owner }, + ); +} + #[contract] pub struct Escrow; @@ -666,10 +677,34 @@ impl Escrow { .get(&DataKey::Admin) .unwrap_or_else(|| panic_with_error!(&env, EscrowError::NotInitialized)); admin.require_auth(); - env.storage().persistent().set( - &DataKey::ServiceMetadata(service_id), - &ServiceMetadata { description, owner }, - ); + write_service_metadata(&env, &service_id, description, owner); + } + + /// Register a service and set its metadata in a single admin-gated, + /// atomic call. Equivalent to calling `register_service` followed by + /// `set_service_metadata`, but with one auth check and one event so + /// indexers see registration and metadata land together. + /// + /// Sets `ServiceRegistered(service_id) = true` and persists + /// `ServiceMetadata(service_id)` (`description` + `owner`). Idempotent: + /// re-registering an existing id overwrites its metadata. An empty + /// `description` is accepted. Emits `svc_reg(service_id, owner)`. + pub fn register_service_with_metadata( + env: Env, + service_id: Symbol, + description: String, + owner: Address, + ) { + 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::ServiceRegistered(service_id.clone()), true); + write_service_metadata(&env, &service_id, description, owner.clone()); + env.events() + .publish((symbol_short!("svc_reg"),), (service_id, owner)); } /// Transfer ownership of a service's metadata to `new_owner`, diff --git a/contracts/escrow/src/test.rs b/contracts/escrow/src/test.rs index 2d924db..e7deb15 100644 --- a/contracts/escrow/src/test.rs +++ b/contracts/escrow/src/test.rs @@ -697,3 +697,95 @@ fn test_pause_pause_unpause_ends_unpaused() { assert!(!client.is_paused()); } + +#[test] +fn test_register_service_with_metadata_sets_flag_and_metadata() { + let env = Env::default(); + let (client, _admin) = setup_initialized(&env); + let svc = Symbol::new(&env, "infer"); + let owner = Address::generate(&env); + let description = String::from_str(&env, "GPU inference endpoint"); + + client.register_service_with_metadata(&svc, &description, &owner); + + // A single call sets both the registration flag and the metadata. + assert!(client.is_service_registered(&svc)); + let meta = client.get_service_metadata(&svc).unwrap(); + assert_eq!(meta.description, description); + assert_eq!(meta.owner, owner); +} + +#[test] +fn test_register_service_with_metadata_emits_svc_reg_event() { + let env = Env::default(); + let (client, _admin) = setup_initialized(&env); + let svc = Symbol::new(&env, "infer"); + let owner = Address::generate(&env); + let description = String::from_str(&env, "GPU inference endpoint"); + + client.register_service_with_metadata(&svc, &description, &owner); + + let events = env.events().all(); + assert!(!events.is_empty()); + let (_addr, topics, data) = events.last().unwrap(); + let expected_topics: soroban_sdk::Vec = + (symbol_short!("svc_reg"),).into_val(&env); + assert_eq!(topics, expected_topics); + let decoded: (Symbol, Address) = data.into_val(&env); + assert_eq!(decoded, (svc.clone(), owner.clone())); +} + +#[test] +fn test_register_service_with_metadata_overwrites_idempotently() { + let env = Env::default(); + let (client, _admin) = setup_initialized(&env); + let svc = Symbol::new(&env, "infer"); + let owner1 = Address::generate(&env); + let owner2 = Address::generate(&env); + let desc1 = String::from_str(&env, "first"); + let desc2 = String::from_str(&env, "second"); + + client.register_service_with_metadata(&svc, &desc1, &owner1); + // Re-registering the same id overwrites the metadata and keeps it registered. + client.register_service_with_metadata(&svc, &desc2, &owner2); + + assert!(client.is_service_registered(&svc)); + let meta = client.get_service_metadata(&svc).unwrap(); + assert_eq!(meta.description, desc2); + assert_eq!(meta.owner, owner2); +} + +#[test] +fn test_register_service_with_metadata_allows_empty_description() { + let env = Env::default(); + let (client, _admin) = setup_initialized(&env); + let svc = Symbol::new(&env, "infer"); + let owner = Address::generate(&env); + let empty = String::from_str(&env, ""); + + client.register_service_with_metadata(&svc, &empty, &owner); + + assert!(client.is_service_registered(&svc)); + let meta = client.get_service_metadata(&svc).unwrap(); + assert_eq!(meta.description, empty); + assert_eq!(meta.owner, owner); +} + +#[test] +#[should_panic] +fn test_register_service_with_metadata_rejects_non_admin() { + 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); + + // Clear all authorizations so the admin.require_auth() inside the + // entrypoint has nothing to satisfy it and the call is rejected. + let svc = Symbol::new(&env, "infer"); + let owner = Address::generate(&env); + let description = String::from_str(&env, "GPU inference endpoint"); + env.set_auths(&[]); + client.register_service_with_metadata(&svc, &description, &owner); +}