Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

patron.gno

9.84 Kb · 258 lines
  1// Package patron is recurring support for a builder, on chain.
  2//
  3// A creator opens a plan with a price per period and a period measured in
  4// blocks. A supporter attaches ugnot to [Subscribe] and buys whole periods of
  5// it. Anybody can ask at any height whether a given address is still active,
  6// which is the one question a tip cannot answer.
  7//
  8// # There is no cron on chain, so a renewal is a pull and not a push
  9//
 10// Nothing in this realm can charge anybody. A renewal happens because the
 11// supporter sends another payment, never because a timer fired: there is no
 12// scheduler here, and there is no way to write one, since a native coin
 13// cannot be pulled at all. A reader arriving from web2 expects a standing
 14// mandate on a card and there is nothing of the kind, anywhere on this chain.
 15//
 16// # What this adds over the tip jar
 17//
 18// gno.land/r/moul/x/daily/tipjar is the one-shot version and is already live:
 19// one payment, a leaderboard, and then nothing. The recurrence is the whole
 20// difference. A plan has a price and a period, a payment buys whole periods
 21// of it, renewing early never discards time already paid for, and the realm
 22// answers "is this address active right now" at any height. It is the one app
 23// in the x/social family that produces a recurring write rather than a
 24// one-off.
 25//
 26// # Earnings are credited at payment time, not streamed
 27//
 28// The creator can withdraw the full price the instant it is paid. A supporter
 29// who stops being active is NOT refunded, and no part of a paid period ever
 30// comes back. That is a deliberate v0 limitation: escrowed streaming, where
 31// the creator claims only what has elapsed and the supporter cancels and
 32// reclaims the rest, needs a claim schedule and a refund path, and that is a
 33// larger realm than this one rather than a flag on it.
 34//
 35// # Money leaves by pull
 36//
 37// Both halves of what this realm owes, a creator's earnings and a supporter's
 38// change, land in one credit ledger and leave through [Withdraw]. The credit
 39// is zeroed before any coin moves, and the realm never loops over payees: one
 40// unpayable address would otherwise fail the whole batch and hand a griefer a
 41// cheap denial of service.
 42//
 43// # v0 ships no token
 44//
 45// Deliberately, and the README says why: a creator coin minted per period
 46// paid is easy, and the sink is not, because what it would be redeemed for is
 47// a promise made off chain.
 48package patron
 49
 50import (
 51	"chain"
 52	"chain/banker"
 53	"chain/runtime"
 54	"strconv"
 55
 56	"gno.land/p/moul/kit/store/v0"
 57	"gno.land/p/moul/x/envelope/v0"
 58	pt "gno.land/p/moul/x/social/patron/v0"
 59)
 60
 61// realmPath is this realm's own path, the one its gnomod.toml module line
 62// declares. It is written out rather than read from the frame, because a
 63// plain read exported by a realm reports the CALLER and would build every
 64// link against whoever asked.
 65const realmPath = "gno.land/r/moul/x/social/patron/v0"
 66
 67// denom is the only coin this realm handles.
 68const denom = "ugnot"
 69
 70// plans holds every plan and the credit ledger. A redeploy wipes it, which is
 71// the trade `private = true` makes: see gnomod.toml.
 72var plans = pt.NewRegistry()
 73
 74// Open creates a plan and returns its id. Anyone may open one, and opening
 75// one costs nothing beyond gas and the storage deposit.
 76//
 77// pricePerPeriod is in ugnot and must be at least one: a free plan is a tip
 78// jar, not a subscription. periodBlocks is a period in BLOCKS, bounded both
 79// ways by the engine, because height is the clock consensus agrees on and a
 80// block timestamp is not something to build a billing cliff out of.
 81func Open(cur realm, title, description string, pricePerPeriod, periodBlocks int64) int64 {
 82	who := caller(cur)
 83	id, err := plans.Open(who, title, description, pricePerPeriod, periodBlocks)
 84	if err != nil {
 85		panic(err.Error())
 86	}
 87	chain.Emit("Open",
 88		"id", id.String(),
 89		"creator", who.String(),
 90		"price", strconv.FormatInt(pricePerPeriod, 10),
 91		"period", strconv.FormatInt(periodBlocks, 10))
 92	return int64(id)
 93}
 94
 95// Subscribe buys whole periods on a plan with the ugnot attached to the call,
 96// and returns the caller's new paid-through height.
 97//
 98// Attach the coins with -send: a payment short of one period is refused, and
 99// the remainder under one period is credited back to the caller rather than
