diff --git a/README.md b/README.md index 5d82d8d..7892bde 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,23 @@ history are untouched. `propose_admin_transfer` rejects proposing the current admin as the new admin (panics with `InvalidAdminProposal`). This surfaces no-op handovers as caller mistakes rather than silently storing a pending entry equal to the active admin. + +### Settlement authorization (admin or service owner) + +`settle(caller, agent, service_id)` accepts **either** the global admin **or** +the `ServiceMetadata(service_id).owner` for that specific service, so a service +owner can drain their own service without holding the central admin key. + +| `caller` | Condition | Result | +|----------|-----------|--------| +| admin | always | settles | +| service owner | `caller == ServiceMetadata(service_id).owner` | settles | +| non-admin | service has no metadata/owner | `ServiceMetadataNotFound` (#13) | +| any other address | metadata exists but `caller` isn't the owner | `NotPendingAdmin` (#6, reused as unauthorized) | + +The owner of service A cannot settle service B. The pause gate and +counter-drain semantics are unchanged, and the `settled` event is emitted +identically. ### Schema version: fresh v2 init vs. legacy v1→v2 migration `init` stamps the current storage schema version (v2) directly, so a freshly diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index 82ccfaf..53531e7 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -355,21 +355,46 @@ impl Escrow { /// Settle the accumulated usage for an `(agent, service_id)` pair. /// - /// Admin-gated. Computes the outstanding bill (same math as - /// `compute_billing`), resets the usage counter to zero, and returns - /// the billed amount in stroops. The settlement loop is expected to - /// transfer the returned amount off-chain or via a paired token - /// contract call; this contract intentionally holds no balance. - pub fn settle(env: Env, agent: Address, service_id: Symbol) -> i128 { + /// Authorised by `caller`, which must be **either** the global admin + /// **or** the `ServiceMetadata(service_id).owner` for that specific + /// service. This lets a registered service owner trigger settlement + /// for their own service without holding the central admin key, while + /// still allowing the admin to settle anything. + /// + /// Authorization matrix: + /// - `caller == admin` → always allowed. + /// - `caller == owner` of this service → allowed. + /// - service has no metadata/owner and `caller != admin` → + /// [`EscrowError::ServiceMetadataNotFound`]. + /// - `caller` is some other address → [`EscrowError::NotPendingAdmin`] + /// (reused as the unauthorized-caller error, matching + /// `transfer_service_ownership`). + /// + /// Computes the outstanding bill (same math as `compute_billing`), + /// resets the usage counter to zero, stamps `LastSettlement`, and + /// returns the billed amount in stroops. Honours the pause gate and + /// emits the `settled` event identically to before. + pub fn settle(env: Env, caller: Address, agent: Address, service_id: Symbol) -> i128 { if read_flag(&env, &DataKey::Paused) { 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)); - admin.require_auth(); + if caller != admin { + // Non-admin caller must be the registered owner of this service. + let meta: ServiceMetadata = env + .storage() + .persistent() + .get(&DataKey::ServiceMetadata(service_id.clone())) + .unwrap_or_else(|| panic_with_error!(&env, EscrowError::ServiceMetadataNotFound)); + if caller != meta.owner { + panic_with_error!(&env, EscrowError::NotPendingAdmin); + } + } let usage_key = DataKey::Usage(agent.clone(), service_id.clone()); let requests: u32 = env.storage().persistent().get(&usage_key).unwrap_or(0); let price: i128 = env diff --git a/contracts/escrow/src/test.rs b/contracts/escrow/src/test.rs index 2d924db..299b323 100644 --- a/contracts/escrow/src/test.rs +++ b/contracts/escrow/src/test.rs @@ -139,12 +139,12 @@ fn test_compute_billing_basic() { #[test] fn test_settle_drains_usage_and_returns_billed() { let env = Env::default(); - let (client, _admin) = setup_initialized(&env); + 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); - let billed = client.settle(&agent, &svc); + let billed = client.settle(&admin, &agent, &svc); assert_eq!(billed, 420i128); assert_eq!(client.get_usage(&agent, &svc), 0); } @@ -168,10 +168,10 @@ fn test_unpause_admin_can_unpause() { #[should_panic(expected = "Error(Contract, #4)")] fn test_settle_rejected_while_paused() { let env = Env::default(); - let (client, _admin) = setup_initialized(&env); + let (client, admin) = setup_initialized(&env); client.pause(); let agent = Address::generate(&env); - client.settle(&agent, &Symbol::new(&env, "infer")); + client.settle(&admin, &agent, &Symbol::new(&env, "infer")); } #[test] @@ -225,11 +225,11 @@ fn test_is_paused_round_trip() { #[test] fn test_settle_returns_zero_for_unused_pair() { let env = Env::default(); - let (client, _admin) = setup_initialized(&env); + let (client, admin) = setup_initialized(&env); let agent = Address::generate(&env); let svc = Symbol::new(&env, "infer"); client.set_service_price(&svc, &10i128); - assert_eq!(client.settle(&agent, &svc), 0i128); + assert_eq!(client.settle(&admin, &agent, &svc), 0i128); } #[test] @@ -386,7 +386,7 @@ fn test_settle_drains_to_zero_and_stamps_last_settlement() { let ts: u64 = 12345; env.ledger().with_mut(|li| li.timestamp = ts); - let (client, _admin) = setup_initialized(&env); + let (client, admin) = setup_initialized(&env); let agent = Address::generate(&env); let svc = Symbol::new(&env, "infer"); client.set_service_price(&svc, &10i128); @@ -395,7 +395,7 @@ fn test_settle_drains_to_zero_and_stamps_last_settlement() { // No settlement has happened yet for this pair. assert_eq!(client.get_last_settlement(&agent, &svc), None); - let billed = client.settle(&agent, &svc); + let billed = client.settle(&admin, &agent, &svc); assert_eq!(billed, 420i128); // Usage drains to exactly zero. @@ -407,7 +407,7 @@ fn test_settle_drains_to_zero_and_stamps_last_settlement() { #[test] fn test_settle_billed_matches_compute_billing_for_presettle_state() { let env = Env::default(); - let (client, _admin) = setup_initialized(&env); + let (client, admin) = setup_initialized(&env); let agent = Address::generate(&env); let svc = Symbol::new(&env, "infer"); client.set_service_price(&svc, &7i128); @@ -417,7 +417,7 @@ fn test_settle_billed_matches_compute_billing_for_presettle_state() { let expected = client.compute_billing(&agent, &svc); assert_eq!(expected, 91i128); - let billed = client.settle(&agent, &svc); + let billed = client.settle(&admin, &agent, &svc); assert_eq!(billed, expected); // And compute_billing now reads zero since usage drained. assert_eq!(client.compute_billing(&agent, &svc), 0i128); @@ -426,13 +426,13 @@ fn test_settle_billed_matches_compute_billing_for_presettle_state() { #[test] fn test_settle_emits_settled_event_with_payload() { let env = Env::default(); - let (client, _admin) = setup_initialized(&env); + 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); - let billed = client.settle(&agent, &svc); + let billed = client.settle(&admin, &agent, &svc); let events = env.events().all(); assert!(!events.is_empty()); @@ -476,13 +476,13 @@ fn test_settle_zero_usage_returns_zero_stamps_and_emits_event() { let ts: u64 = 99_999; env.ledger().with_mut(|li| li.timestamp = ts); - let (client, _admin) = setup_initialized(&env); + let (client, admin) = setup_initialized(&env); let agent = Address::generate(&env); let svc = Symbol::new(&env, "infer"); client.set_service_price(&svc, &10i128); // Settle a pair that never recorded any usage. - let billed = client.settle(&agent, &svc); + let billed = client.settle(&admin, &agent, &svc); assert_eq!(billed, 0i128); // Capture events immediately after `settle`: `events().all()` only @@ -697,3 +697,95 @@ fn test_pause_pause_unpause_ends_unpaused() { assert!(!client.is_paused()); } + +// --------------------------------------------------------------------------- +// Owner-or-admin settlement authorization (#13) +// --------------------------------------------------------------------------- + +/// The registered service owner can settle their own service without the +/// admin key. +#[test] +fn test_owner_can_settle_own_service() { + let env = Env::default(); + let (client, _admin) = setup_initialized(&env); + let owner = Address::generate(&env); + let agent = Address::generate(&env); + let svc = Symbol::new(&env, "infer"); + + client.set_service_metadata(&svc, &String::from_str(&env, "inference"), &owner); + client.set_service_price(&svc, &10i128); + client.record_usage(&agent, &svc, &5u32); + + let billed = client.settle(&owner, &agent, &svc); + assert_eq!(billed, 50i128); + assert_eq!(client.get_usage(&agent, &svc), 0); +} + +/// The admin can always settle, even a service owned by someone else. +#[test] +fn test_admin_can_settle_owned_service() { + let env = Env::default(); + let (client, admin) = setup_initialized(&env); + let owner = Address::generate(&env); + let agent = Address::generate(&env); + let svc = Symbol::new(&env, "infer"); + + client.set_service_metadata(&svc, &String::from_str(&env, "inference"), &owner); + client.set_service_price(&svc, &10i128); + client.record_usage(&agent, &svc, &4u32); + + let billed = client.settle(&admin, &agent, &svc); + assert_eq!(billed, 40i128); +} + +/// The owner of service A cannot settle service B (panics #6, the reused +/// unauthorized-caller error). +#[test] +#[should_panic(expected = "Error(Contract, #6)")] +fn test_owner_cannot_settle_other_service() { + let env = Env::default(); + let (client, _admin) = setup_initialized(&env); + let owner_a = Address::generate(&env); + let owner_b = Address::generate(&env); + let agent = Address::generate(&env); + let svc_a = Symbol::new(&env, "svc_a"); + let svc_b = Symbol::new(&env, "svc_b"); + + client.set_service_metadata(&svc_a, &String::from_str(&env, "a"), &owner_a); + client.set_service_metadata(&svc_b, &String::from_str(&env, "b"), &owner_b); + client.set_service_price(&svc_b, &10i128); + client.record_usage(&agent, &svc_b, &3u32); + + // owner_a tries to settle svc_b — unauthorized. + client.settle(&owner_a, &agent, &svc_b); +} + +/// A non-admin caller settling a service with no metadata is rejected with +/// ServiceMetadataNotFound (#13). +#[test] +#[should_panic(expected = "Error(Contract, #13)")] +fn test_nonadmin_settle_without_metadata_rejected() { + let env = Env::default(); + let (client, _admin) = setup_initialized(&env); + let stranger = Address::generate(&env); + let agent = Address::generate(&env); + let svc = Symbol::new(&env, "infer"); + client.set_service_price(&svc, &10i128); + client.record_usage(&agent, &svc, &2u32); + + client.settle(&stranger, &agent, &svc); +} + +/// The pause gate still applies to owner-authorized settlement. +#[test] +#[should_panic(expected = "Error(Contract, #4)")] +fn test_owner_settle_rejected_while_paused() { + let env = Env::default(); + let (client, _admin) = setup_initialized(&env); + let owner = Address::generate(&env); + let agent = Address::generate(&env); + let svc = Symbol::new(&env, "infer"); + client.set_service_metadata(&svc, &String::from_str(&env, "inference"), &owner); + client.pause(); + client.settle(&owner, &agent, &svc); +}