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

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

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