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
42 changes: 41 additions & 1 deletion contracts/escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
169 changes: 169 additions & 0 deletions contracts/escrow/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<soroban_sdk::Val> =
(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();
Expand Down
Loading