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

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) }