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}