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

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}