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

v0 source pure

Package patron is the engine behind recurring support for a builder: a plan somebody subscribes to, period after peri...

Readme View source

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

The engine behind recurring support for a builder: a plan somebody subscribes to, period after period, paid in ugnot. NewRegistry, Open, Subscribe, Close, Reopen, Withdraw, plus the reads a page needs.

1r := patron.NewRegistry()
2id, _ := r.Open(creator, "monthly support", "what you get", 1_000_000, 43_200)
3
4pay, _ := r.Subscribe(id, supporter, 2_500_000, height) // 2 periods, 500000 change
5plan, _ := r.Get(id)
6plan.IsActive(supporter, height)                        // true until pay.PaidThrough
7
8r.Withdraw(creator)   // the earnings
9r.Withdraw(supporter) // the change

There is no cron on chain, so a renewal is a pull and not a push. Nothing here can charge anybody, and nothing could: a native coin cannot be pulled at all, since a banker may only spend its own realm's address. A supporter renews by signing another payment. That is the one sentence a reader arriving from web2 needs, because the thing they are picturing, a standing mandate on a card, does not exist anywhere on this chain.

What it adds over a tip jar. r/moul/x/daily/tipjar is the one-shot version and is already live: one payment, a leaderboard, done. The recurrence is the whole difference here. A plan carries a price per period and a period measured in blocks, a payment buys whole periods of it, and the registry answers "is this address active right now" at any height. It is the one piece of the x/social family that produces a recurring write rather than a one-off.

The period arithmetic is the part that has to be right, so it is stated rather than left to the reader:

  • A payment buys floor(sent / price) whole periods and refuses anything short of one. Rounding is down, and the remainder under one period is credited back to the supporter rather than kept. Keeping it would be a fee nobody agreed to, and a silent fee is the thing a subscription realm must not have.
  • The extension starts from whichever is later, now or the current paidThrough. Renewing early therefore adds a whole period on top of what is left instead of discarding it; renewing after a lapse starts from now, because the gap was never paid for.
  • IsActive is strict: an address paid through height h is active at h-1 and not at h. One rule at both edges, so two periods never overlap by a block.
  • Subscribe computes the extension through xmath.MulDiv. The division there is exact by construction, so nothing is rounded a second time; what it buys is the 128-bit intermediate, because spent * periodBlocks overflows an int64 for a plan priced in whole GNOT with a period measured in months, and the naive product wraps to a plausible-looking height rather than an obviously wrong one.

Earnings are credited at the moment of payment, not streamed. The creator can withdraw the whole price the instant it arrives, so a supporter who stops being active is not refunded and no part of a paid period ever comes back. That is a real limitation, and it is v0 on purpose: escrowed streaming, where the creator claims only what has elapsed and the supporter cancels and reclaims the rest, needs a claim schedule and a refund path. That is a larger realm than this one, not a flag on it, and it is the v1.

The trap it avoids: money leaves by pull, never by a push loop. Nothing here moves a coin. Subscribe credits a pullpayment ledger, and Withdraw zeroes a credit and reports what was owed so the holding realm transfers afterwards, with the balance already gone when control leaves. A realm that looped over payees instead would fail entirely on one unpayable address and hand a griefer a cheap denial of service.

A title and a description are attacker-controlled markdown. ValidTitle and ValidDescription bound them and refuse control characters, which is a different protection from escaping and not a substitute for it. One note for whoever renders them: md.Link escapes its text with the inline escaper, and the inline escaper does not touch a pipe, so a caller's title carried inside a link inside a table cell still opens a column. In a table, the link text has to be something the realm owns and the title gets ui.Cell.

v0 ships no token, and that is the answer rather than a gap

A creator coin minted per period paid is the easy half. The sink is not: what a supporter would redeem it for is a promise the creator makes off chain, and a token whose only sink is a promise is a scoreboard with a price. It would also compete with the thing that already works here, which is that a period is paid in ugnot and either active or not.

What would change the answer is a redeem the creator can be held to on chain: a queue position the realm enforces, an allocation it hands out, an access gate another realm checks before it lets somebody in. Any of those turns the coin into a claim rather than a souvenir, and at that point the mint rule, the sink and the buyer can all be named.

Until one of them exists, the condition is the deliverable. The sibling package gno.land/p/moul/x/social/coin/v0 is where that gets enforced: a GRC20 that refuses to exist until its mint rule, its sink and its buyer are declared. This package does not import it, and will not until there is something true to declare.