100// kept, so an overpayment is change and never a silent fee. Take it back with
101// [Withdraw].
102//
103// # It has to be called directly by a user, and that is not a style choice
104//
105// The envelope is what the SIGNER attached to the transaction. A realm the
106// user called has already received those coins itself, and could then call in
107// here as many times as it liked against one payment, so the caller is
108// checked before the envelope is read. The same property means a realm cannot
109// forward an envelope it was handed (`NewBanker` requires the previous frame
110// to be a user call), and that `maketx run` cannot reach this function at all,
111// because a run script is itself a code realm. Use `maketx call -send`.
112func Subscribe(cur realm, planID int64) int64 {
113	// The frame is read here rather than through caller(), because this
114	// function asks the previous frame two questions and the order of them is
115	// the whole guard: is the token live, is the caller a user, and only then
116	// what did they send.
117	if !cur.IsCurrent() {
118		panic("spoofed realm: cur is not the live crossing frame")
119	}
120	prev := cur.Previous()
121	if !prev.IsUserCall() {
122		panic("patron: Subscribe must be called directly by a user with maketx call -send, " +
123			"not through another realm and not from maketx run")
124	}
125	who := prev.Address()
126
127	id := store.ID(planID)
128	p := mustGet(id)
129	if !p.Open {
130		panic(pt.ErrPlanClosed.Error())
131	}
132
133	// RequireAtLeast names both numbers when the envelope falls short, which
134	// is the difference between a caller fixing their command and a caller
135	// opening their wallet.
136	sent := envelope.RequireAtLeast(denom, p.PricePerPeriod)
137
138	pay, err := plans.Subscribe(id, who, sent, runtime.ChainHeight())
139	if err != nil {
140		panic(err.Error())
141	}
142	chain.Emit("Subscribe",
143		"id", id.String(),
144		"supporter", who.String(),
145		"periods", strconv.FormatInt(pay.Periods, 10),
146		"spent", strconv.FormatInt(pay.Spent, 10),
147		"change", strconv.FormatInt(pay.Change, 10),
148		"paidThrough", strconv.FormatInt(pay.PaidThrough, 10))
149	return pay.PaidThrough
150}
151
152// Close stops a plan taking new subscriptions. Creator only.
153//
154// Everything already paid for runs to its own paid-through height. Closing a
155// plan is not a way to take a period back.
156func Close(cur realm, planID int64) {
157	who := caller(cur)
158	id := store.ID(planID)
159	if err := plans.Close(id, who); err != nil {
160		panic(err.Error())
161	}
162	chain.Emit("Close", "id", id.String(), "by", who.String())
163}
164
165// Reopen lets a closed plan take subscriptions again. Creator only.
166func Reopen(cur realm, planID int64) {
167	who := caller(cur)
168	id := store.ID(planID)
169	if err := plans.Reopen(id, who); err != nil {
170		panic(err.Error())
171	}
172	chain.Emit("Reopen", "id", id.String(), "by", who.String())
173}
174
175// Withdraw pays the caller everything this realm owes them and returns the
176// amount: a creator's earnings, a supporter's change, or both at once.
177//
178// The credit is zeroed by the engine before a coin moves, so a recipient that
179// calls straight back in finds nothing left to take.
180func Withdraw(cur realm) int64 {
181	who := caller(cur)
182	amount, err := plans.Withdraw(who)
183	if err != nil {
184		panic(err.Error())
185	}
186	banker.NewBanker(banker.BankerTypeRealmSend, cur).SendCoins(
187		cur.Address(), who, chain.NewCoins(chain.NewCoin(denom, amount)))
188	chain.Emit("Withdraw", "to", who.String(), "amount", strconv.FormatInt(amount, 10))
189	return amount
190}
191
192// Get returns a plan's fields.
193//
194// It returns a tuple rather than the stored record: handing out a pointer to
195// realm state is a live mutation handle, and this realm is private, so an
196// importer retaining one of its objects would be a runtime panic rather than
197// a design debate.
198func Get(planID int64) (creator address, title, description string, pricePerPeriod, periodBlocks int64, open bool) {
199	p := mustGet(store.ID(planID))
200	return p.Creator, p.Title, p.Description, p.PricePerPeriod, p.PeriodBlocks, p.Open
201}
202
203// Count is how many plans exist.
204func Count() int { return plans.Count() }
205
206// IsActive reports whether who is paid up on this plan at the current height.
207//
208// Active means now < paidThrough, strictly: an address paid through height h
209// is active at h-1 and not at h.
210func IsActive(planID int64, who address) bool {
211	return mustGet(store.ID(planID)).IsActive(who, runtime.ChainHeight())
212}
213
214// PaidThrough is the height who stops being active at on this plan, or zero
215// if they never paid.
216func PaidThrough(planID int64, who address) int64 {
217	return mustGet(store.ID(planID)).PaidThrough(who)
218}
219
220// SupporterCount is how many distinct addresses have ever paid this plan,
221// active or not.
222func SupporterCount(planID int64) int {
223	return mustGet(store.ID(planID)).SupporterCount()
224}
225
226// Supporters is every address that has ever paid this plan, in first-payment
227// order. It is a copy, not the stored slice.
228func Supporters(planID int64) []address {
229	return mustGet(store.ID(planID)).Supporters()
230}
231
232// EarnedBy is the lifetime ugnot credited to creator across every plan,
233// whether or not it has been withdrawn.
234func EarnedBy(creator address) int64 { return plans.EarnedBy(creator) }
235
236// CreditOf is what addr can withdraw right now.
237func CreditOf(addr address) int64 { return plans.CreditOf(addr) }
238
239// TotalOwed is everything this realm owes, which is what it must keep in
240// reserve at its own address.
241func TotalOwed() int64 { return plans.TotalOwed() }
242
243// mustGet reads a plan or aborts naming it.
244func mustGet(id store.ID) *pt.Plan {
245	p, ok := plans.Get(id)
246	if !ok {
247		panic("patron: plan #" + id.String() + " not found")
248	}
249	return p
250}
251
252// caller is the address that called us, checked the one way that is safe.
253func caller(cur realm) address {
254	if !cur.IsCurrent() {
255		panic("spoofed realm: cur is not the live crossing frame")
256	}
257	return cur.Previous().Address()
258}