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}