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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 10 additions & 5 deletions contracts/escrow/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
59 changes: 59 additions & 0 deletions docs/escrow/pricing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)`
Expand Down Expand Up @@ -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
Expand All @@ -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.
Loading