diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index cd643f0..0efa9e7 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -1149,6 +1149,44 @@ impl Escrow { write_flag(&env, &DataKey::ServiceRegistered(service_id), true); } + /// Register a service and set its metadata in a single admin-gated, + /// pause-respecting call. + /// + /// Atomically sets `ServiceRegistered(service_id) = true` and writes + /// `ServiceMetadata(service_id)` so that after one invocation both + /// `is_service_registered` and `get_service_metadata` return the new + /// state. Emits `svc_reg(service_id, owner)` for indexers. + /// + /// Idempotent overwrite: re-registering an existing id overwrites its + /// metadata. An empty description is accepted. + /// + /// # Security + /// - Admin-gated (`require_admin + require_auth`). + /// - Honours the pause gate (`ensure_not_paused`). + /// - Both slots land together — there is no window where a service is + /// registered but has no metadata, or vice versa. + pub fn register_service_with_metadata( + env: Env, + service_id: Symbol, + description: String, + owner: Address, + ) { + ensure_not_paused(&env); + require_admin(&env); + write_flag(&env, &DataKey::ServiceRegistered(service_id.clone()), true); + env.storage().persistent().set( + &DataKey::ServiceMetadata(service_id.clone()), + &ServiceMetadata { + description, + owner: owner.clone(), + }, + ); + env.events().publish( + (symbol_short!("svc_reg"),), + (service_id, owner), + ); + } + /// Cancel a pending admin transfer. Current admin only. No-op when /// nothing is pending. pub fn cancel_admin_transfer(env: Env) { @@ -1311,7 +1349,9 @@ impl Escrow { require_admin(&env); env.storage() .persistent() - .set(&DataKey::UsageAlertThreshold, &threshold); + .remove(&DataKey::ServiceMetadata(service_id.clone())); + env.events() + .publish((symbol_short!("meta_clr"),), service_id); } /// Read the on-chain schema version, or `1` (the implicit diff --git a/contracts/escrow/src/test.rs b/contracts/escrow/src/test.rs index 2885f98..cf75b46 100644 --- a/contracts/escrow/src/test.rs +++ b/contracts/escrow/src/test.rs @@ -25,6 +25,19 @@ //! * [`set_price`] — one-liner to call `set_service_price` on a client. //! * [`record`] — one-liner to call `record_usage` on a client. //! +//! ### `register_service_with_metadata` coverage +//! +//! The combined registration-plus-metadata entrypoint is tested for: +//! * **Atomicity** — after one call `is_service_registered` is `true` and +//! `get_service_metadata` returns the exact description and owner. +//! * **Event emission** — `svc_reg(service_id, owner)` is published. +//! * **Idempotent overwrite** — re-registering an existing id replaces its +//! metadata; an empty description is accepted. +//! * **Admin gate** — a non-admin caller is rejected (`Unauthorized`). +//! * **Pause gate** — calling while paused panics with `ContractPaused` (#4). +//! * **Equivalence** — the combined call produces the same post-state as the +//! two-step `register_service + set_service_metadata` sequence. +//! //! ### Security note //! Tests that use `setup_initialized` rely on `mock_all_auths`, which //! satisfies every `require_auth` call unconditionally. When a test needs to @@ -937,6 +950,162 @@ fn test_service_slot_toggle_matrix_is_independent() { assert!(!client.is_service_disabled(&svc)); } +// ── register_service_with_metadata ─────────────────────────────────────────── +// +// `register_service_with_metadata` atomically sets `ServiceRegistered` and +// `ServiceMetadata` in a single admin-gated call, emits `svc_reg(service_id, +// owner)`, honours the pause gate, and is idempotent (overwrites metadata on +// re-registration). The combined call must produce the same resulting state as +// calling `register_service` followed by `set_service_metadata`. + +/// After a single `register_service_with_metadata` call, both +/// `is_service_registered` returns `true` and `get_service_metadata` +/// returns the exact description and owner that were passed. +#[test] +fn test_register_with_metadata_atomicity() { + let env = Env::default(); + let (client, _admin) = setup_initialized(&env); + let svc = Symbol::new(&env, "infer"); + let owner = Address::generate(&env); + let desc = String::from_str(&env, "GPU inference endpoint"); + + client.register_service_with_metadata(&svc, &desc, &owner); + + // Both slots are set atomically by the single call. + assert!(client.is_service_registered(&svc)); + let meta = client.get_service_metadata(&svc).unwrap(); + assert_eq!(meta.description, desc); + assert_eq!(meta.owner, owner); +} + +/// The call emits a `svc_reg(service_id, owner)` event that can be +/// decoded from `env.events().all()`. +#[test] +fn test_register_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 desc = String::from_str(&env, "GPU inference endpoint"); + + client.register_service_with_metadata(&svc, &desc, &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, owner)); +} + +/// Re-registering an existing service id overwrites the stored metadata +/// (idempotent overwrite). +#[test] +fn test_register_with_metadata_overwrite() { + let env = Env::default(); + let (client, _admin) = setup_initialized(&env); + let svc = Symbol::new(&env, "infer"); + let owner1 = Address::generate(&env); + let desc1 = String::from_str(&env, "first description"); + let owner2 = Address::generate(&env); + let desc2 = String::from_str(&env, "second description"); + + // First registration. + client.register_service_with_metadata(&svc, &desc1, &owner1); + let meta = client.get_service_metadata(&svc).unwrap(); + assert_eq!(meta.description, desc1); + assert_eq!(meta.owner, owner1); + + // Overwrite with different metadata. + client.register_service_with_metadata(&svc, &desc2, &owner2); + let meta = client.get_service_metadata(&svc).unwrap(); + assert_eq!(meta.description, desc2); + assert_eq!(meta.owner, owner2); + // Registration flag stays true (idempotent). + assert!(client.is_service_registered(&svc)); +} + +/// An empty description string is accepted by the entrypoint. +#[test] +fn test_register_with_metadata_empty_description_accepted() { + 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); +} + +/// A non-admin caller is rejected with `Unauthorized` (the auth framework's +/// panic, not a typed error). +#[test] +#[should_panic(expected = "Unauthorized")] +fn test_register_with_metadata_requires_admin() { + let env = Env::default(); + let client = setup_scoped_auth(&env); + let svc = Symbol::new(&env, "infer"); + let owner = Address::generate(&env); + let desc = String::from_str(&env, "some service"); + // No admin auth is wired up beyond init, so require_admin will fail. + client.register_service_with_metadata(&svc, &desc, &owner); +} + +/// Calling while the contract is paused panics with `ContractPaused` (#4). +#[test] +#[should_panic(expected = "Error(Contract, #4)")] +fn test_register_with_metadata_rejected_while_paused() { + let env = Env::default(); + let (client, admin) = setup_initialized(&env); + client.pause(); + let svc = Symbol::new(&env, "infer"); + let owner = Address::generate(&env); + let desc = String::from_str(&env, "some service"); + client.register_service_with_metadata(&svc, &desc, &owner); +} + +/// The combined call produces the same resulting state as calling +/// `register_service` then `set_service_metadata` separately, proving +/// the atomic entrypoint is semantically equivalent to the two-step +/// sequence. +#[test] +fn test_register_with_metadata_equivalent_to_separate_calls() { + let env = Env::default(); + let (client, _admin) = setup_initialized(&env); + let svc = Symbol::new(&env, "infer"); + let owner = Address::generate(&env); + let desc = String::from_str(&env, "GPU inference endpoint"); + + // Combined call. + client.register_service_with_metadata(&svc, &desc, &owner); + + // Capture state produced by the combined call. + let registered_combined = client.is_service_registered(&svc); + let meta_combined = client.get_service_metadata(&svc).unwrap(); + + // Fresh contract, separate calls. + let env2 = Env::default(); + let (client2, _admin2) = setup_initialized(&env2); + let svc2 = Symbol::new(&env2, "infer"); + let owner2 = Address::generate(&env2); + let desc2 = String::from_str(&env2, "GPU inference endpoint"); + + client2.register_service(&svc2); + client2.set_service_metadata(&svc2, &desc2, &owner2); + + // State must be identical. + assert_eq!(client2.is_service_registered(&svc2), registered_combined); + let meta_separate = client2.get_service_metadata(&svc2).unwrap(); + assert_eq!(meta_separate, meta_combined); +} + #[test] fn test_pause_emits_paused_event_true() { let env = Env::default();