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)34pay,_:=r.Subscribe(id,supporter,2_500_000,height)// 2 periods, 500000 change5plan,_:=r.Get(id)6plan.IsActive(supporter,height)// true until pay.PaidThrough78r.Withdraw(creator)// the earnings9r.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.
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:
🧪 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.
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. 5MaxTitleLen=80 6MaxDescriptionLen=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 wearing10// a price tag. The ceiling is about a year at the five second blocks the11// test chain runs, past which "recurring" stops meaning anything and the12// plan is a one-off with extra steps.13MinPeriodBlocks=int64(10)14MaxPeriodBlocks=int64(6307200)1516// MinPricePerPeriod is one ugnot, because a free plan is a tip jar17// (gno.land/r/moul/x/daily/tipjar) and not a subscription: at a price of18// zero there is nothing to buy a period with and every address would be19// active forever.20MinPricePerPeriod=int64(1)2122// MaxPeriodsPerPayment bounds what one payment may buy. It keeps23// periods * PeriodBlocks inside an int64 by construction rather than by24// hope, and it stops a single send from parking a paid-through height so25// far ahead that no later arithmetic on it means anything.26MaxPeriodsPerPayment=int64(10000)27)
1var( 2ErrBadTitle=errors.New("patron: title is empty, too long, or has control characters") 3ErrBadDescription=errors.New("patron: description is too long or has control characters") 4ErrBadPrice=errors.New("patron: a plan needs a price of at least one ugnot per period") 5ErrBadPeriod=errors.New("patron: period out of range") 6ErrNoPlan=errors.New("patron: no such plan") 7ErrPlanClosed=errors.New("patron: this plan is closed to new subscriptions") 8ErrNotCreator=errors.New("patron: only the plan's creator can do that") 9ErrAlreadyOpen=errors.New("patron: the plan is already open")10ErrAlreadyClosed=errors.New("patron: the plan is already closed")11ErrShortOfOnePeriod=errors.New("patron: the payment does not cover one whole period")12ErrTooManyPeriods=errors.New("patron: one payment cannot buy that many periods")13ErrNothingToWithdraw=errors.New("patron: nothing to withdraw")14)
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.
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.
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.
1typePaymentstruct{ 2// Periods is how many whole periods the payment covered. 3Periodsint64 4 5// Spent is the ugnot those periods cost, credited to the creator. 6Spentint64 7 8// Change is the remainder under one period, credited back to the 9// supporter rather than kept.10Changeint641112// PaidThrough is the supporter's new paid-through height.13PaidThroughint641415// NewSupporter reports whether this address had never paid this plan16// before, which is the signal a realm wants for an event or a counter.17NewSupporterbool18}
1typePlanstruct{ 2Creatoraddress 3Titlestring 4Descriptionstring 5 6// PricePerPeriod is what one period costs, in ugnot. 7PricePerPeriodint64 8 9// PeriodBlocks is how long a period lasts, in blocks. Blocks and not10// seconds: height is the clock consensus agrees on, and a block timestamp11// is set by proposers and is not something to build a billing cliff out12// of at second resolution.13PeriodBlocksint641415// Open reports whether the plan takes NEW payments. Closing it never16// touches a subscription already paid for, which runs to its own17// paid-through height.18Openbool1920// Received is the lifetime ugnot this plan credited to its creator.21Receivedint642223// paidThrough is the first height at which a supporter is no longer24// active. A supporter is active while now < paidThrough, so a period25// bought at height h ends at h+PeriodBlocks and the holder is inactive26// at exactly that height.27paidThroughmap[string]int642829// supporters is every address that has ever paid, in first-payment30// order. It exists so a listing is deterministic without iterating a map31// as if insertion order were a sort.32supporters[]address33}
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.
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.
1typeRegistrystruct{ 2plans*store.Store 3ledger*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. 9earnedmap[string]int6410}
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.
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.
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.