Live realm: r/moul/x/social/patron · render it at /r/moul/x/social/patron/v0.


Part of moul/gno-contracts — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage.

Dependency graph:

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

🧪 Highly experimental — potentially vibe-coded. Not audited; may break, change, or be removed at any time. Do not use with anything of value. Full disclaimer: DISCLAIMER.

Overview

Package patron is the engine behind recurring support for a builder: a plan somebody subscribes to, period after period, paid in ugnot on chain.

There is no cron on chain, so a renewal is a pull and not a push

Nothing here can charge anybody. The supporter sends another payment and their paid-through height moves forward; there is no scheduler, no keeper, and no standing authority over anybody's balance. There is no way to write one either, because a native coin cannot be pulled at all: a banker may only spend its own realm's address, so the inbound path is always the holder signing. A reader arriving from web2 expects the opposite, and that is the one expectation to unlearn before reading the rest of this package.

What this adds over a tip jar

A tip jar is one payment and then nothing, and the one-shot version is already live at gno.land/r/moul/x/daily/tipjar. The recurrence is the only reason this exists: a plan carries a price per period and a period measured in BLOCKS, a supporter buys whole periods, and anybody can ask at any height whether a given address is still active. It is the one piece of the x/social family that produces a recurring write rather than a one-off.

The model

Example
1Registry  every plan, plus the credit ledger money leaves through
2Plan      a creator, a title, a description, a price, a period, open or not
3Payment   what one Subscribe call bought: periods, spent, change, through

Renewing early never loses time already paid for

Registry.Subscribe extends from whichever is LATER, now or the supporter's current paid-through height. Renewing two blocks before a lapse adds a whole period on top of what is left. Renewing after a lapse starts from now, because the gap was never paid for and nothing backdates it.

The change is credited back, never kept

A payment buys floor(sent / price) whole periods, and the remainder under one period is credited to the SUPPORTER's own withdrawable balance. Keeping it would be a fee nobody agreed to, and a silent fee is the thing a subscription realm must not have. The supporter takes it back through the same Registry.Withdraw a creator uses.

Earnings are credited at the moment of payment, not streamed

The creator can withdraw the whole price the instant it is paid. A supporter who stops being active is therefore NOT refunded, and no part of a paid period is ever returned. That is a real limitation and it is v0 on purpose: escrowed streaming, where the creator claims only what has elapsed and the supporter can cancel and reclaim the rest, needs a claim schedule and a refund path, which is a bigger realm than this one. It is the v1, and it is not a line that can be bolted onto this one.

Money leaves by pull, never by a push loop

Nothing here moves coins. Registry.Subscribe credits a gno.land/p/moul/x/daily/pullpayment ledger, and Registry.Withdraw zeroes a credit and reports what was owed so the holding realm can transfer after that call, with the balance already gone when control leaves. A realm that looped over payees instead would fail entirely on one unpayable address and hand a griefer a cheap denial of service.

A title and a description are attacker-controlled markdown

Both are free text. ValidTitle and ValidDescription bound them and refuse control characters, which is a different protection from escaping and not a substitute for it: a realm that renders either one escapes it with ui.Inline in prose or ui.Cell in a table cell.

Constants 1

const MaxTitleLen, MaxDescriptionLen, MinPeriodBlocks, MaxPeriodBlocks, MinPricePerPeriod, MaxPeriodsPerPayment

 1const (
 2	// MaxTitleLen and MaxDescriptionLen bound the two free-text fields. Long
 3	// enough to say what the plan is, short enough that opening one cannot
 4	// lock an unbounded storage deposit somebody else is paying for.
 5	MaxTitleLen       = 80
 6	MaxDescriptionLen = 500
 7
 8	// MinPeriodBlocks and MaxPeriodBlocks bound a period both ways. A period
 9	// of zero blocks is not a subscription, it is a division by zero wearing
10	// a price tag. The ceiling is about a year at the five second blocks the
11	// test chain runs, past which "recurring" stops meaning anything and the
12	// plan is a one-off with extra steps.
13	MinPeriodBlocks = int64(10)
14	MaxPeriodBlocks = int64(6307200)
15
16	// MinPricePerPeriod is one ugnot, because a free plan is a tip jar
17	// (gno.land/r/moul/x/daily/tipjar) and not a subscription: at a price of
18	// zero there is nothing to buy a period with and every address would be
19	// active forever.
20	MinPricePerPeriod = int64(1)
21
22	// MaxPeriodsPerPayment bounds what one payment may buy. It keeps
23	// periods * PeriodBlocks inside an int64 by construction rather than by
24	// hope, and it stops a single send from parking a paid-through height so
25	// far ahead that no later arithmetic on it means anything.
26	MaxPeriodsPerPayment = int64(10000)
27)
source

