Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

meters.gno

33.17 Kb · 989 lines
  1// Realm meters is usage-based billing with graduated marginal pricing: a
  2// provider publishes a METER (a unit of service, a list price per unit, a
  3// billing period, and a tiers.Schedule of marginal volume discounts), a
  4// consumer ENROLLS and prepays a deposit, the provider RECORDS usage as it is
  5// served, and each period's accumulated usage settles from the consumer's
  6// deposit into the provider's claimable balance.
  7//
  8// It is the usage-based complement to r/subscriptions' flat recurring plans —
  9// the one billing shape this portfolio lacked (its discovery gate's whole
 10// finding). The pricing identity is p/tiers':
 11//
 12//	base = units × unitPrice        (checked int64 multiplication)
 13//	due  = Schedule.Charge(base)    (≤ base — marginal discount off list)
 14//
 15// tiers validates every rate into [0, RateScale], so Charge(base) ≤ base is an
 16// arithmetic fact: a schedule can DISCOUNT list price and can never surcharge
 17// it. A flat price is the one-rate schedule (no edges, rates "10000") — the
 18// degenerate case, not a second code path.
 19//
 20// # The three trust statements, in order of who bears what
 21//
 22//   - USAGE IS PROVIDER-ASSERTED, and that declaration is the trust boundary —
 23//     the same boundary r/books draws at owner-posted entries and r/cashflow
 24//     at Policy.Cash. The realm cannot verify off-chain consumption; what it
 25//     does is make the assertion attributable (only the meter's provider may
 26//     Record, and only against an enrollment to that meter), bounded (next
 27//     statement), and auditable (every unit is an event and a readable row).
 28//   - LIABILITY IS BOUNDED BY DEPOSIT, fail-closed. Record refuses any posting
 29//     whose end-of-period charge would exceed the enrollment's deposited
 30//     balance, and a withdrawal leaves the pending charge covered. Charge is
 31//     monotone in units (tiers' documented monotonicity), so the refusal
 32//     predicate is well-defined. No arrears exist anywhere in this design: the
 33//     realm never holds a claim against funds a consumer has not deposited.
 34//   - SETTLEMENT IS LAZY AND EXACT. Usage accumulates against the period it
 35//     was recorded in; the first touch after a period boundary settles that
 36//     period's charge. A period nobody recorded usage in owes zero, so lazy
 37//     settlement loses nothing — there is no keeper, no upkeep dependency, and
 38//     a Settle valve is permissionless so neither party's absence can wedge
 39//     the other's funds.
 40//
 41// Funds move on pull paths only: providers Claim, consumers WithdrawDeposit or
 42// Exit, the fee recipient WithdrawFees. Periods are fixed-length block windows
 43// anchored at Enroll height — runtime.ChainHeight() throughout, never a
 44// caller-supplied time (r/books' obligation, inherited verbatim).
 45//
 46// REALM-CALLER CAVEAT, inherited from the siblings verbatim: coinio's receipt
 47// guard admits only EOA payers, so enrolling consumers are EOAs; assertNoSend
 48// reads the ORIGIN envelope, so every non-payable function refuses any
 49// transaction that attached coins anywhere. Providers may be EOAs or realms,
 50// but a realm provider must expose its own crossing path to Claim, or what it
 51// earns is stranded.
 52package meters
 53
 54import (
 55	"chain"
 56	"chain/runtime"
 57	"chain/runtime/unsafe"
 58	"strconv"
 59
 60	"gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/coinio"
 61	"gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/feeledger"
 62	"gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/tiers"
 63	"gno.land/p/nt/avl/v0"
 64	"gno.land/p/nt/markdown/sanitize/v0"
 65)
 66
 67// Denom is the only asset this realm accepts.
 68const Denom = "ugnot"
 69
 70// MaxFeeBps is the hard protocol-fee cap: 1000 bps = 10%.
 71const MaxFeeBps = int64(1000)
 72
 73// Meter status values.
 74const (
 75	MeterActive  = "active"
 76	MeterRetired = "retired"
 77)
 78
 79// Enrollment status values. Exited is terminal.
 80const (
 81	EnrollActive = "active"
 82	EnrollExited = "exited"
 83)
 84
 85// Input bounds.
 86const (
 87	MaxTitleLen = 80
 88	MaxUnitLen  = 24
 89
 90	// MinUnitPrice keeps a meter from being free-by-rounding; a provider
 91	// who wants a free tier declares a 0-rate first tier instead, which
 92	// keeps the arithmetic visible in the schedule rather than implicit
 93	// in a zero price.
 94	MinUnitPrice = int64(1)
 95	// MaxUnitPrice keeps base = units × unitPrice far from int64
 96	// overflow for any usage a deposit could plausibly cover; the
 97	// checked multiplication is still there and still refuses.
 98	MaxUnitPrice = int64(1_000_000_000) // 1000 GNOT per unit
 99
100	// MinPeriodBlocks/MaxPeriodBlocks bound a meter's billing period —
101	// the same bounds and reasoning as r/subscriptions: the floor keeps
102	// settlement from becoming a per-block treadmill, the ceiling keeps
103	// period arithmetic far from overflow.
104	MinPeriodBlocks = int64(10)
105	MaxPeriodBlocks = int64(7500000)
106
107	// MaxMetersPerProvider bounds catalog monopolization by a single
108	// address — the finding that was RED in permission_registry and
109	// service_registry, carried from the start here.
110	MaxMetersPerProvider = 20
111
112	// MaxEnrollmentsPerConsumer bounds one account's open-enrollment
113	// state. Exited enrollments free their slot.
114	MaxEnrollmentsPerConsumer = 100
115
116	// RenderLimit bounds every rendered list — an unbounded Render was
117	// YELLOW in three prior audits.
118	RenderLimit = 20
119)
120
121type meter struct {
122	id           int64
123	provider     address
124	title        string
125	unit         string // what one unit is, for humans: "call", "GB", "seat-day"
126	unitPrice    int64  // ugnot list price per unit
127	sched        tiers.Schedule
128	periodBlocks int64
129	feeBps       int64 // snapshotted at creation, charged at every settlement
130	status       string
131	enrollments  int64 // lifetime count, never decremented
132}
133
134type enrollment struct {
135	id       int64
136	meterID  int64
137	consumer address
138	provider address // copied at Enroll; never re-read from the meter
139	// pricing terms, all copied at Enroll so nothing a consumer agreed
140	// to can move underneath them (meters are immutable anyway; the
141	// copy makes that a structural fact of the enrollment rather than
142	// a property of the meter's lifecycle):
143	unitPrice    int64
144	sched        tiers.Schedule
145	periodBlocks int64
146	feeBps       int64
147
148	status      string
149	startHeight int64 // period anchor: period k is [start+k·p, start+(k+1)·p)
150	usage       int64 // units recorded in period usagePeriod, not yet settled
151	usagePeriod int64 // the period index usage belongs to
152	periods     int64 // lifetime settled-period count (nonzero charges only)
153}
154
155var (
156	admin        address // may set fee, fee recipient, stage successor
157	pendingAdmin address // staged by TransferAdmin, completes via AcceptAdmin
158	feeRecipient address // may withdraw fees and sweep surplus
159	feeBps       int64   // fee snapshotted into NEW meters
160
161	self      address // this realm's address, captured at deploy
162	nextMeter int64
163	nextEnr   int64
164
165	metersT     = avl.NewTree() // padID(id)              -> *meter
166	enrollments = avl.NewTree() // padID(id)              -> *enrollment
167	activeByKey = avl.NewTree() // padID(meterID)|consumer -> int64 enroll id, while Active
168	providerNum = avl.NewTree() // address -> *int64, live meters per provider
169	consumerNum = avl.NewTree() // address -> *int64, Active enrollments per consumer
170	ledger      = feeledger.MustNew(MaxFeeBps)
171)
172
173func init() {
174	admin = unsafe.OriginCaller()
175	feeRecipient = admin
176	self = unsafe.CurrentRealm().Address()
177}
178
179// --- provider side ---
180
181// CreateMeter publishes a meter and returns its id. No coins are accepted; the
182// storage deposit the caller pays is the anti-spam. edges and rates are
183// comma-separated int64 lists building the tiers.Schedule over the ugnot BASE
184// (units × unitPrice): len(rates) == len(edges)+1, each rate in [0, 10000]
185// ten-thousandths of list price. "" edges with rates "10000" is a flat meter.
186// The current protocol fee is snapshotted in and must not exceed maxFeeBps,
187// the ceiling the provider signed for — pass MaxFeeBps to accept any legal
188// fee. Meters are immutable once created: price, period or schedule changes
189// are a new meter, so nothing a consumer agreed to can move underneath them.
190func CreateMeter(cur realm, title, unit string, unitPrice, periodBlocks int64, edges, rates string, maxFeeBps int64) int64 {
191	assertNoSend()
192	provider := cur.Previous().Address()
193
194	if title == "" || len(title) > MaxTitleLen {
195		panic("title must be 1.." + itoa(MaxTitleLen) + " bytes")
196	}
197	if unit == "" || len(unit) > MaxUnitLen {
198		panic("unit must be 1.." + itoa(MaxUnitLen) + " bytes")
199	}
200	if unitPrice < MinUnitPrice || unitPrice > MaxUnitPrice {
201		panic("unitPrice must be " + itoa(MinUnitPrice) + ".." + itoa(MaxUnitPrice) + Denom)
202	}
203	if periodBlocks < MinPeriodBlocks || periodBlocks > MaxPeriodBlocks {
204		panic("periodBlocks must be " + itoa(MinPeriodBlocks) + ".." + itoa(MaxPeriodBlocks))
205	}
206	if maxFeeBps < 0 || maxFeeBps > MaxFeeBps {
207		panic("maxFeeBps must be 0.." + itoa(MaxFeeBps))
208	}
209	if feeBps > maxFeeBps {
210		panic("current protocol fee " + itoa(feeBps) + " bps exceeds the ceiling you signed for (" + itoa(maxFeeBps) + ")")
211	}
212	s, err := tiers.New(
213		parseInt64List(edges, tiers.MaxTiers-1, "edge"),
214		parseInt64List(rates, tiers.MaxTiers, "rate"),
215	)
216	if err != nil {
217		panic("schedule: " + err.Error())
218	}
219
220	n := counter(providerNum, provider)
221	if *n >= MaxMetersPerProvider {
222		panic("per-provider meter limit reached")
223	}
224
225	id := nextMeter
226	nextMeter++
227	metersT.Set(padID(id), &meter{
228		id:           id,
229		provider:     provider,
230		title:        title,
231		unit:         unit,
232		unitPrice:    unitPrice,
233		sched:        s,
234		periodBlocks: periodBlocks,
235		feeBps:       feeBps,
236		status:       MeterActive,
237	})
238	*n++
239
240	chain.Emit("MeterCreated",
241		"meterId", itoa(id),
242		"provider", provider.String(),
243		"unitPrice", itoa(unitPrice),
244		"periodBlocks", itoa(periodBlocks),
245		"feeBps", itoa(feeBps),
246	)
247	return id
248}
249
250// RetireMeter stops NEW enrollments and NEW usage on the caller's own meter.
251// Existing enrollments keep their funds paths whole: pending usage still
252// settles, deposits stay withdrawable, Exit still refunds. The provider's
253// meter slot frees. Terminal.
254func RetireMeter(cur realm, meterID int64) {
255	assertNoSend()
256	m := mustGetMeter(meterID)
257	if cur.Previous().Address() != m.provider {
258		panic("only the provider may retire a meter")
259	}
260	if m.status != MeterActive {
261		panic("meter is " + m.status)
262	}
263	m.status = MeterRetired
264	decCounter(providerNum, m.provider)
265	chain.Emit("MeterRetired", "meterId", itoa(meterID), "provider", m.provider.String())
266}
267
268// Record asserts that the enrollment's consumer used units more units of the
269// meter's service in the CURRENT period. Provider only. Fail-closed on the
270// deposit bound: if charging the period's new total at the enrollment's
271// schedule would exceed the deposited balance, the whole posting is refused —
272// never clipped, because a clipped assertion is a wrong assertion with the
273// same signature as a right one. Any elapsed period settles first, so the
274// bound is always measured against the balance that will actually back this
275// period.
276func Record(cur realm, enrollID, units int64) {
277	assertNoSend()
278	e := mustGetEnrollment(enrollID)
279	if cur.Previous().Address() != e.provider {
280		panic("only the provider may record usage")
281	}
282	if e.status != EnrollActive {
283		panic("enrollment is " + e.status)
284	}
285	m := mustGetMeter(e.meterID)
286	if m.status != MeterActive {
287		panic("meter is " + m.status + "; no new usage")
288	}
289	if units <= 0 {
290		panic("units must be positive")
291	}
292	settleElapsed(e)
293
294	newUsage, ok := addChecked(e.usage, units)
295	if !ok {
296		panic("usage overflow")
297	}
298	charge := chargeFor(e, newUsage)
299	bal := ledger.BalanceOf(enrollKey(e.id))
300	if charge > bal {
301		panic("deposit bound: charging " + itoa(newUsage) + " units costs " + itoa(charge) +
302			Denom + " against a balance of " + itoa(bal) + Denom +
303			" — the consumer must deposit before this usage can be recorded")
304	}
305	e.usage = newUsage
306
307	chain.Emit("UsageRecorded",
308		"enrollId", itoa(enrollID),
309		"meterId", itoa(e.meterID),
310		"units", itoa(units),
311		"periodUsage", itoa(newUsage),
312		"period", itoa(e.usagePeriod),
313		"pendingCharge", itoa(charge),
314	)
315}
316
317// --- consumer side ---
318
319// Enroll opens an enrollment on a meter for the caller, with any attached
320// coins credited as the initial deposit. One active enrollment per
321// (meter, consumer); Exit frees the slot. Terms (price, schedule, period,
322// fee) are copied in at this moment and never re-read.
323func Enroll(cur realm, meterID int64) int64 {
324	// The deposit is optional at enrollment: with coins attached the payer is
325	// the EOA origin coinio's receipt guard admits, without them the caller is
326	// taken as-is. A REALM may therefore enroll coinless, but it can never
327	// fund the enrollment afterwards (Deposit's payer comes through the same
328	// EOA-only guard), so a realm enrollment is inert rather than unsafe.
329	consumer := cur.Previous().Address()
330	amount := int64(0)
331	if !unsafe.OriginSend().IsZero() {
332		consumer, amount = coinio.Receive(0, cur, Denom)
333	}
334	m := mustGetMeter(meterID)
335	if m.status != MeterActive {
336		panic("meter is " + m.status)
337	}
338	key := padID(meterID) + "|" + consumer.String()
339	if activeByKey.Get(key) != nil {
340		panic("an active enrollment on this meter already exists")
341	}
342	n := counter(consumerNum, consumer)
343	if *n >= MaxEnrollmentsPerConsumer {
344		panic("per-consumer enrollment limit reached")
345	}
346
347	if amount > 0 {
348		ledger.MustDeposit(enrollKey(nextEnr), amount, 0)
349	}
350
351	id := nextEnr
352	nextEnr++
353	enrollments.Set(padID(id), &enrollment{
354		id:           id,
355		meterID:      meterID,
356		consumer:     consumer,
357		provider:     m.provider,
358		unitPrice:    m.unitPrice,
359		sched:        m.sched,
360		periodBlocks: m.periodBlocks,
361		feeBps:       m.feeBps,
362		status:       EnrollActive,
363		startHeight:  runtime.ChainHeight(),
364		usagePeriod:  0,
365	})
366	activeByKey.Set(key, id)
367	*n++
368	m.enrollments++
369
370	chain.Emit("Enrolled",
371		"enrollId", itoa(id),
372		"meterId", itoa(meterID),
373		"consumer", consumer.String(),
374		"deposit", itoa(amount),
375	)
376	return id
377}
378
379// Deposit adds the attached coins to the caller's own enrollment deposit.
380// Elapsed usage settles first, so a top-up lands on a current balance.
381func Deposit(cur realm, enrollID int64) {
382	payer, amount := coinio.Receive(0, cur, Denom)
383	e := mustGetEnrollment(enrollID)
384	if payer != e.consumer {
385		panic("only the consumer may deposit")
386	}
387	if e.status != EnrollActive {
388		panic("enrollment is " + e.status)
389	}
390	if amount <= 0 {
391		panic("attach the deposit to the call")
392	}
393	settleElapsed(e)
394	ledger.MustDeposit(enrollKey(enrollID), amount, 0)
395	chain.Emit("Deposited",
396		"enrollId", itoa(enrollID),
397		"amount", itoa(amount),
398		"balance", itoa(ledger.BalanceOf(enrollKey(enrollID))),
399	)
400}
401
402// WithdrawDeposit returns amount of the caller's own enrollment deposit.
403// The current period's pending charge stays covered: withdrawable is
404// balance − Charge(current usage), so a provider's already-recorded usage can
405// never be defunded. Elapsed usage settles first.
406func WithdrawDeposit(cur realm, enrollID, amount int64) {
407	assertNoSend()
408	e := mustGetEnrollment(enrollID)
409	caller := cur.Previous().Address()
410	if caller != e.consumer {
411		panic("only the consumer may withdraw the deposit")
412	}
413	if e.status != EnrollActive {
414		panic("enrollment is " + e.status)
415	}
416	if amount <= 0 {
417		panic("amount must be positive")
418	}
419	settleElapsed(e)
420	pending := chargeFor(e, e.usage)
421	free := ledger.BalanceOf(enrollKey(enrollID)) - pending
422	if amount > free {
423		panic("withdrawable is " + itoa(free) + Denom + " (balance minus the pending charge " +
424			itoa(pending) + Denom + ")")
425	}
426	ledger.MustWithdraw(enrollKey(enrollID), amount)
427	coinio.Payout(0, cur, caller, Denom, amount)
428	chain.Emit("DepositWithdrawn",
429		"enrollId", itoa(enrollID),
430		"amount", itoa(amount),
431	)
432}
433
434// Exit closes the caller's own enrollment: elapsed periods settle, the
435// CURRENT period's recorded usage settles immediately (the provider served
436// it; exiting is not an escape from it), the remaining deposit refunds in
437// full, and the slot frees. Terminal.
438func Exit(cur realm, enrollID int64) {
439	assertNoSend()
440	e := mustGetEnrollment(enrollID)
441	caller := cur.Previous().Address()
442	if caller != e.consumer {
443		panic("only the consumer may exit")
444	}
445	if e.status != EnrollActive {
446		panic("enrollment is " + e.status)
447	}
448	settleElapsed(e)
449	settleUsage(e) // the current partial period, charged on what was consumed
450
451	refund, err := ledger.WithdrawAll(enrollKey(enrollID))
452	if err != nil {
453		panic(err)
454	}
455	e.status = EnrollExited
456	activeByKey.Remove(padID(e.meterID) + "|" + e.consumer.String())
457	decCounter(consumerNum, e.consumer)
458	if refund > 0 {
459		coinio.Payout(0, cur, caller, Denom, refund)
460	}
461	chain.Emit("Exited",
462		"enrollId", itoa(enrollID),
463		"refund", itoa(refund),
464	)
465}
466
467// Settle settles any fully-elapsed period of an enrollment. Permissionless by
468// design — like the sibling realms' valves, no settlement's liveness may
469// depend on either party showing up. No coins may ride on it, and calling it
470// on an enrollment with nothing elapsed is a refused no-op so the event
471// stream never carries empty settlements.
472func Settle(cur realm, enrollID int64) {
473	assertNoSend()
474	e := mustGetEnrollment(enrollID)
475	if e.status != EnrollActive {
476		panic("enrollment is " + e.status)
477	}
478	if currentPeriod(e) <= e.usagePeriod {
479		panic("nothing to settle: the current period is still open")
480	}
481	settleElapsed(e)
482}
483
484// --- payouts ---
485
486// Claim sends amount ugnot of the caller's claimable balance back to the
487// caller. Providers earn into this balance at every settlement.
488func Claim(cur realm, amount int64) {
489	assertNoSend()
490	caller := cur.Previous().Address()
491	if err := ledger.Withdraw(caller.String(), amount); err != nil {
492		panic(err)
493	}
494	coinio.Payout(0, cur, caller, Denom, amount)
495	chain.Emit("Claim", "to", caller.String(), "amount", itoa(amount))
496}
497
498// ClaimAll sends the caller's entire claimable balance back to the caller.
499// Fails if there is nothing to claim.
500func ClaimAll(cur realm) {
501	assertNoSend()
502	caller := cur.Previous().Address()
503	amount, err := ledger.WithdrawAll(caller.String())
504	if err != nil {
505		panic(err)
506	}
507	if amount == 0 {
508		panic("nothing to claim")
509	}
510	coinio.Payout(0, cur, caller, Denom, amount)
511	chain.Emit("Claim", "to", caller.String(), "amount", itoa(amount))
512}
513
514// WithdrawFees sends the accrued fee pot to the fee recipient. Only the fee
515// recipient may call it, and only the pot moves.
516func WithdrawFees(cur realm) {
517	assertNoSend()
518	if cur.Previous().Address() != feeRecipient {
519		panic("fee recipient only")
520	}
521	amount := ledger.WithdrawFees()
522	if amount == 0 {
523		panic("no fees accrued")
524	}
525	coinio.Payout(0, cur, feeRecipient, Denom, amount)
526	chain.Emit("FeesWithdrawn", "to", feeRecipient.String(), "amount", itoa(amount))
527}
528
529// SweepDenom recovers out-of-band coins (sent by raw bank transfer, outside
530// any entrypoint) to the fee recipient. For the ledger denom the reserve is
531// Liabilities() — user balances and the fee pot are structurally unreachable.
532// Fee recipient only.
533func SweepDenom(cur realm, denom string) {
534	assertNoSend()
535	if cur.Previous().Address() != feeRecipient {
536		panic("fee recipient only")
537	}
538	reserve := int64(0)
539	if denom == Denom {
540		reserve = ledger.Liabilities()
541	}
542	swept := coinio.Sweep(0, cur, feeRecipient, denom, reserve)
543	chain.Emit("SurplusSwept", "to", feeRecipient.String(), "coins", itoa(swept)+denom)
544}
545
546// --- administration (two-step transfer, the house pattern) ---
547
548// SetFeeBps sets the protocol fee snapshotted into NEW meters. Admin only.
549// No existing meter or enrollment moves.
550func SetFeeBps(cur realm, bps int64) {
551	assertNoSend()
552	if cur.Previous().Address() != admin {
553		panic("admin only")
554	}
555	if bps < 0 || bps > MaxFeeBps {
556		panic("fee must be 0.." + itoa(MaxFeeBps) + " bps")
557	}
558	feeBps = bps
559	chain.Emit("FeeSet", "bps", itoa(bps))
560}
561
562// SetFeeRecipient points fee withdrawal and sweeps at recipient. Admin only.
563func SetFeeRecipient(cur realm, recipient address) {
564	assertNoSend()
565	if cur.Previous().Address() != admin {
566		panic("admin only")
567	}
568	if !recipient.IsValid() {
569		panic("invalid recipient")
570	}
571	feeRecipient = recipient
572	chain.Emit("FeeRecipientSet", "to", recipient.String())
573}
574
575// TransferAdmin stages successor as the next admin; AcceptAdmin completes it.
576// Two-step with consent — a one-step transfer bricked resources in two prior
577// audits (service_registry Y4, permission_registry Y4).
578func TransferAdmin(cur realm, successor address) {
579	assertNoSend()
580	if cur.Previous().Address() != admin {
581		panic("admin only")
582	}
583	if !successor.IsValid() {
584		panic("invalid successor")
585	}
586	pendingAdmin = successor
587	chain.Emit("AdminTransferStaged", "to", successor.String())
588}
589
590// AcceptAdmin completes a staged admin transfer. Pending admin only.
591func AcceptAdmin(cur realm) {
592	assertNoSend()
593	caller := cur.Previous().Address()
594	if pendingAdmin == "" || caller != pendingAdmin {
595		panic("no admin transfer staged for caller")
596	}
597	admin = pendingAdmin
598	pendingAdmin = ""
599	chain.Emit("AdminTransferred", "to", caller.String())
600}
601
602// --- views (non-crossing: cross-realm readable) ---
603
604// PriceOf prices units of meter meterID at its schedule: the charge a period
605// closing at exactly that usage would settle. The quote every consumer can
606// take before enrolling and every provider can show beside an invoice.
607func PriceOf(meterID, units int64) int64 {
608	m := mustGetMeter(meterID)
609	if units < 0 {
610		panic("units must be non-negative")
611	}
612	base, ok := mulChecked(units, m.unitPrice)
613	if !ok {
614		panic("base overflow")
615	}
616	c, err := m.sched.Charge(base)
617	if err != nil {
618		panic("charge: " + err.Error())
619	}
620	return c
621}
622
623// PendingChargeOf is the charge the enrollment's current recorded usage would
624// settle at — what WithdrawDeposit holds back.
625func PendingChargeOf(enrollID int64) int64 {
626	e := mustGetEnrollment(enrollID)
627	return chargeFor(e, e.usage)
628}
629
630// BalanceOf is an enrollment's deposited balance as the LEDGER holds it —
631// including a not-yet-settled elapsed period's charge; net view: subtract
632// PendingChargeOf.
633func BalanceOf(enrollID int64) int64 {
634	mustGetEnrollment(enrollID)
635	return ledger.BalanceOf(enrollKey(enrollID))
636}
637
638// ChargeHeadroomOf is how much MORE charge the current period could absorb
639// before Record's deposit bound refuses: balance − pending charge.
640func ChargeHeadroomOf(enrollID int64) int64 {
641	e := mustGetEnrollment(enrollID)
642	return ledger.BalanceOf(enrollKey(enrollID)) - chargeFor(e, e.usage)
643}
644
645// MeterInfo returns a meter's terms.
646func MeterInfo(meterID int64) (provider address, title, unit string, unitPrice, periodBlocks, meterFeeBps int64, status string, lifetimeEnrollments int64) {
647	m := mustGetMeter(meterID)
648	return m.provider, m.title, m.unit, m.unitPrice, m.periodBlocks, m.feeBps, m.status, m.enrollments
649}
650
651// ScheduleOf returns a meter's schedule as its edge and rate slices.
652func ScheduleOf(meterID int64) (edges, rates []int64) {
653	m := mustGetMeter(meterID)
654	return m.sched.Edges(), m.sched.Rates()
655}
656
657// EnrollInfo returns an enrollment's state. usage/usagePeriod are the raw
658// accumulator — an elapsed, unsettled period reads here exactly as it will
659// settle.
660func EnrollInfo(enrollID int64) (meterID int64, consumer, provider address, status string, startHeight, usage, usagePeriod, settledPeriods int64) {
661	e := mustGetEnrollment(enrollID)
662	return e.meterID, e.consumer, e.provider, e.status, e.startHeight, e.usage, e.usagePeriod, e.periods
663}
664
665// ActiveEnrollID resolves the caller-free (meter, consumer) index.
666func ActiveEnrollID(meterID int64, consumer address) (int64, bool) {
667	v := activeByKey.Get(padID(meterID) + "|" + consumer.String())
668	if v == nil {
669		return 0, false
670	}
671	return v.(int64), true
672}
673
674// CurrentPeriodOf is the period index now open for an enrollment.
675func CurrentPeriodOf(enrollID int64) int64 { return currentPeriod(mustGetEnrollment(enrollID)) }
676
677func Admin() address            { return admin }
678func PendingAdmin() address     { return pendingAdmin }
679func FeeRecipient() address     { return feeRecipient }
680func FeeBps() int64             { return feeBps }
681func NumMeters() int64          { return nextMeter }
682func NumEnrollments() int64     { return nextEnr }
683func UsersTotal() int64         { return ledger.UsersTotal() }
684func FeesAccrued() int64        { return ledger.FeesAccrued() }
685func Liabilities() int64        { return ledger.Liabilities() }
686func ClaimableOf(a address) int64 { return ledger.BalanceOf(a.String()) }
687func Held() int64               { return coinio.HeldAt(self, Denom) }
688func Address() address          { return self }
689func Height() int64             { return runtime.ChainHeight() }
690
691// --- internals ---
692
693// currentPeriod is the index of the period open at the chain head.
694func currentPeriod(e *enrollment) int64 {
695	return (runtime.ChainHeight() - e.startHeight) / e.periodBlocks
696}
697
698// chargeFor prices usage units at the enrollment's copied terms.
699func chargeFor(e *enrollment, usage int64) int64 {
700	if usage == 0 {
701		return 0
702	}
703	base, ok := mulChecked(usage, e.unitPrice)
704	if !ok {
705		panic("base overflow")
706	}
707	c, err := e.sched.Charge(base)
708	if err != nil {
709		panic("charge: " + err.Error())
710	}
711	return c
712}
713
714// settleElapsed settles the usage accumulator if its period has closed, then
715// re-anchors the accumulator on the current period. Exact because usage can
716// only ever belong to one period: periods between usagePeriod and now had no
717// recorded usage and so owe zero.
718func settleElapsed(e *enrollment) {
719	cp := currentPeriod(e)
720	if cp <= e.usagePeriod {
721		return
722	}
723	settleUsage(e)
724	e.usagePeriod = cp
725}
726
727// settleUsage charges the accumulator where it stands and moves the funds:
728// enrollment deposit -> provider claimable, protocol fee split at the
729// enrollment's snapshotted bps. Record's deposit bound is what guarantees the
730// withdrawal cannot fail; if that invariant were ever broken the ledger's own
731// refusal aborts the whole transaction rather than under-settling.
732func settleUsage(e *enrollment) {
733	if e.usage == 0 {
734		return
735	}
736	charge := chargeFor(e, e.usage)
737	// A zero charge is a LEGAL settlement, not a degenerate one: the realm's
738	// own doc recommends a 0-rate first tier for free allowances, and the
739	// per-slice floor prices small bases at 0 under positive rates too. The
740	// ledger refuses a zero-amount move (feeledger: amount must be positive),
741	// so an unconditional MustWithdraw here panics and WEDGES the enrollment
742	// forever -- every path to its funds routes through this function. Audit
743	// finding R1. The accumulator still clears; nothing is owed, so nothing
744	// moves, no period is counted settled and no Settled event is emitted
745	// (the event stream carries funds moves, not free usage).
746	if charge > 0 {
747		ledger.MustWithdraw(enrollKey(e.id), charge)
748		credited, fee := ledger.MustDeposit(e.provider.String(), charge, e.feeBps)
749		e.periods++
750		chain.Emit("Settled",
751			"enrollId", itoa(e.id),
752			"meterId", itoa(e.meterID),
753			"period", itoa(e.usagePeriod),
754			"units", itoa(e.usage),
755			"charge", itoa(charge),
756			"credited", itoa(credited),
757			"fee", itoa(fee),
758		)
759	}
760	e.usage = 0
761}
762
763func mustGetMeter(id int64) *meter {
764	v := metersT.Get(padID(id))
765	if v == nil {
766		panic("unknown meter " + itoa(id))
767	}
768	return v.(*meter)
769}
770
771func mustGetEnrollment(id int64) *enrollment {
772	v := enrollments.Get(padID(id))
773	if v == nil {
774		panic("unknown enrollment " + itoa(id))
775	}
776	return v.(*enrollment)
777}
778
779// enrollKey is the ledger account an enrollment's deposit lives under. It
780// cannot collide with a provider's claimable account: addresses never start
781// with '#'.
782func enrollKey(id int64) string { return "#enr|" + padID(id) }
783
784// assertNoSend refuses coins on every non-payable entrypoint. Reads the
785// ORIGIN envelope, so a transaction that attached coins anywhere is refused
786// even if an intermediary consumed them.
787func assertNoSend() {
788	if !unsafe.OriginSend().IsZero() {
789		panic("this function does not accept coins")
790	}
791}
792
793// counter returns the live-count cell for a, creating it at zero.
794func counter(tree *avl.Tree, a address) *int64 {
795	k := a.String()
796	if v := tree.Get(k); v != nil {
797		return v.(*int64)
798	}
799	n := int64(0)
800	tree.Set(k, &n)
801	return &n
802}
803
804// decCounter decrements a's live count and removes the cell at zero — the
805// index must not accumulate dead keys (service_registry's lesson).
806func decCounter(tree *avl.Tree, a address) {
807	k := a.String()
808	v := tree.Get(k)
809	if v == nil {
810		panic("counter underflow: no cell for " + k)
811	}
812	n := v.(*int64)
813	*n--
814	if *n < 0 {
815		panic("counter underflow for " + k)
816	}
817	if *n == 0 {
818		tree.Remove(k)
819	}
820}
821
822// parseInt64List parses a comma-separated int64 list, bounding the element
823// count BEFORE materialising values so a thousand-element string dies on
824// count, not memory (r/receivables' bounding argument). "" is the empty list.
825func parseInt64List(s string, max int, what string) []int64 {
826	if s == "" {
827		return nil
828	}
829	var out []int64
830	start := 0
831	for i := 0; i <= len(s); i++ {
832		if i < len(s) && s[i] != ',' {
833			continue
834		}
835		if len(out) >= max {
836			panic("too many " + what + "s: at most " + strconv.Itoa(max))
837		}
838		field := s[start:i]
839		start = i + 1
840		n, err := strconv.ParseInt(trimSpaces(field), 10, 64)
841		if err != nil {
842			panic("bad " + what + " " + field + ": " + err.Error())
843		}
844		out = append(out, n)
845	}
846	return out
847}
848
849func trimSpaces(s string) string {
850	i, j := 0, len(s)
851	for i < j && s[i] == ' ' {
852		i++
853	}
854	for j > i && s[j-1] == ' ' {
855		j--
856	}
857	return s[i:j]
858}
859
860// addChecked / mulChecked are int64 overflow-checked arithmetic. Written here
861// rather than imported because they are two lines each and the house keeps
862// its payment arithmetic visible at the call site.
863func addChecked(a, b int64) (int64, bool) {
864	s := a + b
865	if (b > 0 && s < a) || (b < 0 && s > a) {
866		return 0, false
867	}
868	return s, true
869}
870
871func mulChecked(a, b int64) (int64, bool) {
872	if a == 0 || b == 0 {
873		return 0, true
874	}
875	p := a * b
876	if p/b != a {
877		return 0, false
878	}
879	return p, true
880}
881
882func padID(id int64) string {
883	s := strconv.FormatInt(id, 10)
884	for len(s) < 12 {
885		s = "0" + s
886	}
887	return s
888}
889
890func itoa(n int64) string { return strconv.FormatInt(n, 10) }
891
892// --- Render ---
893
894// Render shows the catalog at "", a meter at "meter/<id>", an enrollment at
895// "enroll/<id>". Every user string is sanitized; every list is bounded by
896// RenderLimit with the bound stated.
897func Render(path string) string {
898	switch {
899	case path == "":
900		return renderHome()
901	case hasPrefix(path, "meter/"):
902		return renderMeter(path[len("meter/"):])
903	case hasPrefix(path, "enroll/"):
904		return renderEnrollment(path[len("enroll/"):])
905	default:
906		return "unknown path; try \"\", \"meter/<id>\" or \"enroll/<id>\"\n"
907	}
908}
909
910func renderHome() string {
911	out := "# meters — usage-based billing, graduated pricing\n\n"
912	out += "Meters: " + itoa(nextMeter) + " · enrollments: " + itoa(nextEnr) +
913		" · liabilities: " + itoa(ledger.Liabilities()) + Denom +
914		" · fees accrued: " + itoa(ledger.FeesAccrued()) + Denom + "\n\n"
915	out += "| id | title | unit | list price | period | status |\n|---|---|---|---|---|---|\n"
916	// Newest-first, matching r/subscriptions: under oldest-first a squatter
917	// retires 20 early meters once and owns the visible catalog forever;
918	// newest-first makes that cost continuous re-creation. Audit finding Y2.
919	shown := 0
920	metersT.ReverseIterate("", "", func(_ string, v any) bool {
921		m := v.(*meter)
922		// TableCell, not InlineText: these strings sit between GFM pipes, and
923		// InlineText deliberately leaves | literal -- a provider title with a
924		// pipe would inject cells and spoof the price and status columns of
925		// the one page a consumer reads before enrolling. Audit finding Y1.
926		out += "| " + itoa(m.id) + " | " + sanitize.TableCell(m.title) + " | " +
927			sanitize.TableCell(m.unit) + " | " + itoa(m.unitPrice) + Denom + " | " +
928			itoa(m.periodBlocks) + " blocks | " + m.status + " |\n"
929		shown++
930		return shown >= RenderLimit
931	})
932	if nextMeter > int64(shown) {
933		out += "\n(first " + itoa(int64(shown)) + " of " + itoa(nextMeter) + " meters)\n"
934	}
935	return out
936}
937
938func renderMeter(idStr string) string {
939	id, err := strconv.ParseInt(idStr, 10, 64)
940	if err != nil {
941		return "bad meter id\n"
942	}
943	v := metersT.Get(padID(id))
944	if v == nil {
945		return "unknown meter " + idStr + "\n"
946	}
947	m := v.(*meter)
948	out := "# meter " + itoa(m.id) + " — " + sanitize.InlineText(m.title) + "\n\n"
949	out += "- provider: " + m.provider.String() + "\n"
950	out += "- unit: " + sanitize.InlineText(m.unit) + " at list " + itoa(m.unitPrice) + Denom + "\n"
951	out += "- period: " + itoa(m.periodBlocks) + " blocks · fee: " + itoa(m.feeBps) + " bps · status: " + m.status + "\n"
952	out += "- lifetime enrollments: " + itoa(m.enrollments) + "\n\n"
953	out += "Schedule over the period base (units × list):\n\n| tier from (base " + Denom + ") | rate (/10000) |\n|---|---|\n"
954	edges, rates := m.sched.Edges(), m.sched.Rates()
955	lo := int64(0)
956	for i, r := range rates {
957		out += "| " + itoa(lo) + " | " + itoa(r) + " |\n"
958		if i < len(edges) {
959			lo = edges[i]
960		}
961	}
962	return out
963}
964
965func renderEnrollment(idStr string) string {
966	id, err := strconv.ParseInt(idStr, 10, 64)
967	if err != nil {
968		return "bad enrollment id\n"
969	}
970	v := enrollments.Get(padID(id))
971	if v == nil {
972		return "unknown enrollment " + idStr + "\n"
973	}
974	e := v.(*enrollment)
975	pending := chargeFor(e, e.usage)
976	out := "# enrollment " + itoa(e.id) + " (meter " + itoa(e.meterID) + ")\n\n"
977	out += "- consumer: " + e.consumer.String() + "\n"
978	out += "- provider: " + e.provider.String() + "\n"
979	out += "- status: " + e.status + " · settled periods: " + itoa(e.periods) + "\n"
980	out += "- period " + itoa(currentPeriod(e)) + " open; usage accumulator: " +
981		itoa(e.usage) + " units in period " + itoa(e.usagePeriod) + "\n"
982	out += "- balance: " + itoa(ledger.BalanceOf(enrollKey(e.id))) + Denom +
983		" · pending charge: " + itoa(pending) + Denom + "\n"
984	return out
985}
986
987func hasPrefix(s, p string) bool {
988	return len(s) >= len(p) && s[:len(p)] == p
989}