tiers
gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/tiers on onyx-1 testnet
pure packageprimitiveaccountinglive on onyx-1
Price an amount against a graduated schedule.
Charges each slice of an amount at its own rate, which is the arithmetic behind volume discounts, tax bands and tiered fees.
In detail curated
A pure graduated-rate primitive: cut one amount at caller-declared breakpoints, charge each slice at its own rate, sum. The arithmetic behind a progressive fee, a marginal tax bracket, a volume discount and a tiered royalty. MARGINAL, never a cliff: it guarantees Charge(n) <= Charge(n+1) <= Charge(n)+1 at every amount and every valid schedule (monotone AND no-cliff), and a third consequence Charge(n) <= n, all from the rate cap. Charge is DEFINED as the sum of the per-slice ChargeByTier column (per-slice floor, not one floor at the end), so the single number and the line items can never disagree -- the p/aging/p/depreciation discipline. UNLIKE p/aging it is genuinely monotone in the amount (increasing it only adds mass at the top; nothing moves between slices) and it does NOT require ordered rates (a falling schedule is a volume discount -- copying p/aging's ordering check would wrongly forbid every discount). The per-slice multiply goes through 128 bits (bits.Mul64/Div64, discipline adopted from p/curve) because slice x rate overflows int64; every chargeSlice guard is provably unreachable from a validated Schedule and kept anyway because the proof rests on the rate cap, with direct unexported tests. MaxTiers 16; the one-tier flat schedule (empty edges) is legitimate and reproduces grc721/royalty's salePrice*Bps/Denominator. Imports stdlib errors and math/bits only.
Use it
A pure package has no state and no transactions: you use it by importing it.
Import it
import "gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/tiers"The path is the address: there is no package manager, nothing to add to a manifest and no version to pin, so the package you compile against is the one the chain runs. It differs per chain because the namespace in it is the deploying account's.
Called from real code
Not an example written for this page. These lines are quoted from payslips, a deployed package in this portfolio that imports this one as tiers, out of the committed file whose sha256 onyx-1 testnet attested. They compile because they are compiled.
s, err := tiers.New(nil, []int64{bps})s, err := tiers.New(e, r)Deployments
onyx-1 testnet onyx-1
chain-attested| Import path | gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/tiers |
|---|---|
| Namespace | g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3 |
| Deployed at height | 364,085 |
| Deploy transaction | a8ae306eb57a6ae0ed5290ec7f62bb54138c35422b28dd61d5507382f81a334f look it up on the RPC |
| Deployer | g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3 |
| Gas used | 48,947,239 |
| Storage | not attested per package on this chain — see the limitations below |
| Files on chain | tiers.gno gnomod.toml |
| Deployed bytes | tiers.gno — 36,347 bytes |
| sha256 | 2adf734f9b74bb6aa529044d922a09566e77213acbc6eba1a964deb8d6398616 |
| Realm address | none — A /p/ package is not a realm: it holds no state, custodies no coins and is never a transaction sender. The pkgPath derivation would still produce a value; recording one would name nothing. |
| Used by | r/latefees, r/meters, r/payslips |
|---|
Do not take the hash above on trust. $download returns the bytes onyx-1 is actually running; this command fetches them and prints their digest, which should equal the one in the table:
curl -sS 'https://onyx.testnets.gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/tiers$download&file=tiers.gno' | shasum -a 256Expected: 2adf734f9b74bb6aa529044d922a09566e77213acbc6eba1a964deb8d6398616 — 36,347 bytes. Use curl: the gno.land edges answer Python's default user-agent with HTTP 403.
API
chain-derived 1 exported function, 1 type, 10 methods.
This is a /p/ package: you import it, you do not call it in a transaction. gnoweb's $help shows only exported top-level functions, so the types and methods below do not appear there at all — which is why this reference exists.
Overview
Package tiers prices one amount against a graduated schedule: cut the amount at caller-declared breakpoints, charge each slice at its own rate, and sum.
This is the arithmetic behind a progressive fee, a marginal tax bracket, a volume discount and a tiered royalty. Nothing here reads the chain, holds state, or knows what the amount is a quantity of, so a realm can price a whole schedule without a node.
s, _ := New([]int64{1000, 5000}, []int64{100, 250, 500}) parts, err := s.SliceAt(7000) // [1000 4000 2000] -- the partition col, err := s.ChargeByTier(7000) // [10 100 100] -- the line items fee, err := s.Charge(7000) // 210 -- their sum
The three steps are separable on purpose, in the shape p/aging uses: the partition is what a reader checks against the schedule, the per-tier column is the line items, and Schedule.Charge is the one number that goes in a ledger. Charge is *defined* as the sum of the column, so the two cannot disagree.
Marginal, and never a cliff
Each slice of the amount is charged at the rate for the tier it falls in. The whole amount is never charged at the top tier's rate. Those are different numbers, and the difference is not a rounding matter: the cliff form has a discontinuity at every edge, where earning one more unit can cost more than one unit.
With edges `{100}` and rates `{0, 5000}`, the cliff form charges 0 at 99 and 50 at 100 — one extra unit costs fifty. The graduated form charges 0 at 100 and 0 at 101, because only the one unit above the edge is priced at 50%.
What this package guarantees, at every amount and for every valid schedule:
Charge(n) <= Charge(n+1) <= Charge(n) + 1
The left inequality is monotonicity; the right is the no-cliff property. Adding one unit to the amount grows exactly one slice by one, and `floor((x+1)r/RateScale) - floor(x·r/RateScale)` is 0 or 1 whenever `r <= RateScale`. [TestChargeHasNoCliffAtAnyEdge] asserts it at and around every edge of every schedule it builds, and [TestCliffFormulaViolatesIt] pins the counterexample so that a later "simplification" to the cliff form fails the suite instead of silently changing every published number.
A third consequence of the same bound: `Charge(n) <= n`. A graduated schedule can never charge more than the amount it is pricing, which is what makes the rate cap in New an arithmetic fact and not only hygiene.
The amount is a length, and the half-open convention is load-bearing
Think of the amount as the interval `[0, amount)`. Tier i covers the half-open range `[edges[i-1], edges[i])`, with tier 0 starting at 0 and the last tier unbounded above. That makes the tiers exhaustive and disjoint over every non-negative amount, with no breakpoint shared by two tiers. A slice is the overlap of `[0, amount)` with a tier's range, so the slices always sum to exactly the amount — Schedule.SliceAt's cross-foot.
The unit running from x to x+1 is priced at the tier containing **x**. So Schedule.TierOf and Schedule.MarginalRate answer a question about the *next* unit, not the last one:
MarginalRate(n) is the rate the (n+1)'th unit will attract.
At an edge the two readings differ, and that difference is the off-by-one this package is most exposed to. With edges `{30}`, `MarginalRate(30)` is the *upper* tier's rate: the amount 30 exactly fills tier 0, and the next unit is the first one above the edge. The other reading — "the rate the last unit was charged at" — would answer with the lower tier, and is not offered, because it is undefined at amount 0.
p/aging shipped an off-by-one of exactly this kind in its own discovery record (its doc, "The degenerate two-bucket case"), so the convention is stated here and pinned by [TestMarginalRateAtAnEdgeIsTheUpperTier] rather than left to a reader to reconstruct. Schedule.TierRange exists for the same reason: reconstructing a half-open range from Schedule.Edges is where the mistake lives.
Why the floor is taken per slice
Charge = Σ floor(sliceᵢ × rateᵢ / RateScale)
not one floor taken at the end of an exact rational total. Those are different numbers, and which one a package shipped is invisible in any test that happens to use round rates.
Per-slice is chosen because Schedule.ChargeByTier is the line-item column a reader sees next to the slices, and line items that do not add up to the stated total are a bookkeeping defect regardless of which total is "more accurate". The same discipline, and the same rationale, as p/aging's per-bucket floor and p/depreciation's `Charge`-from-`CarryingAt`.
[TestFloorIsPerSliceNotAtTheEnd] pins a case where the two disagree.
Monotone in the amount — and why, unlike [p/aging], it really is
p/aging published the analogous claim as settled and the code falsified it: its provision can **fall** as time advances, by up to one unit per bucket, because mass *moves between* buckets as an obligation ages and each bucket re-floors. Its doc records the counterexample and the attained bound.
Here the claim holds, and the reason is structural rather than fortunate: increasing the amount only ever **adds mass at the top**. Every lower slice is already full, so its subtotal is literally the same number and no floored subtotal can shrink. Nothing moves between slices.
That is an argument, so it is also measured: [TestChargeIsMonotone] sweeps random schedules across contiguous amounts and asserts a strictly non-decreasing charge. The discovery record for this package committed to amending itself rather than quietly editing if the sweep found a drop; the sweep found none, and the figure it ran is in the test rather than restated here.
Why the rates need NOT be monotone, which is the opposite of [p/aging]
New accepts rates in any order. A rising schedule is a progressive fee; a falling one is a volume discount, which is the same arithmetic read from the other end and is a first-class use of this package, not a tolerated degenerate case.
p/aging refuses a falling schedule, and the contrast is worth stating because copying that check here would be the plausible mistake. It needs the ordering for two reasons, neither of which transfers: a falling loss rate is a domain error (an older receivable is not more collectible), and without the ordering its exact-rational monotonicity fails as well, since an obligation crossing into a cheaper bucket *reduces* the total. In this package nothing crosses into anything. The monotonicity above needs only `rate >= 0`.
So a transposed rate array is a silent authoring error here in a way it is not in p/aging, and this package cannot detect it. That is recorded in "What this package does not claim" rather than papered over with a check that would also forbid every discount schedule.
The per-slice multiply goes through 128 bits
`slice × rate` overflows an int64 for large amounts — a slice near maxInt64 at any rate above 1 basis point — so the product is taken in 128 bits with `bits.Mul64` and divided with `bits.Div64`.
The discipline is ADOPTED, not derived, and the source is p/curve, read in full before this was written. Its guard carries the argument in its own comment: without the `hi < m` check, `bits.Div64` violates its own precondition and over-issues. See DISCOVERY.md § 3 and § 4, which attribute it.
Here that precondition is *provable*: rates are capped at RateScale, so `slice × rate < 2^63 × 10^4 < 2^77` and `hi < 2^13 = 8192 < 10000 = m`. **It is checked anyway**, and so is the quotient's fit in an int64, which is provable from the same cap. A proof that rests on the rate cap is a proof that expires silently the day someone raises the cap, and CLAUDE.md § 7a's whole subject is checks that stop asking their question.
Every guard in `chargeSlice` is therefore unreachable through the exported API, which makes them untestable through it. They are not left unexercised: `chargeSlice` is unexported and the suite calls it directly — [TestChargeSliceGuardsAPreconditionTheAPICannotReach] — so each guard has a test that fails when it is removed.
No count is given here, deliberately. This paragraph said "Both guards" when it meant the two OVERFLOW guards specifically, and when the rate-sign guard was added the obvious repair — "three" — was wrong twice over: the function holds four guards, and two of the four are preconditions rather than overflow checks, so any single number here answers an ambiguous question. CLAUDE.md § 4a's rule applies — prefer deleting a restated number over updating it — and the sabotage harness counts them by construction anyway.
An alternative was considered and rejected: binding the amount to `maxInt64/RateScale` so a plain int64 multiply could not wrap. That bound is safe for any µGNOT quantity and is refused for exactly that reason — justifying it needs a unit, and this package declares it has none. See DISCOVERY.md § 4.
The rate-sign guard was added after an adversarial audit, and the reason it was missing is worth more than the fix. The paragraph above proves the precondition from the rate *cap*, and a cap is an upper bound: writing it out established `rate <= RateScale` and said nothing about the other end, while the `slice < 0` guard sitting one line away made the function look as though both of its signed inputs had been considered. Measured, before the fix: `chargeSlice(1, -1)` returned `(1844674407370955, nil)` — a positive charge, a nil error, and both overflow guards cleanly passed, because `uint64(-1)` has a zero high word when multiplied by 1.
What made it survive review is the near miss: `chargeSlice(maxInt64, -1)` *is* caught, by `hi >= m`, so the negative-rate family is half-covered by an existing check and a spot probe at the interesting end of int64 reports an error exactly as it should. The hole is at the *small* end, where nothing about the value looks extreme. A guard that catches the dramatic case and misses the mundane one reads, from the outside, like a guard that works.
What bounds the work
Tier lookup is a linear scan over the edges, cheaper than a binary search at these sizes and the shape an auditor can check by reading. MaxTiers is 16, so there are at most 15 edges. One Schedule.Charge performs at most 16 128-bit multiplications and divisions over a 16-entry partition, and every exported method re-runs `check`, costing at most 50 further comparisons.
That 50 is `2 + 2 + (MaxTiers-2) + 2×MaxTiers`, and the decomposition is pinned by [TestConstantsAreTheDocumentedValues] rather than left as a number in prose beside a constant nobody checked. This project has shipped that defect more than once.
Those are the figures, not an assertion that they are affordable. A realm pricing many amounts in one transaction, or doing it inside a `Render`, must size them against the gas it has. This package bounds the work; it does not price it.
The one-tier schedule, and what it subsumes
Empty edges are legitimate: `New(nil, []int64{250})` is a flat 2.5% on the whole amount, with one tier and no breakpoint. It is the degenerate case of the same formula rather than a special path.
That schedule reproduces p/nt/grc721/royalty's arithmetic — `salePrice * Bps / FeeDenominator` against the same 10000 denominator — with one difference in the implementation and none in the result for the amounts that package can take. Royalty multiplies before dividing in plain int64, so it is implicitly bounded at about `maxInt64/10000 ≈ 9.2e14`; this package's 128-bit path has no such bound. [TestOneTierSchedule] pins the agreement over the range where both are defined.
p/aging is refused as a comparison in the other direction: it bins a *set* of items by each item's age, where this slices *one* amount by magnitude. A bin is not a slice, and the measurable consequence is the monotonicity section above. See DISCOVERY.md § 3.
What this package does not claim
- **No novelty for the formula.** A graduated rate schedule is textbook arithmetic and three independent mul-div helpers already exist in the ecosystem. What was not found in the searched sources is this shape: a partition of one amount into magnitude slices at caller-declared edges, each priced at its own rate. See DISCOVERY.md § 1 and § 3, which also record a functionally exact mul-div primitive that was found, classified reusable, and deliberately not imported.
- **No unit, deliberately.** The amount, the edges and the slices are int64 in whatever unit the caller keeps consistently. A rate is dimensionless. This is the stronger half of p/aging's rule, and here it costs nothing because there is no time axis to pick a convention for.
- **Nothing about money.** A charge is a number in a report. This package never sees a coin, and a realm using it must not infer custody or a transfer from it.
- **No opinion on where the edges and rates come from.** Picking them is the whole of a fee policy and none of it is here. They are validated for shape and range, never for plausibility — and, unlike p/aging, not for ordering either. A transposed rate array is accepted.
- **No schedule identity, and no versioning.** A Schedule is a value with no name and no effective date. A realm that changes its fees must keep its own record of which schedule priced which charge, because nothing here can tell two schedules apart or say when one took effect.
- **Not a rounding policy.** The floor is the only rounding this package performs and it is not configurable. A caller needing round-half-up, or needing the exact rational, must compute it from Schedule.SliceAt and the rates, both of which are exported for that purpose.
p/aging: /p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/aging p/depreciation: /p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/depreciation p/curve: /p/g1ecsuj0q572jr0dhu29q9njtnmw03hyu7tyyvv6/curve/v0 p/nt/grc721/royalty: /p/nt/grc721/royalty/v0
Imports
errorsmath/bits
Constants and variables
MaxTiers bounds the number of tiers, and so bounds the edge scan in Schedule.TierOf and the loops in Schedule.SliceAt and Schedule.ChargeByTier. 16 is past the point where a tier is meaningful: a conventional fee schedule uses three or four.
The minimum is 1, not 2, and that is the deliberate difference from p/aging's MaxBuckets. A one-tier schedule has no edges and applies one rate to the whole amount, which is a flat fee — degenerate, but the same formula, and useful enough to be the subject of its own section in the package doc. p/aging refuses its own one-bucket case because a flat loss rate is not an aging schedule at all; a flat fee *is* a rate schedule.
What the bound costs: a caller needing more than 16 tiers must compose two schedules, and the composition is **not** a 32-tier schedule. The second would restart its partition at 0, so every slice and every floor would be taken against the wrong origin. Composing graduated schedules is not supported and cannot be faked by addition.
const MaxTiers = 16
RateScale is the denominator every rate is expressed against, so a rate is in basis points: 0 is 0%, 250 is 2.5%, 10000 is 100%.
It is a constant rather than a per-schedule field on purpose, the same decision and the same reason as p/aging's: a schedule carrying its own denominator lets two schedules in one realm be priced on different scales, and the first symptom of that is a charge wrong by a factor of a hundred with every individual number looking reasonable.
The value is taken from the corpus rather than invented. p/nt/grc721/royalty declares it as the EIP-2981 basis-point denominator and `guildhall/trust/v0` independently ships the same ceiling; see DISCOVERY.md § 4.
const RateScale int64 = 10000
var (
// ErrBadTierCount is returned when a schedule would have fewer than 1 or
// more than MaxTiers tiers. The tier count is len(edges)+1, so this is a
// statement about the edge slice: it must hold between 0 and MaxTiers-1
// entries. A nil or empty edge slice is legitimate and does NOT land
// here — it is the one-tier flat schedule.
ErrBadTierCount = errors.New("tiers: tier count must be in [1, MaxTiers]")
// ErrBadEdges is returned when the edges are not positive and strictly
// increasing.
//
// Equal adjacent edges would define an empty tier, which is not an error
// in arithmetic but is always an authoring mistake: it silently reduces
// the schedule's resolution while every tier still reports.
//
// Positive and not merely non-negative, for the same reason one step
// earlier. A first edge of 0 makes tier 0 the half-open range [0,0),
// occupiable by nothing, for every amount, forever — while NumTiers still
// reports it and its rate still prints. That is [p/aging]'s finding Y2,
// which shipped as `< 0` and was corrected before deployment; the check
// here reaches the first edge from the outset because of it.
ErrBadEdges = errors.New("tiers: edges must be positive and strictly increasing")
// ErrBadRateCount is returned when len(rates) != len(edges)+1. The
// off-by-one here is the most likely way to build a wrong schedule that
// still looks right, so it is named apart from ErrBadRate: a reader
// seeing this error knows to count, not to look at values.
ErrBadRateCount = errors.New("tiers: need exactly one rate per tier, which is len(edges)+1")
// ErrBadRate is returned when a rate is negative or exceeds RateScale.
//
// The cap is arithmetic as well as hygiene. `rate <= RateScale` is what
// makes `Charge(n) <= n`, what makes the no-cliff bound
// `Charge(n+1) <= Charge(n)+1` hold, and what proves the 128-bit
// precondition in chargeSlice. A rate above 100% would charge more than
// the amount; a negative rate would charge a refund and would break the
// monotonicity the package doc claims.
//
// The rates are NOT required to be ordered. See the package doc: a
// falling schedule is a volume discount, and refusing it would be copying
// a check from [p/aging] whose two reasons do not transfer.
ErrBadRate = errors.New("tiers: each rate must be in [0, RateScale]")
// ErrBadAmount is returned for a negative amount, and by the per-tier
// accessors for a negative slice.
//
// Zero is legitimate, unlike [p/aging]'s obligation amount: an amount of
// zero partitions into all-zero slices and charges 0, which is the right
// answer and not a caller bug. A realm pricing an empty cart should not
// have to special-case it.
ErrBadAmount = errors.New("tiers: amount must not be negative")
// ErrBadTierIndex is returned by the per-tier accessors for an index
// outside [0, NumTiers).
ErrBadTierIndex = errors.New("tiers: tier index out of range")
// ErrOverflow is returned when the arithmetic would wrap.
//
// Through the exported API it is unreachable, and that is a claim with a
// proof rather than an absence of evidence: the slices sum to the amount
// and so each is at most maxInt64; every rate is at most RateScale; the
// product is taken in 128 bits; and the quotient is at most the slice. The
// guards in chargeSlice are kept anyway, because the proof rests entirely
// on the rate cap and would expire silently if the cap were raised. See
// the package doc, and TestChargeSliceGuardsAPreconditionTheAPICannotReach
// for the direct unexported calls that do reach them.
ErrOverflow = errors.New("tiers: int64 overflow")
)
Types
type Schedule
type Schedule struct {
// edges holds the tier breakpoints, strictly increasing and positive.
// Tier i is the half-open amount range [edges[i-1], edges[i]), with tier
// 0 starting at 0 and the last tier unbounded above. It may be empty,
// which is the one-tier flat schedule.
edges []int64
// rates holds one rate per tier in units of RateScale, each in
// [0, RateScale] and in any order. len(rates) == len(edges)+1.
rates []int64
}
Schedule is a graduated rate schedule: the amount breakpoints and the rate charged on each resulting slice.
Its fields are unexported and the accessors copy, following p/aging and for the same reason: a schedule holding slices is not safely copyable, and a caller that kept the slice it passed to New could mutate the edges after validation so that every subsequent call priced against a schedule that never passed `check`. Construct with New.
Schedule.Charge
func (s Schedule) Charge(amount int64) (int64, error)
Charge returns the total charge on an amount: one number for a ledger.
It is defined as the sum of Schedule.ChargeByTier, so the single figure and the line items can never disagree — the same discipline p/aging uses for Provision and p/depreciation for Charge.
The sum cannot overflow: each term is at most its slice, because every rate is at most RateScale, and the slices sum to the amount. That also gives `Charge(n) <= n`, stated in the package doc.
Schedule.ChargeByTier
func (s Schedule) ChargeByTier(amount int64) ([]int64, error)
ChargeByTier returns the charge on each slice of an amount, lowest first: the line-item column.
Each entry is `floor(sliceᵢ × rateᵢ / RateScale)`, the floor taken per slice so that this column sums to exactly what Schedule.Charge reports. See the package doc for what that choice costs.
Unlike p/aging's ProvisionByBucket, this takes the amount rather than a caller-built report, so there is no cross-foot to validate and no way to price a partition that came from a different schedule. p/aging exports its Report so that a realm can accumulate buckets across calls, and pays for it with four validation branches and a documented hazard. Nothing here needs accumulation — one amount is one amount — so the opportunity is removed instead of guarded.
Schedule.Edges
func (s Schedule) Edges() []int64
Edges returns a copy of the tier breakpoints. It is empty for a one-tier schedule.
Schedule.MarginalRate
func (s Schedule) MarginalRate(amount int64) (int64, error)
MarginalRate returns the rate the next unit of the amount will attract, in units of RateScale: the rate of Schedule.TierOf's tier.
It is not the average rate and not the rate the last unit was charged at. `MarginalRate(n)` prices unit n+1, so at an edge it returns the upper tier's rate; the package doc gives the reason the other reading is not offered.
The effective rate on the whole amount is `Charge(n) × RateScale / n`, which is the caller's arithmetic: it needs a rounding decision this package does not make, and it is undefined at n = 0.
Schedule.NumTiers
func (s Schedule) NumTiers() int
NumTiers is the number of tiers, which is one more than the number of edges. It returns 0 for an invalid schedule, including the zero value, so a caller that skipped Schedule.Valid gets a number that reads as wrong rather than one that reads as a legitimate one-tier schedule.
That distinction is sharper here than in p/aging, where 0 could not be confused with anything: a one-tier schedule is valid in this package, so if NumTiers returned 1 for the zero value a caller could not tell the flat schedule from the broken one.
Schedule.Rates
func (s Schedule) Rates() []int64
Rates returns a copy of the per-tier rates, in units of RateScale.
Schedule.SliceAt
func (s Schedule) SliceAt(amount int64) ([]int64, error)
SliceAt partitions an amount across the tiers and returns the slice in each, lowest first.
The slices sum to exactly the amount — no remainder, no dust, by construction rather than by a correction step — because a slice is the overlap of `[0, amount)` with the tier's half-open range and the ranges partition `[0, ∞)`. [TestSliceAtCrossFoots] asserts the sum, and the package doc explains why that assertion alone is weak: moving mass between adjacent slices preserves it. Each edge comparison therefore has its own sabotage case, in tools/test_tiers_sabotage.sh.
An amount of 0 returns all-zero slices and no error. Tiers above the amount are 0, which is why the result always has Schedule.NumTiers entries rather than being truncated at the last non-empty tier: a caller rendering line items against Schedule.Rates indexes both by tier.
Schedule.TierOf
func (s Schedule) TierOf(amount int64) (int, error)
TierOf returns the index of the tier that the NEXT unit of the amount falls in: 0 for the lowest, NumTiers-1 for the unbounded top.
The partition is half-open, [lo, hi), which is what makes it exhaustive and disjoint over every non-negative amount with no breakpoint shared by two tiers. An amount equal to an edge is in the **upper** tier, so with edge 1000, `TierOf(1000)` is 1: the amount 1000 exactly fills tier 0 and the next unit is the first one above the breakpoint.
Read it as a question about the next unit, never about the last one. See the package doc, which states the convention and names the off-by-one it avoids.
Schedule.TierRange
func (s Schedule) TierRange(i int) (lo, hi int64, unbounded bool, err error)
TierRange returns the half-open amount range of tier i as [lo, hi), together with whether it is unbounded above — which is true for the last tier and only the last tier, where hi is meaningless.
It exists so that a caller rendering line items does not have to reconstruct the half-open convention from Schedule.Edges. That reconstruction is where an off-by-one lives, and p/aging shipped one of those in its own discovery record; see the package doc.
Schedule.Valid
func (s Schedule) Valid() bool
Valid reports whether the schedule is well-formed. A zero Schedule is not.
Functions
New
func New(edges, rates []int64) (Schedule, error)
New validates and returns a schedule. The edges are the amount breakpoints, positive and strictly increasing; the rates are one per tier, so one more than there are edges, each in [0, RateScale].
New([]int64{1000, 5000}, []int64{100, 250, 500})
reads as: 1% on the first 1000, 2.5% on the next 4000, 5% on everything above 5000.
Empty edges are legitimate and give the one-tier flat schedule: `New(nil, []int64{250})` charges 2.5% of the whole amount. A first edge of 0 is ErrBadEdges — it would define an empty tier 0.
The rates are deliberately not required to be ordered; a falling schedule is a volume discount. See the package doc on why p/aging refuses what this accepts.
Both slices are copied, so the caller may reuse or mutate its own.
Doc text is reproduced as vm/qdoc returns it. The node markdown-escapes doc comments, so a bracket or angle bracket may carry a backslash the committed source does not have. The source itself is at source and in this repository.
Dependencies
chain-attested| Imports | errors, math/bits |
|---|---|
| First-party dependencies | none |
Known limitations
Recorded by the people who built and deployed it. This list is deliberately not empty where honesty costs something.
curated- THE BYTE-MATCH WAS EXACT. On-chain tiers.gno == committed 2adf734f at 36347 bytes; no reconciliation needed. Second consecutive byte-perfect /p/ deploy after p/aging.
- A STAMPED EVAL CITATION EXISTS, UNLIKE p/aging. gno_eval RateScale -> (10000 int64) with chain_id_bound: true is a section-9a value citation, because a package-level const evaluates on a bare /p/ where a method on a struct literal does not. The byte-match (unstamped, bound by URL) remains the which-bytes-run proof; the eval adds chain attestation of one source-derived value. Behaviour rests on the 41-test suite and 105-case sweep under the pinned onyx toolchain.
- GATE A/B COMPARED ZERO DEPENDENCIES. errors + math/bits are both stdlib; EQUIVALENT means 'no chain deps', not 'a closure was verified'. Both residual stdlibs execute from the node binary and rest on the GNOROOT pin.
- THE 0.53x /p/ CLUSTER WAS PREMATURE. tiers measured 0.3214x, so three bare /p/ now span 0.32x-0.53x. The ratio is not a predictor and was never treated as one for sizing; a deposit comes from the simulate dry run.
- COST FIGURES ARE SELF-REPORTED AND THE PACKAGE PRICES NOTHING. Edges and rates are an input validated for shape and range, never plausibility, and -- unlike p/aging -- not for ordering either, so a transposed rate array is accepted and undetectable. Picking a fee policy is none of this package.
- THE WORK IS BOUNDED, NOT PRICED. MaxTiers 16 bounds the loops; the doc gives the 128-bit mul/div and comparison counts but declines to assert they are affordable inside a Render.
- THE AUDIT WAS SELF-ADMINISTERED (Task unavailable in-subagent). Verdict PROCEED, 0 RED / 3 YELLOW; the rate-sign guard (chargeSlice(1,-1) returned a positive charge with nil error) was added after the adversarial pass and is recorded rather than smoothed.
Source
| Source file | onyx/p/tiers/tiers.gno at commit c2e10d17ffbf in the project repository (not public — the digest command above is the check that needs no repository) |
|---|---|
| Matches the deployed bytes | yes — byte-identical |