PathrockNetwork Gno Explorer
HomeBlocksTransactionsTokensRealmsPackagesValidatorsAnalytics

PathrockNetwork Gno Explorer — an independent explorer for Gno.land Mainnet (gnoland-1), operated by PathrockNetwork. Not an official Gno.land service.

gnowebarchive RPC

gno.land/p/moul/x/social/patron/v0

Package
Open in gnoweb ↗

Overview

Kind
Pure package
Name
v0
Namespace
moul / x / social / patron
Files
4 (README)(gnomod.toml)
Exported functions
n/a — not supported for pure packages by the node (vm/qfuncs)
Module
gno.land/p/moul/x/social/patron/v0
gno
0.9

Files (4)

  • README.mdmarkdown
  • gnomod.tomltoml
  • patron.gnogno
  • patron_test.gnogno
patron.gnogno
1// Package patron is the engine behind recurring support for a builder: a plan2// 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 push5//6// Nothing here can charge anybody. The supporter sends another payment and7// 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 write9// one either, because a native coin cannot be pulled at all: a banker may only10// spend its own realm's address, so the inbound path is always the holder11// signing. A reader arriving from web2 expects the opposite, and that is the12// one expectation to unlearn before reading the rest of this package.13//14// # What this adds over a tip jar15//16// A tip jar is one payment and then nothing, and the one-shot version is17// already live at gno.land/r/moul/x/daily/tipjar. The recurrence is the only18// reason this exists: a plan carries a price per period and a period measured19// in BLOCKS, a supporter buys whole periods, and anybody can ask at any height20// whether a given address is still active. It is the one piece of the x/social21// family that produces a recurring write rather than a one-off.22//23// # The model24//25//	Registry  every plan, plus the credit ledger money leaves through26//	Plan      a creator, a title, a description, a price, a period, open or not27//	Payment   what one Subscribe call bought: periods, spent, change, through28//29// # Renewing early never loses time already paid for30//31// [Registry.Subscribe] extends from whichever is LATER, now or the supporter's32// current paid-through height. Renewing two blocks before a lapse adds a whole33// 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 kept37//38// A payment buys floor(sent / price) whole periods, and the remainder under39// one period is credited to the SUPPORTER's own withdrawable balance. Keeping40// it would be a fee nobody agreed to, and a silent fee is the thing a41// subscription realm must not have. The supporter takes it back through the42// same [Registry.Withdraw] a creator uses.43//44// # Earnings are credited at the moment of payment, not streamed45//46// The creator can withdraw the whole price the instant it is paid. A supporter47// who stops being active is therefore NOT refunded, and no part of a paid48// 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 the50// supporter can cancel and reclaim the rest, needs a claim schedule and a51// refund path, which is a bigger realm than this one. It is the v1, and it is52// not a line that can be bolted onto this one.53//54// # Money leaves by pull, never by a push loop55//56// Nothing here moves coins. [Registry.Subscribe] credits a57// gno.land/p/moul/x/daily/pullpayment ledger, and [Registry.Withdraw] zeroes a58// credit and reports what was owed so the holding realm can transfer after59// that call, with the balance already gone when control leaves. A realm that60// looped over payees instead would fail entirely on one unpayable address and61// hand a griefer a cheap denial of service.62//63// # A title and a description are attacker-controlled markdown64//65// Both are free text. [ValidTitle] and [ValidDescription] bound them and66// refuse control characters, which is a different protection from escaping and67// not a substitute for it: a realm that renders either one escapes it with68// ui.Inline in prose or ui.Cell in a table cell.69package patron7071import (72	"errors"73	"strings"7475	"gno.land/p/moul/kit/store/v0"76	"gno.land/p/moul/x/daily/pullpayment/v0"77	"gno.land/p/moul/xmath/v1"78)7980const (81	// MaxTitleLen and MaxDescriptionLen bound the two free-text fields. Long82	// enough to say what the plan is, short enough that opening one cannot83	// lock an unbounded storage deposit somebody else is paying for.84	MaxTitleLen       = 8085	MaxDescriptionLen = 5008687	// MinPeriodBlocks and MaxPeriodBlocks bound a period both ways. A period88	// of zero blocks is not a subscription, it is a division by zero wearing89	// a price tag. The ceiling is about a year at the five second blocks the90	// test chain runs, past which "recurring" stops meaning anything and the91	// plan is a one-off with extra steps.92	MinPeriodBlocks = int64(10)93	MaxPeriodBlocks = int64(6307200)9495	// MinPricePerPeriod is one ugnot, because a free plan is a tip jar96	// (gno.land/r/moul/x/daily/tipjar) and not a subscription: at a price of97	// zero there is nothing to buy a period with and every address would be98	// active forever.99	MinPricePerPeriod = int64(1)100101	// MaxPeriodsPerPayment bounds what one payment may buy. It keeps102	// periods * PeriodBlocks inside an int64 by construction rather than by103	// hope, and it stops a single send from parking a paid-through height so104	// far ahead that no later arithmetic on it means anything.105	MaxPeriodsPerPayment = int64(10000)106)107108// The errors a caller can get back. A p/ package returns them and the realm109// 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)124125// Plan is one creator's recurring support plan.126type Plan struct {127	Creator     address128	Title       string129	Description string130131	// PricePerPeriod is what one period costs, in ugnot.132	PricePerPeriod int64133134	// PeriodBlocks is how long a period lasts, in blocks. Blocks and not135	// seconds: height is the clock consensus agrees on, and a block timestamp136	// is set by proposers and is not something to build a billing cliff out137	// of at second resolution.138	PeriodBlocks int64139140	// Open reports whether the plan takes NEW payments. Closing it never141	// touches a subscription already paid for, which runs to its own142	// paid-through height.143	Open bool144145	// Received is the lifetime ugnot this plan credited to its creator.146	Received int64147148	// paidThrough is the first height at which a supporter is no longer149	// active. A supporter is active while now < paidThrough, so a period150	// bought at height h ends at h+PeriodBlocks and the holder is inactive151	// at exactly that height.152	paidThrough map[string]int64153154	// supporters is every address that has ever paid, in first-payment155	// order. It exists so a listing is deterministic without iterating a map156	// as if insertion order were a sort.157	supporters []address158}159160// PaidThrough is the height who stops being active at, or zero if they never161// paid.162func (p *Plan) PaidThrough(who address) int64 {163	if p == nil {164		return 0165	}166	return p.paidThrough[who.String()]167}168169// IsActive reports whether who is paid up at height now.170//171// The comparison is strict: a supporter paid through height h is active at172// h-1 and not at h. One rule, applied at both edges, so a period never173// overlaps the next one by a block.174func (p *Plan) IsActive(who address, now int64) bool {175	return p.PaidThrough(who) > now176}177178// SupporterCount is how many distinct addresses have ever paid, active or not.179func (p *Plan) SupporterCount() int {180	if p == nil {181		return 0182	}183	return len(p.supporters)184}185186// 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 mutation189// handle on realm state, which is the cheapest way for a reader to become a190// writer.191func (p *Plan) Supporters() []address {192	if p == nil {193		return nil194	}195	out := make([]address, len(p.supporters))196	copy(out, p.supporters)197	return out198}199200// ActiveCount is how many supporters are paid up at height now.201func (p *Plan) ActiveCount(now int64) int {202	if p == nil {203		return 0204	}205	n := 0206	for _, who := range p.supporters {207		if p.IsActive(who, now) {208			n++209		}210	}211	return n212}213214// Payment is what one [Registry.Subscribe] call bought.215type Payment struct {216	// Periods is how many whole periods the payment covered.217	Periods int64218219	// Spent is the ugnot those periods cost, credited to the creator.220	Spent int64221222	// Change is the remainder under one period, credited back to the223	// supporter rather than kept.224	Change int64225226	// PaidThrough is the supporter's new paid-through height.227	PaidThrough int64228229	// NewSupporter reports whether this address had never paid this plan230	// before, which is the signal a realm wants for an event or a counter.231	NewSupporter bool232}233234// Registry holds every plan and the credit ledger money leaves through.235type Registry struct {236	plans  *store.Store237	ledger *pullpayment.Ledger238239	// earned is lifetime ugnot credited per creator, which survives a240	// withdrawal. The ledger only knows what is owed RIGHT NOW, and a page241	// showing a creator zero the moment they cash out would be telling the242	// truth about the wrong question.243	earned map[string]int64244}245246// 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}254255// 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, ErrBadTitle259	}260	if !ValidDescription(description) {261		return 0, ErrBadDescription262	}263	if pricePerPeriod < MinPricePerPeriod {264		return 0, ErrBadPrice265	}266	if periodBlocks < MinPeriodBlocks || periodBlocks > MaxPeriodBlocks {267		return 0, ErrBadPeriod268	}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	}), nil278}279280// 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 from284// whichever is LATER, now or the supporter's current paid-through height, so285// renewing early never discards time already paid for and renewing after a286// lapse never backdates the gap.287//288// The creator is credited at the moment of payment, not as the periods289// elapse. See the package doc for why that is v0 and what v1 would have to290// 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{}, ErrNoPlan295	}296	if !p.Open {297		return Payment{}, ErrPlanClosed298	}299	if sent < p.PricePerPeriod {300		return Payment{}, ErrShortOfOnePeriod301	}302303	// The one rounding decision in this function: periods is floor, so a304	// supporter is never sold a period they did not fully fund, and the305	// remainder is handed back below rather than kept. Neither side keeps a306	// fraction of a period.307	periods := sent / p.PricePerPeriod308	if periods > MaxPeriodsPerPayment {309		return Payment{}, ErrTooManyPeriods310	}311	spent := sent - sent%p.PricePerPeriod312	change := sent - spent313314	// spent is an exact whole number of periods, so this division leaves no315	// remainder and there is no second rounding direction to choose. MulDiv316	// is here for the intermediate: spent * PeriodBlocks overflows an int64317	// for a plan priced in whole GNOT with a period measured in months, and318	// the naive product wraps to a plausible-looking height rather than to an319	// obvious one. MulDivUp would be identical on an exact division; MulDiv320	// says plainly that nothing is being rounded up.321	added := xmath.MulDiv(spent, p.PeriodBlocks, p.PricePerPeriod)322323	from := now324	if pt := p.PaidThrough(who); pt > from {325		from = pt326	}327	through := from + added328329	key := who.String()330331	// Both credits happen before the plan is touched, and both can fail: the332	// ledger is bounded and guards its own overflow. A realm calling this333	// aborts on the error, which reverts everything written in the same334	// frame, so a half-applied subscription is not reachable from a crossing335	// 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{}, err338	}339	if change > 0 {340		if err := r.ledger.Credit(key, change); err != nil {341			return Payment{}, err342		}343	}344	r.earned[p.Creator.String()] += spent345346	_, seen := p.paidThrough[key]347	if !seen {348		p.supporters = append(p.supporters, who)349	}350	p.paidThrough[key] = through351	p.Received += spent352353	return Payment{354		Periods:      periods,355		Spent:        spent,356		Change:       change,357		PaidThrough:  through,358		NewSupporter: !seen,359	}, nil360}361362// Close stops a plan taking new subscriptions. Creator only.363//364// It does not touch anything already paid for: existing supporters run to365// their own paid-through height, which is the only behaviour that does not366// 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 err371	}372	if !p.Open {373		return ErrAlreadyClosed374	}375	p.Open = false376	return nil377}378379// 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 err384	}385	if p.Open {386		return ErrAlreadyOpen387	}388	p.Open = true389	return nil390}391392// ownPlan is the shared lookup plus authority check, so Close and Reopen393// 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, ErrNoPlan398	}399	if p.Creator != who {400		return nil, ErrNotCreator401	}402	return p, nil403}404405// Withdraw zeroes who's credit and reports what they were owed.406//407// It moves no coins. The holding realm transfers the returned amount AFTER408// this call, which is the whole point of the pattern: the credit is already409// gone from the ledger when control passes to the payee, so a reentrant call410// 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, ErrNothingToWithdraw415	}416	return amount, nil417}418419// CreditOf is what addr can withdraw right now: earnings as a creator, change420// as a supporter, or both.421func (r *Registry) CreditOf(addr address) int64 { return r.ledger.Balance(addr.String()) }422423// 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()] }426427// TotalOwed is everything the holding realm must keep in reserve.428func (r *Registry) TotalOwed() int64 { return r.ledger.TotalOwed() }429430// 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, false435	}436	return v.(*Plan), true437}438439// Count is how many plans exist.440func (r *Registry) Count() int { return r.plans.Len() }441442// Listing is one row of [Registry.List]: the plan and the id a link needs.443type Listing struct {444	ID   store.ID445	Plan *Plan446}447448// List returns one page of plans, newest first.449func (r *Registry) List(page, size int) []Listing {450	var out []Listing451	for _, e := range r.plans.PageReverse(page, size) {452		out = append(out, Listing{ID: e.ID, Plan: e.Value.(*Plan)})453	}454	return out455}456457// Pages is how many pages of the given size the registry holds.458func (r *Registry) Pages(size int) int { return r.plans.Pages(size) }459460// 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 rather464// than stripped: silently rewriting what somebody typed is worse than telling465// them it was refused.466func ValidTitle(title string) bool {467	if len(title) > MaxTitleLen || strings.TrimSpace(title) == "" {468		return false469	}470	for i := 0; i < len(title); i++ {471		if c := title[i]; c < 0x20 || c == 0x7f {472			return false473		}474	}475	return true476}477478// ValidDescription reports whether description can be stored: within479// [MaxDescriptionLen] and free of control characters other than newline and480// tab. Empty is allowed, because a plan whose title says it all should not481// have to invent prose.482func ValidDescription(description string) bool {483	if len(description) > MaxDescriptionLen {484		return false485	}486	for i := 0; i < len(description); i++ {487		c := description[i]488		if c < 0x20 && c != '\n' && c != '\t' || c == 0x7f {489			return false490		}491	}492	return true493}494495// 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}499500// 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 is503// the rest of it, so this is a prefix strip and not a hostname this package504// has to know.505func RealmURL(realmPath string) string {506	if i := strings.Index(realmPath, "/"); i >= 0 {507		return realmPath[i:]508	}509	return "/" + realmPath510}511

Functions

not supported for pure packages by the node (vm/qfuncs)

Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.