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

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}