Variables 1

var ErrBadTitle, ErrBadDescription, ErrBadPrice, ErrBadPeriod, ErrNoPlan, ErrPlanClosed, ErrNotCreator, ErrAlreadyOpen, ErrAlreadyClosed, ErrShortOfOnePeriod, ErrTooManyPeriods, ErrNothingToWithdraw

 1var (
 2	ErrBadTitle          = errors.New("patron: title is empty, too long, or has control characters")
 3	ErrBadDescription    = errors.New("patron: description is too long or has control characters")
 4	ErrBadPrice          = errors.New("patron: a plan needs a price of at least one ugnot per period")
 5	ErrBadPeriod         = errors.New("patron: period out of range")
 6	ErrNoPlan            = errors.New("patron: no such plan")
 7	ErrPlanClosed        = errors.New("patron: this plan is closed to new subscriptions")
 8	ErrNotCreator        = errors.New("patron: only the plan's creator can do that")
 9	ErrAlreadyOpen       = errors.New("patron: the plan is already open")
10	ErrAlreadyClosed     = errors.New("patron: the plan is already closed")
11	ErrShortOfOnePeriod  = errors.New("patron: the payment does not cover one whole period")
12	ErrTooManyPeriods    = errors.New("patron: one payment cannot buy that many periods")
13	ErrNothingToWithdraw = errors.New("patron: nothing to withdraw")
14)
source

The errors a caller can get back. A p/ package returns them and the realm decides to abort.

Functions 5

func PlanURL

1func PlanURL(realmPath string, id store.ID) string
source

PlanURL is the gnoweb path of one plan on the hosting realm.

func RealmURL

1func RealmURL(realmPath string) string
source

RealmURL is the gnoweb path of a realm given as a package path.

The chain domain is the first element of a package path and a gnoweb path is the rest of it, so this is a prefix strip and not a hostname this package has to know.

func ValidDescription

1func ValidDescription(description string) bool
source

ValidDescription reports whether description can be stored: within MaxDescriptionLen and free of control characters other than newline and tab. Empty is allowed, because a plan whose title says it all should not have to invent prose.

func ValidTitle

1func ValidTitle(title string) bool
source

ValidTitle reports whether title can be stored: non-empty after trimming, within MaxTitleLen, and free of control characters including newlines.

A title is one line by construction, so a newline in one is refused rather than stripped: silently rewriting what somebody typed is worse than telling them it was refused.

func NewRegistry

1func NewRegistry() *Registry
source

NewRegistry returns an empty registry.

Types 4

type Listing

struct
1type Listing struct {
2	ID   store.ID
3	Plan *Plan
4}
source

Listing is one row of Registry.List: the plan and the id a link needs.

type Payment

struct
 1type Payment struct {
 2	// Periods is how many whole periods the payment covered.
 3	Periods int64
 4
 5	// Spent is the ugnot those periods cost, credited to the creator.
 6	Spent int64
 7
 8	// Change is the remainder under one period, credited back to the
 9	// supporter rather than kept.
10	Change int64
11
12	// PaidThrough is the supporter's new paid-through height.
13	PaidThrough int64
14
15	// NewSupporter reports whether this address had never paid this plan
16	// before, which is the signal a realm wants for an event or a counter.
17	NewSupporter bool
18}
source

Payment is what one Registry.Subscribe call bought.

type Plan

struct
 1type Plan struct {
 2	Creator     address
 3	Title       string
 4	Description string
 5
 6	// PricePerPeriod is what one period costs, in ugnot.
 7	PricePerPeriod int64
 8
 9	// PeriodBlocks is how long a period lasts, in blocks. Blocks and not
10	// seconds: height is the clock consensus agrees on, and a block timestamp
11	// is set by proposers and is not something to build a billing cliff out
12	// of at second resolution.
13	PeriodBlocks int64
14
15	// Open reports whether the plan takes NEW payments. Closing it never
16	// touches a subscription already paid for, which runs to its own
17	// paid-through height.
18	Open bool
19
20	// Received is the lifetime ugnot this plan credited to its creator.
21	Received int64
22
23	// paidThrough is the first height at which a supporter is no longer
24	// active. A supporter is active while now < paidThrough, so a period
25	// bought at height h ends at h+PeriodBlocks and the holder is inactive
26	// at exactly that height.
27	paidThrough map[string]int64
28
29	// supporters is every address that has ever paid, in first-payment
30	// order. It exists so a listing is deterministic without iterating a map
31	// as if insertion order were a sort.
32	supporters []address
33}
source

