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}