This document describes the billing model supported by the escrow contract,
covering both the legacy flat-rate mode and the volume-discount tier mode
introduced in this update.
When no tier schedule is configured for a service, billing uses a single
price_per_request value stored under DataKey::ServicePrice(service_id).
bill = accumulated_requests * price_per_request (saturating_mul, stroops)
Set via set_service_price(service_id, price_stroops).
Read via get_service_price(service_id).
An admin can attach a tier schedule to any service via set_price_tiers.
When a schedule is present, compute_billing and settle ignore
ServicePrice and apply the tier math instead.
A tier schedule is a Vec<PriceTier> where each entry is:
| field | type | meaning |
|---|---|---|
threshold_requests |
u32 |
Inclusive upper bound on cumulative requests in this tier |
price_stroops |
i128 |
Marginal price per request within this tier (≥ 0) |
- The schedule must contain at least one entry.
threshold_requestsvalues must be strictly ascending — no ties.- The first tier's
threshold_requestsmust be > 0. - Every
price_stroopsmust be ≥ 0.
Violations are rejected with EscrowError::InvalidPriceTiers (#18).
Tiers are inclusive at the upper boundary:
- Tier 0 covers requests
[1 .. threshold_0](both endpoints included). - Tier k covers requests
[threshold_{k-1}+1 .. threshold_k]. - The last tier is open-ended: any requests beyond the last threshold
are still billed at the last tier's
price_stroops.
A threshold_requests of u32::MAX on the final tier therefore means
"unlimited" and is the conventional choice when you want the last tier to
have no upper ceiling.
tier 0: threshold=100, price=10 stroops/request
tier 1: threshold=1000, price=7 stroops/request
tier 2: threshold=MAX, price=4 stroops/request
| accumulated_requests | calculation | total (stroops) |
|---|---|---|
| 50 | 50 × 10 | 500 |
| 100 | 100 × 10 | 1 000 |
| 101 | 100 × 10 + 1 × 7 | 1 007 |
| 1 000 | 100 × 10 + 900 × 7 | 7 300 |
| 1 001 | 100 × 10 + 900 × 7 + 1 × 4 | 7 304 |
| 5 000 | 100 × 10 + 900 × 7 + 4 000 × 4 | 23 300 |
tier 0: threshold=MAX, price=5 stroops/request
Equivalent to set_service_price(svc, 5) but using the tier path.
An admin can constrain the flat-rate price band with
set_price_bounds(min_stroops, max_stroops), stored in
DataKey::MinServicePrice / DataKey::MaxServicePrice.
- Unset: floor =
0, ceiling =i128::MAX(unbounded). Callingset_price_bounds(0, i128::MAX)restores these explicitly. - Once set, every
set_service_price(service_id, price_stroops)call is rejected withPriceOutOfBounds(#20) ifprice_stroops < MinServicePrice || price_stroops > MaxServicePrice. set_price_boundsitself rejectsmin_stroops > max_stroopswithInvertedPriceBand(#22) — a logically impossible band can never be stored.- Emits
bnd_set(min_stroops, max_stroops)on success.
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.
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.
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.
Admin-gated. Stores the tier schedule under DataKey::PriceTiers(service_id).
Validates the schedule (monotonicity, non-negative prices, non-empty) before
writing. Emits tiers_set(service_id).
Pure read. Returns the stored schedule or None if the flat-rate path is in use.
Admin-gated. Removes the tier schedule, reverting compute_billing and settle
to the flat ServicePrice. Idempotent. Emits tiers_rm(service_id).
Read-only. Returns the outstanding bill in stroops using whichever pricing
path is active (tier if set, flat otherwise). Saturates at i128::MAX rather
than panicking.
Drains the usage counter and returns the billed amount using the same
tier-aware (or flat fallback) math as compute_billing.
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 above.
Pure reads. Default to 0 and i128::MAX respectively when unset.
- Overflow safety: all arithmetic is
saturating_mul/saturating_add. A saturated return value (i128::MAX) is a sentinel for the off-chain settlement loop, not a panic. - Deterministic ordering: the tier schedule is stored verbatim on-chain.
set_price_tiersrejects non-monotonic input so the schedule read back bycompute_billingandsettleis always in ascending order; no re-sort is needed at read time. - No reentrancy surface:
compute_billingis read-only;settledrains the counter in a single persistent write before emitting the event. - Backward compatibility: services without a tier schedule continue to use
the flat
ServicePricepath unchanged. Existing callers need no migration. - Price bounds are write-time-only and flat-rate-only:
set_price_boundsdoes not re-validate prices already on chain, andset_price_tiersnever consults it — an admin relying on the global bounds as a hard invariant must also audit tier schedules separately.