diff --git a/README.md b/README.md index f2ad5cc..d20fc4b 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ The Rust toolchain is pinned via `rust-toolchain.toml` (stable channel with `was - [CHANGELOG](CHANGELOG.md) — versioned history of entrypoints, events, and error codes; contribution conventions. - [EscrowError code table](docs/escrow/errors.md) — full reference for all 23 error codes: trigger conditions, overloaded codes, and the entrypoints that raise each code. +- [Escrow: Pricing Model](docs/escrow/pricing.md) — flat-rate vs. tiered volume-discount billing, tier-boundary semantics with worked examples, and the global price-bounds invariants. ### Service ownership handover diff --git a/contracts/escrow/src/test.rs b/contracts/escrow/src/test.rs index acc83e0..9bd774d 100644 --- a/contracts/escrow/src/test.rs +++ b/contracts/escrow/src/test.rs @@ -1101,16 +1101,21 @@ fn test_transfer_service_ownership_genuine_transfer_emits_event() { let new_owner = Address::generate(&env); let desc = String::from_str(&env, "inference service"); client.set_service_metadata(&svc, &desc, &owner); - // Capture event count before transfer. - let events_before = env.events().all(); - let count_before = events_before.len(); // Perform genuine transfer. client.transfer_service_ownership(&owner, &svc, &new_owner); - // Exactly one new event (owner_chg). + // env.events().all() reflects only the most recent contract invocation + // (confirmed by every other event test in this file, e.g. + // assert_usage_event_count re-checking a count of 1 after each of + // several sequential record_usage calls), not a running total since + // the start of the test. The prior `set_service_metadata` call's + // `meta_set` event is therefore not present here — only this + // transfer's own event is. Comparing against a pre-call count of + // events from a *different* invocation was the bug; checking this + // call's own event count directly is the fix. let events_after = env.events().all(); assert_eq!( events_after.len(), - count_before + 1, + 1, "genuine transfer must emit exactly one event" ); let (_addr, topics, data) = events_after.last().unwrap(); diff --git a/docs/escrow/pricing.md b/docs/escrow/pricing.md index 0e2adbc..916601f 100644 --- a/docs/escrow/pricing.md +++ b/docs/escrow/pricing.md @@ -86,6 +86,50 @@ Equivalent to `set_service_price(svc, 5)` but using the tier path. --- +## Global price bounds + +An admin can constrain the flat-rate price band with +`set_price_bounds(min_stroops, max_stroops)`, stored in +`DataKey::MinServicePrice` / `DataKey::MaxServicePrice`. + +### Defaults and enforcement + +- Unset: floor = `0`, ceiling = `i128::MAX` (unbounded). Calling + `set_price_bounds(0, i128::MAX)` restores these explicitly. +- Once set, every `set_service_price(service_id, price_stroops)` call is + rejected with `PriceOutOfBounds` (#20) if + `price_stroops < MinServicePrice || price_stroops > MaxServicePrice`. +- `set_price_bounds` itself rejects `min_stroops > max_stroops` with + `InvertedPriceBand` (#22) — a logically impossible band can never be + stored. +- Emits `bnd_set(min_stroops, max_stroops)` on success. + +### Zero-is-free interacts with the floor + +A price of `0` means "free service." If `min_stroops > 0`, free services are +explicitly forbidden — `set_service_price(svc, 0)` is rejected with +`PriceOutOfBounds` until the floor is lowered back to `0`. This is +intentional: a positive floor expresses that every service in the band must +carry a non-zero cost. + +### Not retroactive + +`set_price_bounds` only gates *future* `set_service_price` calls. A price +already stored before the bounds were tightened is left untouched — the +bound is a write-time guard, not an invariant enforced on read. An admin +that needs to bring existing prices into a new band must re-set each +service's price explicitly. + +### Invariant gap: tier prices are unbounded + +**`set_price_tiers` does not consult `MinServicePrice` / `MaxServicePrice` +at all.** Each `PriceTier.price_stroops` is validated only for +non-negativity (see the schedule invariants above) — a tier price can be +set below the floor or above the ceiling that would reject the same value +via `set_service_price`. This means a service using tiered billing can +bypass the global price-bounds policy entirely. Treat the two pricing paths +as independently governed until this asymmetry is closed. + ## API reference ### `set_price_tiers(service_id, tiers)` @@ -114,6 +158,17 @@ than panicking. Drains the usage counter and returns the billed amount using the same tier-aware (or flat fallback) math as `compute_billing`. +### `set_price_bounds(min_stroops, max_stroops)` + +Admin-gated. Stores the global flat-price floor/ceiling. Rejects +`min_stroops > max_stroops` with `InvertedPriceBand`. Emits +`bnd_set(min_stroops, max_stroops)`. Does not affect tiered pricing — see +[Invariant gap](#invariant-gap-tier-prices-are-unbounded) above. + +### `get_min_service_price() -> i128` / `get_max_service_price() -> i128` + +Pure reads. Default to `0` and `i128::MAX` respectively when unset. + --- ## Security notes @@ -129,3 +184,7 @@ tier-aware (or flat fallback) math as `compute_billing`. the counter in a single persistent write before emitting the event. - **Backward compatibility**: services without a tier schedule continue to use the flat `ServicePrice` path unchanged. Existing callers need no migration. +- **Price bounds are write-time-only and flat-rate-only**: `set_price_bounds` + does not re-validate prices already on chain, and `set_price_tiers` never + consults it — an admin relying on the global bounds as a hard invariant + must also audit tier schedules separately.