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

patron.gno

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