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}