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.

patron.gno

17.84 Kb · 512 lines
  1// Package patron is the engine behind recurring support for a builder: a plan
  2// somebody subscribes to, period after period, paid in ugnot on chain.
  3//
  4// # There is no cron on chain, so a renewal is a pull and not a push
  5//
  6// Nothing here can charge anybody. The supporter sends another payment and
  7// their paid-through height moves forward; there is no scheduler, no keeper,
  8// and no standing authority over anybody's balance. There is no way to write
  9// one either, because a native coin cannot be pulled at all: a banker may only
 10// spend its own realm's address, so the inbound path is always the holder
 11// signing. A reader arriving from web2 expects the opposite, and that is the
 12// one expectation to unlearn before reading the rest of this package.
 13//
 14// # What this adds over a tip jar
 15//
 16// A tip jar is one payment and then nothing, and the one-shot version is
 17// already live at gno.land/r/moul/x/daily/tipjar. The recurrence is the only
 18// reason this exists: a plan carries a price per period and a period measured
 19// in BLOCKS, a supporter buys whole periods, and anybody can ask at any height
 20// whether a given address is still active. It is the one piece of the x/social
 21// family that produces a recurring write rather than a one-off.
 22//
 23// # The model
 24//
 25//	Registry  every plan, plus the credit ledger money leaves through
 26//	Plan      a creator, a title, a description, a price, a period, open or not
 27//	Payment   what one Subscribe call bought: periods, spent, change, through
 28//
 29// # Renewing early never loses time already paid for
 30//
 31// [Registry.Subscribe] extends from whichever is LATER, now or the supporter's
 32// current paid-through height. Renewing two blocks before a lapse adds a whole
 33// period on top of what is left. Renewing after a lapse starts from now,
 34// because the gap was never paid for and nothing backdates it.
 35//
 36// # The change is credited back, never kept
 37//
 38// A payment buys floor(sent / price) whole periods, and the remainder under
 39// one period is credited to the SUPPORTER's own withdrawable balance. Keeping
 40// it would be a fee nobody agreed to, and a silent fee is the thing a
 41// subscription realm must not have. The supporter takes it back through the
 42// same [Registry.Withdraw] a creator uses.
 43//
 44// # Earnings are credited at the moment of payment, not streamed
 45//
 46// The creator can withdraw the whole price the instant it is paid. A supporter
 47// who stops being active is therefore NOT refunded, and no part of a paid
 48// period is ever returned. That is a real limitation and it is v0 on purpose:
 49// escrowed streaming, where the creator claims only what has elapsed and the
 50// supporter can cancel and reclaim the rest, needs a claim schedule and a
 51// refund path, which is a bigger realm than this one. It is the v1, and it is
 52// not a line that can be bolted onto this one.
 53//
 54// # Money leaves by pull, never by a push loop
 55//
 56// Nothing here moves coins. [Registry.Subscribe] credits a
 57// gno.land/p/moul/x/daily/pullpayment ledger, and [Registry.Withdraw] zeroes a
 58// credit and reports what was owed so the holding realm can transfer after
 59// that call, with the balance already gone when control leaves. A realm that
 60// looped over payees instead would fail entirely on one unpayable address and
 61// hand a griefer a cheap denial of service.
 62//
 63// # A title and a description are attacker-controlled markdown
 64//
 65// Both are free text. [ValidTitle] and [ValidDescription] bound them and
 66// refuse control characters, which is a different protection from escaping and
 67// not a substitute for it: a realm that renders either one escapes it with
 68// ui.Inline in prose or ui.Cell in a table cell.
 69package patron
 70
 71import (
 72	"chain/markdown"
 73	"errors"
 74	"strings"
 75
 76	"gno.land/p/g1magld5ptgzaktqcexxq0pgq5lmdf0kz6re8hlv/kit/store/v0"
 77	"gno.land/p/g1magld5ptgzaktqcexxq0pgq5lmdf0kz6re8hlv/x/daily/pullpayment/v0"
 78	"gno.land/p/g1magld5ptgzaktqcexxq0pgq5lmdf0kz6re8hlv/xmath/v1"
 79)
 80
 81const (
 82	// MaxTitleLen and MaxDescriptionLen bound the two free-text fields. Long
 83	// enough to say what the plan is, short enough that opening one cannot
 84	// lock an unbounded storage deposit somebody else is paying for.
 85	MaxTitleLen       = 80
 86	MaxDescriptionLen = 500
 87
 88	// MinPeriodBlocks and MaxPeriodBlocks bound a period both ways. A period
 89	// of zero blocks is not a subscription, it is a division by zero wearing
 90	// a price tag. The ceiling is about a year at the five second blocks the
 91	// test chain runs, past which "recurring" stops meaning anything and the
 92	// plan is a one-off with extra steps.
 93	MinPeriodBlocks = int64(10)
 94	MaxPeriodBlocks = int64(6307200)
 95
 96	// MinPricePerPeriod is one ugnot, because a free plan is a tip jar
 97	// (gno.land/r/moul/x/daily/tipjar) and not a subscription: at a price of
 98	// zero there is nothing to buy a period with and every address would be
 99	// active forever.
100	MinPricePerPeriod = int64(1)
101
102	// MaxPeriodsPerPayment bounds what one payment may buy. It keeps
103	// periods * PeriodBlocks inside an int64 by construction rather than by
104	// hope, and it stops a single send from parking a paid-through height so
105	// far ahead that no later arithmetic on it means anything.
106	MaxPeriodsPerPayment = int64(10000)
107)
108
109// The errors a caller can get back. A p/ package returns them and the realm
110// decides to abort.
111var (
112	ErrBadTitle          = errors.New("patron: title is empty, too long, or has control characters")
113	ErrBadDescription    = errors.New("patron: description is too long or has control characters")
114	ErrBadPrice          = errors.New("patron: a plan needs a price of at least one ugnot per period")
115	ErrBadPeriod         = errors.New("patron: period out of range")
116	ErrNoPlan            = errors.New("patron: no such plan")
117	ErrPlanClosed        = errors.New("patron: this plan is closed to new subscriptions")
118	ErrNotCreator        = errors.New("patron: only the plan's creator can do that")
119	ErrAlreadyOpen       = errors.New("patron: the plan is already open")
120	ErrAlreadyClosed     = errors.New("patron: the plan is already closed")
121	ErrShortOfOnePeriod  = errors.New("patron: the payment does not cover one whole period")
122	ErrTooManyPeriods    = errors.New("patron: one payment cannot buy that many periods")
123	ErrNothingToWithdraw = errors.New("patron: nothing to withdraw")
124)
125
126// Plan is one creator's recurring support plan.
127type Plan struct {
128	Creator     address
129	Title       string
130	Description string
131
132	// PricePerPeriod is what one period costs, in ugnot.
133	PricePerPeriod int64
134
135	// PeriodBlocks is how long a period lasts, in blocks. Blocks and not
136	// seconds: height is the clock consensus agrees on, and a block timestamp
137	// is set by proposers and is not something to build a billing cliff out
138	// of at second resolution.
139	PeriodBlocks int64
140
141	// Open reports whether the plan takes NEW payments. Closing it never
142	// touches a subscription already paid for, which runs to its own
143	// paid-through height.
144	Open bool
145
146	// Received is the lifetime ugnot this plan credited to its creator.
147	Received int64
148
149	// paidThrough is the first height at which a supporter is no longer
150	// active. A supporter is active while now < paidThrough, so a period
151	// bought at height h ends at h+PeriodBlocks and the holder is inactive
152	// at exactly that height.
153	paidThrough map[string]int64
154
155	// supporters is every address that has ever paid, in first-payment
156	// order. It exists so a listing is deterministic without iterating a map
157	// as if insertion order were a sort.
158	supporters []address
159}
160
161// PaidThrough is the height who stops being active at, or zero if they never
162// paid.
163func (p *Plan) PaidThrough(who address) int64 {
164	if p == nil {
165		return 0
166	}
167	return p.paidThrough[who.String()]
168}
169
170// IsActive reports whether who is paid up at height now.
171//
172// The comparison is strict: a supporter paid through height h is active at
173// h-1 and not at h. One rule, applied at both edges, so a period never
174// overlaps the next one by a block.
175func (p *Plan) IsActive(who address, now int64) bool {
176	return p.PaidThrough(who) > now
177}
178
179// SupporterCount is how many distinct addresses have ever paid, active or not.
180func (p *Plan) SupporterCount() int {
181	if p == nil {
182		return 0
183	}
184	return len(p.supporters)
185}
186
187// Supporters is every address that has ever paid, in first-payment order.
188//
189// It returns a copy. Handing out the stored slice would be a live mutation
190// handle on realm state, which is the cheapest way for a reader to become a
191// writer.
192func (p *Plan) Supporters() []address {
193	if p == nil {
194		return nil
195	}
196	out := make([]address, len(p.supporters))
197	copy(out, p.supporters)
198	return out
199}
200
201// ActiveCount is how many supporters are paid up at height now.
202func (p *Plan) ActiveCount(now int64) int {
203	if p == nil {
204		return 0
205	}
206	n := 0
207	for _, who := range p.supporters {
208		if p.IsActive(who, now) {
209			n++
210		}
211	}
212	return n
213}
214
215// Payment is what one [Registry.Subscribe] call bought.
216type Payment struct {
217	// Periods is how many whole periods the payment covered.
218	Periods int64
219
220	// Spent is the ugnot those periods cost, credited to the creator.
221	Spent int64
222
223	// Change is the remainder under one period, credited back to the
224	// supporter rather than kept.
225	Change int64
226
227	// PaidThrough is the supporter's new paid-through height.
228	PaidThrough int64
229
230	// NewSupporter reports whether this address had never paid this plan
231	// before, which is the signal a realm wants for an event or a counter.
232	NewSupporter bool
233}
234
235// Registry holds every plan and the credit ledger money leaves through.
236type Registry struct {
237	plans  *store.Store
238	ledger *pullpayment.Ledger
239
240	// earned is lifetime ugnot credited per creator, which survives a
241	// withdrawal. The ledger only knows what is owed RIGHT NOW, and a page
242	// showing a creator zero the moment they cash out would be telling the
243	// truth about the wrong question.
244	earned map[string]int64
245}
246
247// NewRegistry returns an empty registry.
248func NewRegistry() *Registry {
249	return &Registry{
250		plans:  store.Named("plan"),
251		ledger: pullpayment.New(),
252		earned: map[string]int64{},
253	}
254}
255
256// Open creates a plan and returns its id. Anyone may open one.
257func (r *Registry) Open(creator address, title, description string, pricePerPeriod, periodBlocks int64) (store.ID, error) {
258	if !ValidTitle(title) {
259		return 0, ErrBadTitle
260	}
261	if !ValidDescription(description) {
262		return 0, ErrBadDescription
263	}
264	if pricePerPeriod < MinPricePerPeriod {
265		return 0, ErrBadPrice
266	}
267	if periodBlocks < MinPeriodBlocks || periodBlocks > MaxPeriodBlocks {
268		return 0, ErrBadPeriod
269	}
270	return r.plans.Add(&Plan{
271		Creator:        creator,
272		Title:          title,
273		Description:    description,
274		PricePerPeriod: pricePerPeriod,
275		PeriodBlocks:   periodBlocks,
276		Open:           true,
277		paidThrough:    map[string]int64{},
278	}), nil
279}
280
281// Subscribe buys whole periods on a plan with sent ugnot, at height now.
282//
283// It refuses anything short of one period, buys floor(sent / price) of them,
284// and credits the remainder back to the supporter. The extension starts from
285// whichever is LATER, now or the supporter's current paid-through height, so
286// renewing early never discards time already paid for and renewing after a
287// lapse never backdates the gap.
288//
289// The creator is credited at the moment of payment, not as the periods
290// elapse. See the package doc for why that is v0 and what v1 would have to
291// carry instead.
292func (r *Registry) Subscribe(id store.ID, who address, sent, now int64) (Payment, error) {
293	p, ok := r.Get(id)
294	if !ok {
295		return Payment{}, ErrNoPlan
296	}
297	if !p.Open {
298		return Payment{}, ErrPlanClosed
299	}
300	if sent < p.PricePerPeriod {
301		return Payment{}, ErrShortOfOnePeriod
302	}
303
304	// The one rounding decision in this function: periods is floor, so a
305	// supporter is never sold a period they did not fully fund, and the
306	// remainder is handed back below rather than kept. Neither side keeps a
307	// fraction of a period.
308	periods := sent / p.PricePerPeriod
309	if periods > MaxPeriodsPerPayment {
310		return Payment{}, ErrTooManyPeriods
311	}
312	spent := sent - sent%p.PricePerPeriod
313	change := sent - spent
314
315	// spent is an exact whole number of periods, so this division leaves no
316	// remainder and there is no second rounding direction to choose. MulDiv
317	// is here for the intermediate: spent * PeriodBlocks overflows an int64
318	// for a plan priced in whole GNOT with a period measured in months, and
319	// the naive product wraps to a plausible-looking height rather than to an
320	// obvious one. MulDivUp would be identical on an exact division; MulDiv
321	// says plainly that nothing is being rounded up.
322	added := xmath.MulDiv(spent, p.PeriodBlocks, p.PricePerPeriod)
323
324	from := now
325	if pt := p.PaidThrough(who); pt > from {
326		from = pt
327	}
328	through := from + added
329
330	key := who.String()
331
332	// Both credits happen before the plan is touched, and both can fail: the
333	// ledger is bounded and guards its own overflow. A realm calling this
334	// aborts on the error, which reverts everything written in the same
335	// frame, so a half-applied subscription is not reachable from a crossing
336	// call. The ordering is what makes that true for every other caller too.
337	if err := r.ledger.Credit(p.Creator.String(), spent); err != nil {
338		return Payment{}, err
339	}
340	if change > 0 {
341		if err := r.ledger.Credit(key, change); err != nil {
342			return Payment{}, err
343		}
344	}
345	r.earned[p.Creator.String()] += spent
346
347	_, seen := p.paidThrough[key]
348	if !seen {
349		p.supporters = append(p.supporters, who)
350	}
351	p.paidThrough[key] = through
352	p.Received += spent
353
354	return Payment{
355		Periods:      periods,
356		Spent:        spent,
357		Change:       change,
358		PaidThrough:  through,
359		NewSupporter: !seen,
360	}, nil
361}
362
363// Close stops a plan taking new subscriptions. Creator only.
364//
365// It does not touch anything already paid for: existing supporters run to
366// their own paid-through height, which is the only behaviour that does not
367// turn closing a plan into taking money back.
368func (r *Registry) Close(id store.ID, who address) error {
369	p, err := r.ownPlan(id, who)
370	if err != nil {
371		return err
372	}
373	if !p.Open {
374		return ErrAlreadyClosed
375	}
376	p.Open = false
377	return nil
378}
379
380// Reopen lets a closed plan take subscriptions again. Creator only.
381func (r *Registry) Reopen(id store.ID, who address) error {
382	p, err := r.ownPlan(id, who)
383	if err != nil {
384		return err
385	}
386	if p.Open {
387		return ErrAlreadyOpen
388	}
389	p.Open = true
390	return nil
391}
392
393// ownPlan is the shared lookup plus authority check, so Close and Reopen
394// cannot drift apart on who is allowed to call them.
395func (r *Registry) ownPlan(id store.ID, who address) (*Plan, error) {
396	p, ok := r.Get(id)
397	if !ok {
398		return nil, ErrNoPlan
399	}
400	if p.Creator != who {
401		return nil, ErrNotCreator
402	}
403	return p, nil
404}
405
406// Withdraw zeroes who's credit and reports what they were owed.
407//
408// It moves no coins. The holding realm transfers the returned amount AFTER
409// this call, which is the whole point of the pattern: the credit is already
410// gone from the ledger when control passes to the payee, so a reentrant call
411// finds nothing and gets [ErrNothingToWithdraw].
412func (r *Registry) Withdraw(who address) (int64, error) {
413	amount, err := r.ledger.Withdraw(who.String())
414	if err != nil {
415		return 0, ErrNothingToWithdraw
416	}
417	return amount, nil
418}
419
420// CreditOf is what addr can withdraw right now: earnings as a creator, change
421// as a supporter, or both.
422func (r *Registry) CreditOf(addr address) int64 { return r.ledger.Balance(addr.String()) }
423
424// EarnedBy is the lifetime ugnot credited to creator across every plan,
425// whether or not it has been withdrawn.
426func (r *Registry) EarnedBy(creator address) int64 { return r.earned[creator.String()] }
427
428// TotalOwed is everything the holding realm must keep in reserve.
429func (r *Registry) TotalOwed() int64 { return r.ledger.TotalOwed() }
430
431// Get returns a plan by id.
432func (r *Registry) Get(id store.ID) (*Plan, bool) {
433	v, ok := r.plans.Get(id)
434	if !ok {
435		return nil, false
436	}
437	return v.(*Plan), true
438}
439
440// Count is how many plans exist.
441func (r *Registry) Count() int { return r.plans.Len() }
442
443// Listing is one row of [Registry.List]: the plan and the id a link needs.
444type Listing struct {
445	ID   store.ID
446	Plan *Plan
447}
448
449// List returns one page of plans, newest first.
450func (r *Registry) List(page, size int) []Listing {
451	var out []Listing
452	for _, e := range r.plans.PageReverse(page, size) {
453		out = append(out, Listing{ID: e.ID, Plan: e.Value.(*Plan)})
454	}
455	return out
456}
457
458// Pages is how many pages of the given size the registry holds.
459func (r *Registry) Pages(size int) int { return r.plans.Pages(size) }
460
461// ValidTitle reports whether title can be stored: non-empty after the
462// render's own stripping and trimming,
463// within [MaxTitleLen], and free of control characters including newlines.
464//
465// A title is one line by construction, so a newline in one is refused rather
466// than stripped: silently rewriting what somebody typed is worse than telling
467// them it was refused.
468func ValidTitle(title string) bool {
469	if len(title) > MaxTitleLen || strings.TrimSpace(markdown.StripBidiAndZeroWidth(title)) == "" {
470		return false
471	}
472	for i := 0; i < len(title); i++ {
473		if c := title[i]; c < 0x20 || c == 0x7f {
474			return false
475		}
476	}
477	return true
478}
479
480// ValidDescription reports whether description can be stored: within
481// [MaxDescriptionLen] and free of control characters other than newline and
482// tab. Empty is allowed, because a plan whose title says it all should not
483// have to invent prose.
484func ValidDescription(description string) bool {
485	if len(description) > MaxDescriptionLen {
486		return false
487	}
488	for i := 0; i < len(description); i++ {
489		c := description[i]
490		if c < 0x20 && c != '\n' && c != '\t' || c == 0x7f {
491			return false
492		}
493	}
494	return true
495}
496
497// PlanURL is the gnoweb path of one plan on the hosting realm.
498func PlanURL(realmPath string, id store.ID) string {
499	return RealmURL(realmPath) + ":plan/" + id.String()
500}
501
502// RealmURL is the gnoweb path of a realm given as a package path.
503//
504// The chain domain is the first element of a package path and a gnoweb path is
505// the rest of it, so this is a prefix strip and not a hostname this package
506// has to know.
507func RealmURL(realmPath string) string {
508	if i := strings.Index(realmPath, "/"); i >= 0 {
509		return realmPath[i:]
510	}
511	return "/" + realmPath
512}