subscriptions.gno
28.27 Kb · 776 lines
1// Package subscriptions is a multi-provider subscription hub: providers
2// publish plans priced per billing period; subscribers pay per period,
3// with deterministic renewal, grace, expiration and cancellation rules;
4// other realms and off-chain services gate access on Entitled /
5// EntitledFor, which is the integration surface this realm exists to
6// provide.
7//
8// THE BILLING MODEL, COMPLETELY:
9//
10// Subscribe (payable, exact price) : creates the subscription, pays
11// period 1. paidThrough = height
12// + periodBlocks.
13// Renew (payable, exact price) : extends paidThrough by exactly
14// one periodBlocks, FROM
15// paidThrough — never from the
16// current height — so period
17// boundaries are fixed at
18// Subscribe time and never drift.
19// Entitlement : height < paidThrough. Nothing
20// else. Status does not enter
21// into it: a cancelled
22// subscription stays entitled to
23// what it already paid for.
24// Renewal window : a renewal is accepted iff BOTH
25// paidThrough - height <= periodBlocks (early bound)
26// height < paidThrough + periodBlocks (late bound)
27// The early bound caps prepayment
28// at one full unstarted period —
29// a second Renew straight after a
30// first is refused, which is what
31// makes an accidental duplicate
32// payment structurally impossible
33// rather than merely unlikely.
34// The late bound is the grace
35// window: renewing after lapse
36// extends from paidThrough, so it
37// back-pays the lapsed span to
38// keep the original schedule and
39// buys paidThrough + periodBlocks
40// - height further blocks — always
41// at least one, because the bound
42// is exclusive (audit finding Y2).
43// A lapsed subscriber who prefers
44// a fresh full period may Cancel
45// and Subscribe again at the same
46// total price; Subscribe's refusal
47// message states both options.
48// Expire (permissionless valve) : once height >= paidThrough +
49// periodBlocks, anyone may mark
50// the subscription Expired. No
51// funds move — every payment
52// settled when it was made. The
53// valve exists so the
54// plan|subscriber slot frees
55// without depending on either
56// party, and Subscribe itself
57// collapses an expired incumbent,
58// so a fresh start never depends
59// on housekeeping having run. The
60// renewable and expirable height
61// sets partition exactly: no
62// height is in both or neither.
63// Cancel (subscriber only) : Active -> Cancelled. Terminal.
64// No refund — payments settle to
65// the provider at payment time,
66// and what was bought (entitlement
67// through paidThrough) stays
68// bought. What cancellation ends
69// is the OBLIGATION: a Cancelled
70// subscription can never be
71// renewed, by the subscriber or
72// anyone else.
73// RetirePlan (provider only) : no new Subscribes, no renewals.
74// Existing entitlements run to
75// paidThrough untouched. Refusing
76// renewals on a retired plan is
77// subscriber protection: nobody
78// can keep paying for a service
79// whose provider announced its
80// end.
81//
82// The obligation is therefore explicit on chain at every moment: a
83// subscription owes nothing (there is no pull payment and no debt — a
84// lapse simply ends entitlement), and the realm owes the subscriber
85// exactly `paidThrough - height` blocks of entitlement, queryable by
86// anyone via PaidThrough / Entitled / EntitledFor.
87//
88// WHO PAYS WHOM. Payments settle immediately: price - fee is credited
89// to the provider's claimable balance, fee to the protocol pot, both
90// inside the same feeledger the sibling realms use. There is no escrow:
91// H == U + F at all times (plus out-of-band surplus, recoverable by
92// SweepDenom above the Liabilities reserve). The renewal caller must be
93// the subscriber — a third party cannot extend someone else's
94// subscription, which closes both a consent problem (an unwanted gift
95// re-arms a lapsing obligation) and a griefing edge (spending pennies
96// to keep a victim's slot occupied).
97//
98// FEES follow the house pattern exactly: a compile-time MaxFeeBps
99// ceiling, the current fee snapshotted into the PLAN at CreatePlan
100// (provider consents via its own maxFeeBps argument), copied into the
101// subscription at Subscribe, and charged at every payment from the
102// PROVIDER's side. A later SetFeeBps touches only plans created
103// afterwards; no existing plan or subscription can have its fee moved
104// by anyone.
105//
106// REALM-CALLER CAVEAT, inherited from the siblings verbatim: coinio's
107// receipt guard admits only EOA payers, so subscribers are EOAs;
108// assertNoSend reads the ORIGIN envelope, so every non-payable function
109// refuses any transaction that attached coins anywhere. Providers may
110// be EOAs or realms, but a realm provider must expose its own crossing
111// path to Claim, or what it earns is stranded (see RegisterService's
112// caveat in service_market — the same three obligations apply).
113package subscriptions
114
115import (
116 "chain"
117 "chain/runtime"
118 "chain/runtime/unsafe"
119 "strconv"
120
121 "gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/coinio"
122 "gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/feeledger"
123 "gno.land/p/nt/avl/v0"
124 "gno.land/p/nt/markdown/sanitize/v0"
125)
126
127// Denom is the only asset this realm accepts.
128const Denom = "ugnot"
129
130// MaxFeeBps is the hard protocol-fee cap: 1000 bps = 10%.
131const MaxFeeBps = int64(1000)
132
133// Plan status values.
134const (
135 PlanActive = "active"
136 PlanRetired = "retired"
137)
138
139// Subscription status values. Cancelled and Expired are terminal.
140const (
141 SubActive = "active"
142 SubCancelled = "cancelled"
143 SubExpired = "expired"
144)
145
146// Input bounds.
147const (
148 MaxTitleLen = 80
149 MaxDescLen = 2000
150 MinPrice = int64(1)
151
152 // MinPeriodBlocks/MaxPeriodBlocks bound a plan's billing period:
153 // ~40 seconds to ~1 year at pearl's observed ~4.2s blocks. The
154 // floor keeps a hostile plan from turning renewal into a
155 // per-block treadmill; the ceiling keeps paidThrough arithmetic
156 // far from overflow even at maximum prepayment.
157 MinPeriodBlocks = int64(10)
158 MaxPeriodBlocks = int64(7500000)
159
160 // MaxPlansPerProvider bounds catalog monopolization by a single
161 // address — the finding that was RED in permission_registry and
162 // service_registry, carried from the start here.
163 MaxPlansPerProvider = 20
164
165 // MaxSubsPerSubscriber bounds one account's open-subscription
166 // state. Terminal subscriptions free their slot.
167 MaxSubsPerSubscriber = 100
168
169 // RenderLimit bounds every rendered list — an unbounded Render
170 // was YELLOW in three prior audits.
171 RenderLimit = 20
172)
173
174type plan struct {
175 id int64
176 provider address
177 title string
178 description string
179 price int64
180 periodBlocks int64
181 feeBps int64 // snapshotted at creation, charged at every payment
182 status string
183 subs int64 // lifetime count, never decremented
184}
185
186type sub struct {
187 id int64
188 planID int64
189 subscriber address
190 provider address // copied at Subscribe; never re-read from the plan
191 price int64 // copied at Subscribe; a plan is immutable anyway
192 periodBlocks int64 // copied at Subscribe
193 feeBps int64 // copied at Subscribe from the plan's snapshot
194 status string
195 paidThrough int64 // absolute height; entitlement = height < paidThrough
196 periods int64 // lifetime paid-period count
197}
198
199var (
200 admin address // may set fee, fee recipient, stage successor
201 pendingAdmin address // staged by TransferAdmin, completes via AcceptAdmin
202 feeRecipient address // may withdraw fees and sweep surplus
203 feeBps int64 // fee snapshotted into NEW plans
204
205 self address // this realm's address, captured at deploy
206 nextPlan int64
207 nextSub int64
208
209 plans = avl.NewTree() // padID(id) -> *plan
210 subs = avl.NewTree() // padID(id) -> *sub
211 activeByKey = avl.NewTree() // padID(planID)|subscriber -> int64 sub id, while Active
212 latestByKey = avl.NewTree() // padID(planID)|subscriber -> most recent sub id, never removed
213 providerNum = avl.NewTree() // address -> *int64, live plans per provider
214 subNum = avl.NewTree() // address -> *int64, Active subs per subscriber
215 ledger = feeledger.MustNew(MaxFeeBps)
216)
217
218func init() {
219 admin = unsafe.OriginCaller()
220 feeRecipient = admin
221 self = unsafe.CurrentRealm().Address()
222}
223
224// --- provider side ---
225
226// CreatePlan publishes a subscription plan and returns its id. No coins
227// are accepted; the storage deposit the caller pays is the anti-spam.
228// The current protocol fee is snapshotted into the plan and must not
229// exceed maxFeeBps, the ceiling the provider signed for — pass
230// MaxFeeBps to accept any legal fee. Plans are immutable once created:
231// price and period changes are a new plan, so nothing a subscriber
232// agreed to can move underneath them.
233func CreatePlan(cur realm, title, description string, price, periodBlocks, maxFeeBps int64) int64 {
234 assertNoSend()
235 provider := cur.Previous().Address()
236
237 if title == "" || len(title) > MaxTitleLen {
238 panic("title must be 1.." + itoa(int64(MaxTitleLen)) + " bytes")
239 }
240 if len(description) > MaxDescLen {
241 panic("description too long")
242 }
243 if price < MinPrice {
244 panic("price must be at least " + itoa(MinPrice) + Denom)
245 }
246 if periodBlocks < MinPeriodBlocks || periodBlocks > MaxPeriodBlocks {
247 panic("periodBlocks must be in [" + itoa(MinPeriodBlocks) + ", " +
248 itoa(MaxPeriodBlocks) + "]")
249 }
250 if feeBps > maxFeeBps {
251 panic("current fee " + itoa(feeBps) + " bps exceeds the caller's maximum " +
252 itoa(maxFeeBps))
253 }
254 n := counter(providerNum, provider)
255 if *n >= MaxPlansPerProvider {
256 panic("per-provider plan limit reached")
257 }
258
259 id := nextPlan
260 nextPlan++
261 plans.Set(padID(id), &plan{
262 id: id,
263 provider: provider,
264 title: title,
265 description: description,
266 price: price,
267 periodBlocks: periodBlocks,
268 feeBps: feeBps,
269 status: PlanActive,
270 })
271 *n++
272
273 chain.Emit("PlanCreated",
274 "planId", itoa(id),
275 "provider", provider.String(),
276 "price", itoa(price),
277 "periodBlocks", itoa(periodBlocks),
278 "feeBps", itoa(feeBps),
279 )
280 return id
281}
282
283// RetirePlan takes a plan off the market: no new subscriptions and no
284// renewals. Provider only. Existing entitlements run to their
285// paidThrough untouched; refusing renewals is subscriber protection —
286// nobody keeps paying for a service whose provider announced its end.
287// The provider's plan-quota slot frees.
288func RetirePlan(cur realm, planID int64) {
289 assertNoSend()
290 caller := cur.Previous().Address()
291 p := mustGetPlan(planID)
292 if caller != p.provider {
293 panic("only the provider may retire a plan")
294 }
295 if p.status != PlanActive {
296 panic("plan is not active")
297 }
298 p.status = PlanRetired
299 *counter(providerNum, p.provider)--
300 chain.Emit("PlanRetired", "planId", itoa(planID), "provider", p.provider.String())
301}
302
303// --- subscriber side ---
304
305// Subscribe pays for the first billing period of a plan and returns the
306// new subscription id. The transaction must attach EXACTLY the plan's
307// price in ugnot — over- and underpayment are both refused, so a
308// mistaken double-attach cannot silently become a donation. One
309// subscriber holds at most one live subscription per plan: if an Active
310// one exists the call is refused (the renewal path is Renew, never a
311// second Subscribe — that is the duplicate-payment guard at the
312// identity level); an incumbent past its grace window is collapsed to
313// Expired in place, so a fresh start never waits on housekeeping.
314//
315// The payment settles immediately: price minus the plan's snapshotted
316// fee to the provider's claimable balance, fee to the protocol pot.
317// Entitlement runs from this block: paidThrough = height + periodBlocks.
318func Subscribe(cur realm, planID int64) int64 {
319 subscriber, amount := coinio.Receive(0, cur, Denom)
320 p := mustGetPlan(planID)
321 if p.status != PlanActive {
322 panic("plan is not active")
323 }
324 if amount != p.price {
325 panic("send exactly the plan price: " + itoa(p.price) + Denom)
326 }
327
328 key := padID(planID) + "|" + subscriber.String()
329 if v := activeByKey.Get(key); v != nil {
330 inc := mustGetSub(v.(int64))
331 if runtime.ChainHeight() >= inc.paidThrough+inc.periodBlocks {
332 expireInPlace(inc)
333 } else if runtime.ChainHeight() >= inc.paidThrough {
334 panic("subscription " + itoa(inc.id) + " to this plan is lapsed but renewable: " +
335 "Renew back-pays the lapsed span and keeps the schedule; " +
336 "Cancel then Subscribe restarts fresh at the same price")
337 } else {
338 panic("an active subscription to this plan already exists: renew it instead")
339 }
340 }
341 n := counter(subNum, subscriber)
342 if *n >= MaxSubsPerSubscriber {
343 panic("per-subscriber subscription limit reached")
344 }
345
346 ledger.MustDeposit(p.provider.String(), amount, p.feeBps)
347
348 id := nextSub
349 nextSub++
350 subs.Set(padID(id), &sub{
351 id: id,
352 planID: planID,
353 subscriber: subscriber,
354 provider: p.provider,
355 price: p.price,
356 periodBlocks: p.periodBlocks,
357 feeBps: p.feeBps,
358 status: SubActive,
359 paidThrough: runtime.ChainHeight() + p.periodBlocks,
360 periods: 1,
361 })
362 activeByKey.Set(key, id)
363 latestByKey.Set(key, id)
364 *n++
365 p.subs++
366
367 chain.Emit("Subscribed",
368 "subId", itoa(id),
369 "planId", itoa(planID),
370 "subscriber", subscriber.String(),
371 "provider", p.provider.String(),
372 "amount", itoa(amount),
373 "paidThrough", itoa(runtime.ChainHeight()+p.periodBlocks),
374 )
375 return id
376}
377
378// Renew pays for the next billing period of the caller's own
379// subscription. The transaction must attach exactly the subscription's
380// price. The renewal window is deterministic and stated in the header:
381// accepted iff paidThrough - height <= periodBlocks (at most one full
382// unstarted period prepaid — the duplicate-payment bound) and height <
383// paidThrough + periodBlocks (the grace bound, exclusive — a renewal
384// always buys at least one block). Extension is always
385// FROM paidThrough, so period boundaries never drift, and a renewal
386// inside grace covers the lapsed span — that is the price of keeping
387// the original schedule, and it is the documented, deterministic
388// choice.
389func Renew(cur realm, subID int64) {
390 payer, amount := coinio.Receive(0, cur, Denom)
391 s := mustGetSub(subID)
392 if payer != s.subscriber {
393 panic("only the subscriber may renew")
394 }
395 if s.status != SubActive {
396 panic("subscription is " + s.status)
397 }
398 p := mustGetPlan(s.planID)
399 if p.status != PlanActive {
400 panic("plan is retired; the paid period runs to its end but cannot be renewed")
401 }
402 if amount != s.price {
403 panic("send exactly the subscription price: " + itoa(s.price) + Denom)
404 }
405 h := runtime.ChainHeight()
406 if s.paidThrough-h > s.periodBlocks {
407 panic("too early: at most one unstarted period may be prepaid; renewable from height " +
408 itoa(s.paidThrough-s.periodBlocks))
409 }
410 if h >= s.paidThrough+s.periodBlocks {
411 panic("grace window over; the subscription is expirable, subscribe afresh")
412 }
413
414 ledger.MustDeposit(s.provider.String(), amount, s.feeBps)
415 s.paidThrough += s.periodBlocks
416 s.periods++
417
418 chain.Emit("Renewed",
419 "subId", itoa(subID),
420 "subscriber", s.subscriber.String(),
421 "amount", itoa(amount),
422 "paidThrough", itoa(s.paidThrough),
423 )
424}
425
426// Cancel ends the caller's own subscription. Terminal: it can never be
427// renewed afterwards, by anyone. No refund and no funds move — every
428// payment settled when it was made, and the entitlement already bought
429// (height < paidThrough) remains until it runs out. The plan slot and
430// the subscriber's quota slot free immediately.
431func Cancel(cur realm, subID int64) {
432 assertNoSend()
433 caller := cur.Previous().Address()
434 s := mustGetSub(subID)
435 if caller != s.subscriber {
436 panic("only the subscriber may cancel")
437 }
438 if s.status != SubActive {
439 panic("subscription is " + s.status)
440 }
441 s.status = SubCancelled
442 releaseSlots(s)
443 chain.Emit("Cancelled",
444 "subId", itoa(subID),
445 "subscriber", s.subscriber.String(),
446 "paidThrough", itoa(s.paidThrough),
447 )
448}
449
450// Expire marks a lapsed subscription Expired once its grace window is
451// over: height >= paidThrough + periodBlocks. Permissionless by design —
452// like the sibling realms' valves, no slot's liveness may depend on
453// either party showing up. No funds move.
454func Expire(cur realm, subID int64) {
455 assertNoSend()
456 s := mustGetSub(subID)
457 if s.status != SubActive {
458 panic("subscription is " + s.status)
459 }
460 if runtime.ChainHeight() < s.paidThrough+s.periodBlocks {
461 panic("not expirable: grace runs through height " +
462 itoa(s.paidThrough+s.periodBlocks-1))
463 }
464 expireInPlace(s)
465}
466
467// --- payouts ---
468
469// Claim sends amount ugnot of the caller's claimable balance back to
470// the caller. Providers earn into this balance at every payment.
471func Claim(cur realm, amount int64) {
472 assertNoSend()
473 caller := cur.Previous().Address()
474 if err := ledger.Withdraw(caller.String(), amount); err != nil {
475 panic(err)
476 }
477 coinio.Payout(0, cur, caller, Denom, amount)
478 chain.Emit("Claim", "to", caller.String(), "amount", itoa(amount))
479}
480
481// ClaimAll sends the caller's entire claimable balance back to the
482// caller. Fails if there is nothing to claim.
483func ClaimAll(cur realm) {
484 assertNoSend()
485 caller := cur.Previous().Address()
486 amount, err := ledger.WithdrawAll(caller.String())
487 if err != nil {
488 panic(err)
489 }
490 if amount == 0 {
491 panic("nothing to claim")
492 }
493 coinio.Payout(0, cur, caller, Denom, amount)
494 chain.Emit("Claim", "to", caller.String(), "amount", itoa(amount))
495}
496
497// WithdrawFees sends the accrued fee pot to the fee recipient. Only the
498// fee recipient may call it, and only the pot moves.
499func WithdrawFees(cur realm) {
500 assertNoSend()
501 if cur.Previous().Address() != feeRecipient {
502 panic("fee recipient only")
503 }
504 amount := ledger.WithdrawFees()
505 if amount == 0 {
506 panic("no fees accrued")
507 }
508 coinio.Payout(0, cur, feeRecipient, Denom, amount)
509 chain.Emit("FeesWithdrawn", "to", feeRecipient.String(), "amount", itoa(amount))
510}
511
512// SweepDenom recovers out-of-band coins (sent by raw bank transfer,
513// outside any entrypoint) to the fee recipient. For the ledger denom
514// the reserve is Liabilities() — user balances and the fee pot are
515// structurally unreachable. Fee recipient only.
516func SweepDenom(cur realm, denom string) {
517 assertNoSend()
518 if cur.Previous().Address() != feeRecipient {
519 panic("fee recipient only")
520 }
521 reserve := int64(0)
522 if denom == Denom {
523 reserve = ledger.Liabilities()
524 }
525 // coinio.Sweep itself aborts when nothing sits above the reserve.
526 swept := coinio.Sweep(0, cur, feeRecipient, denom, reserve)
527 chain.Emit("SurplusSwept", "to", feeRecipient.String(), "coins", itoa(swept)+denom)
528}
529
530// --- administration ---
531
532// SetFeeBps sets the protocol fee snapshotted into FUTURE plans.
533// Bounded by MaxFeeBps; existing plans and subscriptions are untouched
534// — their fee was fixed the moment the provider consented to it.
535func SetFeeBps(cur realm, bps int64) {
536 assertNoSend()
537 assertAdmin(cur.Previous().Address())
538 if bps < 0 || bps > MaxFeeBps {
539 panic("fee must be in [0, " + itoa(MaxFeeBps) + "] bps")
540 }
541 old := feeBps
542 feeBps = bps
543 chain.Emit("FeeChanged", "oldBps", itoa(old), "newBps", itoa(bps))
544}
545
546// SetFeeRecipient points future fee withdrawals and sweeps at a new
547// address. Admin only. The zero address is refused — it would strand
548// the pot.
549func SetFeeRecipient(cur realm, recipient address) {
550 assertNoSend()
551 assertAdmin(cur.Previous().Address())
552 var zero address
553 if recipient == zero {
554 panic("fee recipient must not be the zero address")
555 }
556 old := feeRecipient
557 feeRecipient = recipient
558 chain.Emit("FeeRecipientChanged", "old", old.String(), "new", recipient.String())
559}
560
561// TransferAdmin stages a two-step admin handover. The successor holds
562// nothing until AcceptAdmin.
563func TransferAdmin(cur realm, successor address) {
564 assertNoSend()
565 assertAdmin(cur.Previous().Address())
566 var zero address
567 if successor == zero {
568 panic("successor must not be the zero address")
569 }
570 pendingAdmin = successor
571 chain.Emit("AdminTransferStaged", "from", admin.String(), "to", successor.String())
572}
573
574// AcceptAdmin completes the handover; only the staged successor may.
575func AcceptAdmin(cur realm) {
576 assertNoSend()
577 caller := cur.Previous().Address()
578 if caller != pendingAdmin {
579 panic("only the staged successor may accept")
580 }
581 old := admin
582 admin = caller
583 var zero address
584 pendingAdmin = zero
585 chain.Emit("AdminTransferred", "from", old.String(), "to", admin.String())
586}
587
588// --- views ---
589
590// Entitled reports whether the subscription's paid entitlement covers
591// the current block: height < paidThrough. Status deliberately does not
592// enter into it — a cancelled subscriber keeps what they paid for, and
593// an expirable-but-unexpired one has already lapsed here.
594func Entitled(subID int64) bool {
595 s := mustGetSub(subID)
596 return runtime.ChainHeight() < s.paidThrough
597}
598
599// EntitledFor reports whether subscriber currently holds paid
600// entitlement to planID, through their MOST RECENT subscription to it.
601// This is the one-call integration surface for other realms and
602// services, and it honors the entitlement contract across status: a
603// cancelled subscription keeps answering true until its paidThrough —
604// what was bought stays bought (audit finding Y1). One self-inflicted
605// edge is fail-closed: cancelling a prepaid subscription and
606// re-subscribing at once points this surface at the NEW, earlier
607// paidThrough; the old subscription's remaining span stays queryable
608// per-id via Entitled.
609func EntitledFor(planID int64, subscriber address) bool {
610 v := latestByKey.Get(padID(planID) + "|" + subscriber.String())
611 if v == nil {
612 return false
613 }
614 return Entitled(v.(int64))
615}
616
617// ActiveSubID returns the caller-facing id of subscriber's live
618// subscription to planID, or (0, false) if none is Active.
619func ActiveSubID(planID int64, subscriber address) (int64, bool) {
620 v := activeByKey.Get(padID(planID) + "|" + subscriber.String())
621 if v == nil {
622 return 0, false
623 }
624 return v.(int64), true
625}
626
627// PlanInfo returns a plan's public fields.
628func PlanInfo(planID int64) (provider address, title string, price, periodBlocks, planFeeBps int64, status string, lifetimeSubs int64) {
629 p := mustGetPlan(planID)
630 return p.provider, p.title, p.price, p.periodBlocks, p.feeBps, p.status, p.subs
631}
632
633// SubInfo returns a subscription's public fields.
634func SubInfo(subID int64) (planID int64, subscriber, provider address, price, paidThrough, periods int64, status string) {
635 s := mustGetSub(subID)
636 return s.planID, s.subscriber, s.provider, s.price, s.paidThrough, s.periods, s.status
637}
638
639// PaidThrough returns the absolute height a subscription is paid to.
640func PaidThrough(subID int64) int64 { return mustGetSub(subID).paidThrough }
641
642// RenewableFrom returns the earliest height at which Renew will accept
643// a payment for this subscription, and the last height at which it
644// still will (inclusive) — the deterministic window, precomputed for
645// integrators. From until+1 the subscription is expirable instead; the
646// two sets partition exactly.
647func RenewableFrom(subID int64) (from, until int64) {
648 s := mustGetSub(subID)
649 return s.paidThrough - s.periodBlocks, s.paidThrough + s.periodBlocks - 1
650}
651
652func Admin() address { return admin }
653func PendingAdmin() address { return pendingAdmin }
654func FeeRecipient() address { return feeRecipient }
655func FeeBps() int64 { return feeBps }
656func NumPlans() int64 { return nextPlan }
657func NumSubs() int64 { return nextSub }
658func UsersTotal() int64 { return ledger.UsersTotal() }
659func FeesAccrued() int64 { return ledger.FeesAccrued() }
660func Liabilities() int64 { return ledger.Liabilities() }
661func BalanceOf(a address) int64 { return ledger.BalanceOf(a.String()) }
662func Address() address { return self }
663func Held() int64 { return coinio.HeldAt(self, Denom) }
664
665// --- render ---
666
667func Render(path string) string {
668 if path == "" {
669 return renderHome()
670 }
671 return "unknown page; try the realm root"
672}
673
674func renderHome() string {
675 out := "# subscriptions\n\n"
676 out += "Provider-published plans, per-period payment, deterministic " +
677 "renewal, grace, expiration and cancellation. Entitlement is " +
678 "`height < paidThrough`, queryable by anyone.\n\n"
679 out += "- plans: " + itoa(nextPlan) + "\n"
680 out += "- subscriptions: " + itoa(nextSub) + "\n"
681 out += "- provider balances: " + itoa(ledger.UsersTotal()) + Denom + "\n"
682 out += "- fees accrued: " + itoa(ledger.FeesAccrued()) + Denom + "\n"
683 out += "- current fee for new plans: " + itoa(feeBps) + " bps (cap " +
684 itoa(MaxFeeBps) + ")\n\n"
685
686 if nextPlan == 0 {
687 return out + "No plans yet.\n"
688 }
689 out += "## newest plans\n\n"
690 shown := int64(0)
691 for id := nextPlan - 1; id >= 0 && shown < RenderLimit; id-- {
692 p := mustGetPlan(id)
693 out += "- #" + itoa(p.id) + " **" + sanitize.InlineText(p.title) +
694 "** — " + itoa(p.price) + Denom + " / " + itoa(p.periodBlocks) +
695 " blocks, " + p.status + ", provider `" + p.provider.String() +
696 "`, " + itoa(p.subs) + " lifetime subs\n"
697 shown++
698 }
699 if nextPlan > shown {
700 out += "\n(" + itoa(nextPlan-shown) + " older plans not shown)\n"
701 }
702 return out
703}
704
705// --- internals ---
706
707// expireInPlace flips an Active subscription to Expired and frees its
708// slots. Callers have already established expirability.
709func expireInPlace(s *sub) {
710 s.status = SubExpired
711 releaseSlots(s)
712 chain.Emit("SubscriptionExpired",
713 "subId", itoa(s.id),
714 "subscriber", s.subscriber.String(),
715 "paidThrough", itoa(s.paidThrough),
716 )
717}
718
719// releaseSlots removes the plan|subscriber activity index entry and
720// decrements the subscriber's quota counter. Exactly once per terminal
721// transition, which both terminal paths guarantee by requiring
722// SubActive first.
723func releaseSlots(s *sub) {
724 activeByKey.Remove(padID(s.planID) + "|" + s.subscriber.String())
725 *counter(subNum, s.subscriber)--
726}
727
728func assertNoSend() {
729 if len(unsafe.OriginSend()) != 0 {
730 panic("this function does not accept coins")
731 }
732}
733
734func assertAdmin(caller address) {
735 if caller != admin {
736 panic("admin only")
737 }
738}
739
740func mustGetPlan(id int64) *plan {
741 v := plans.Get(padID(id))
742 if v == nil {
743 panic("unknown plan id")
744 }
745 return v.(*plan)
746}
747
748func mustGetSub(id int64) *sub {
749 v := subs.Get(padID(id))
750 if v == nil {
751 panic("unknown subscription id")
752 }
753 return v.(*sub)
754}
755
756// counter returns the persistent per-address counter in tree,
757// allocating a zero on first use.
758func counter(tree *avl.Tree, a address) *int64 {
759 k := a.String()
760 if v := tree.Get(k); v != nil {
761 return v.(*int64)
762 }
763 n := new(int64)
764 tree.Set(k, n)
765 return n
766}
767
768func padID(id int64) string {
769 s := strconv.FormatInt(id, 10)
770 for len(s) < 12 {
771 s = "0" + s
772 }
773 return s
774}
775
776func itoa(n int64) string { return strconv.FormatInt(n, 10) }