Plan is one creator's recurring support plan.

Methods on Plan

func ActiveCount

method on Plan
1func (p *Plan) ActiveCount(now int64) int
source

ActiveCount is how many supporters are paid up at height now.

func IsActive

method on Plan
1func (p *Plan) IsActive(who address, now int64) bool
source

IsActive reports whether who is paid up at height now.

The comparison is strict: a supporter paid through height h is active at h-1 and not at h. One rule, applied at both edges, so a period never overlaps the next one by a block.

func PaidThrough

method on Plan
1func (p *Plan) PaidThrough(who address) int64
source

PaidThrough is the height who stops being active at, or zero if they never paid.

func SupporterCount

method on Plan
1func (p *Plan) SupporterCount() int
source

SupporterCount is how many distinct addresses have ever paid, active or not.

func Supporters

method on Plan
1func (p *Plan) Supporters() []address
source

Supporters is every address that has ever paid, in first-payment order.

It returns a copy. Handing out the stored slice would be a live mutation handle on realm state, which is the cheapest way for a reader to become a writer.

type Registry

struct
 1type Registry struct {
 2	plans  *store.Store
 3	ledger *pullpayment.Ledger
 4
 5	// earned is lifetime ugnot credited per creator, which survives a
 6	// withdrawal. The ledger only knows what is owed RIGHT NOW, and a page
 7	// showing a creator zero the moment they cash out would be telling the
 8	// truth about the wrong question.
 9	earned map[string]int64
10}
source

Registry holds every plan and the credit ledger money leaves through.

Methods on Registry

func Close

method on Registry
1func (r *Registry) Close(id store.ID, who address) error
source

Close stops a plan taking new subscriptions. Creator only.

It does not touch anything already paid for: existing supporters run to their own paid-through height, which is the only behaviour that does not turn closing a plan into taking money back.

func Count

method on Registry
1func (r *Registry) Count() int
source

Count is how many plans exist.

func CreditOf

method on Registry
1func (r *Registry) CreditOf(addr address) int64
source

CreditOf is what addr can withdraw right now: earnings as a creator, change as a supporter, or both.

func EarnedBy

method on Registry
1func (r *Registry) EarnedBy(creator address) int64
source

EarnedBy is the lifetime ugnot credited to creator across every plan, whether or not it has been withdrawn.

func Get

method on Registry
1func (r *Registry) Get(id store.ID) (*Plan, bool)
source

Get returns a plan by id.

func List

method on Registry
1func (r *Registry) List(page, size int) []Listing
source

List returns one page of plans, newest first.

func Open

method on Registry
1func (r *Registry) Open(creator address, title, description string, pricePerPeriod, periodBlocks int64) (store.ID, error)
source

Open creates a plan and returns its id. Anyone may open one.

func Pages

method on Registry
1func (r *Registry) Pages(size int) int
source

Pages is how many pages of the given size the registry holds.

func Reopen

method on Registry
1func (r *Registry) Reopen(id store.ID, who address) error
source

Reopen lets a closed plan take subscriptions again. Creator only.

func Subscribe

method on Registry
1func (r *Registry) Subscribe(id store.ID, who address, sent, now int64) (Payment, error)
source

Subscribe buys whole periods on a plan with sent ugnot, at height now.

It refuses anything short of one period, buys floor(sent / price) of them, and credits the remainder back to the supporter. The extension starts from whichever is LATER, now or the supporter's current paid-through height, so renewing early never discards time already paid for and renewing after a lapse never backdates the gap.

The creator is credited at the moment of payment, not as the periods elapse. See the package doc for why that is v0 and what v1 would have to carry instead.

func TotalOwed

method on Registry
1func (r *Registry) TotalOwed() int64
source

TotalOwed is everything the holding realm must keep in reserve.

func Withdraw

method on Registry
1func (r *Registry) Withdraw(who address) (int64, error)
source

Withdraw zeroes who's credit and reports what they were owed.

It moves no coins. The holding realm transfers the returned amount AFTER this call, which is the whole point of the pattern: the credit is already gone from the ledger when control passes to the payee, so a reentrant call finds nothing and gets ErrNothingToWithdraw.

Imports 5

Source Files 4