diff --git a/README.md b/README.md index b4da467..9a99a63 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,14 @@ Soroban smart contracts for the AgentPay protocol: escrow, usage recording, and - **escrow** — Records usage and supports settlement logic for machine-to-machine payments. +### Service ownership handover + +A service's `ServiceMetadata` carries a `description` and an `owner`. The +current owner (or the admin) can reassign the `owner` via +`transfer_service_ownership(caller, service_id, new_owner)` without touching the +`description`. The call honours the pause gate and emits `owner_chg` for +indexers. + ## Prerequisites - [Rust](https://rustup.rs/) (stable, with `rustfmt`) diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index 1811bed..fdd4a2d 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -114,6 +114,9 @@ pub enum EscrowError { MigrationVersionMismatch = 11, /// `record_usage` referenced a service that has been disabled. ServiceDisabled = 12, + /// A metadata-scoped entrypoint referenced a service that has no + /// `ServiceMetadata` slot set. + ServiceMetadataNotFound = 13, } #[contracttype] @@ -711,6 +714,51 @@ impl Escrow { ); } + /// Transfer ownership of a service's metadata to `new_owner`, + /// preserving the existing `description`. Authorised by `caller`, + /// which must be the current owner OR the admin. Panics with + /// `ServiceMetadataNotFound` if no metadata has been set. Emits + /// `owner_chg(service_id, old_owner, new_owner)` for indexers. + /// Honours the pause gate. + pub fn transfer_service_ownership( + env: Env, + caller: Address, + service_id: Symbol, + new_owner: Address, + ) { + if env + .storage() + .persistent() + .get(&DataKey::Paused) + .unwrap_or(false) + { + panic_with_error!(&env, EscrowError::ContractPaused); + } + caller.require_auth(); + let admin: Address = env + .storage() + .persistent() + .get(&DataKey::Admin) + .unwrap_or_else(|| panic_with_error!(&env, EscrowError::NotInitialized)); + let mut meta: ServiceMetadata = env + .storage() + .persistent() + .get(&DataKey::ServiceMetadata(service_id.clone())) + .unwrap_or_else(|| panic_with_error!(&env, EscrowError::ServiceMetadataNotFound)); + if caller != meta.owner && caller != admin { + panic_with_error!(&env, EscrowError::NotPendingAdmin); // reuse: unauthorized caller + } + let old_owner = meta.owner.clone(); + meta.owner = new_owner.clone(); + env.storage() + .persistent() + .set(&DataKey::ServiceMetadata(service_id.clone()), &meta); + env.events().publish( + (symbol_short!("owner_chg"),), + (service_id, old_owner, new_owner), + ); + } + /// Read the on-chain schema version, or `1` (the implicit /// pre-migration default) if absent. pub fn get_schema_version(env: Env) -> u32 { diff --git a/contracts/escrow/src/test.rs b/contracts/escrow/src/test.rs index a233a9d..ddb33d8 100644 --- a/contracts/escrow/src/test.rs +++ b/contracts/escrow/src/test.rs @@ -2,7 +2,7 @@ #![allow(deprecated)] use super::*; -use soroban_sdk::{testutils::Address as _, Address, Symbol}; +use soroban_sdk::{testutils::Address as _, Address, String, Symbol}; fn setup_initialized(env: &Env) -> (EscrowClient<'_>, Address) { env.mock_all_auths(); @@ -248,3 +248,48 @@ fn test_record_usage_rejects_zero_requests() { let service_id = Symbol::new(&env, "weather_api"); client.record_usage(&agent, &service_id, &0u32); } + +#[test] +fn test_transfer_service_ownership_by_owner_preserves_description() { + let env = Env::default(); + let (client, _admin) = setup_initialized(&env); + let svc = Symbol::new(&env, "infer"); + let owner = Address::generate(&env); + let new_owner = Address::generate(&env); + let desc = String::from_str(&env, "inference service"); + client.set_service_metadata(&svc, &desc, &owner); + + client.transfer_service_ownership(&owner, &svc, &new_owner); + + let meta = client.get_service_metadata(&svc).unwrap(); + assert_eq!(meta.owner, new_owner); + assert_eq!(meta.description, desc); +} + +#[test] +fn test_transfer_service_ownership_by_admin() { + let env = Env::default(); + let (client, admin) = setup_initialized(&env); + let svc = Symbol::new(&env, "infer"); + let owner = Address::generate(&env); + let new_owner = Address::generate(&env); + let desc = String::from_str(&env, "inference service"); + client.set_service_metadata(&svc, &desc, &owner); + + client.transfer_service_ownership(&admin, &svc, &new_owner); + + let meta = client.get_service_metadata(&svc).unwrap(); + assert_eq!(meta.owner, new_owner); + assert_eq!(meta.description, desc); +} + +#[test] +#[should_panic(expected = "Error(Contract, #13)")] +fn test_transfer_service_ownership_missing_metadata_panics() { + let env = Env::default(); + let (client, _admin) = setup_initialized(&env); + let svc = Symbol::new(&env, "never_set"); + let caller = Address::generate(&env); + let new_owner = Address::generate(&env); + client.transfer_service_ownership(&caller, &svc, &new_owner); +}