sponsor.gno
12.89 Kb · 319 lines
1package catalog
2
3import (
4 "chain"
5 "chain/banker"
6 "strings"
7
8 "gno.land/p/nym-alexiscolin000/gnoradio/role/v0"
9 "gno.land/p/nym-alexiscolin000/gnoradio/store/v0"
10 "gno.land/p/nym-alexiscolin000/gnoradio/text/v0"
11 "gno.land/r/nym-alexiscolin000/gnoradio/data"
12)
13
14// Sponsored picks: a verified artist funds a budget that refunds the cost of
15// a listener's pick of their track once it has aired in full: the pick's fee
16// at the app's 80M gas limit (0.08 GNOT) and its ~1,200-byte deposit (0.12
17// GNOT), plus the collect's fee at 40M (0.04 GNOT), less the ~500 bytes of
18// deposit the collect frees (0.05 GNOT): about 0.19 GNOT (radio/v1 gas
19// filetests). It is a refund, not paid listening: the payout stays near the
20// pick cost so throwaway wallets gain nothing by farming it, and the radio
21// adds airtime and reputation rules (radio/sponsor.gno). Residual,
22// accepted: a script that sets tight gas limits pays less than the 0.2 GNOT
23// refund and keeps about 0.05 to 0.1 GNOT a pick, within the quotas (three
24// sponsored picks a wallet a day, one per artist, and the budget's daily
25// limit, defaultPerDay unless the artist sets one), so a budget drains no
26// faster than its daily limit.
27//
28// The coins sit in the data realm's vault, account "catalog/<P(artist)>",
29// never in this realm. The radio reserves the payout when the pick is queued
30// (a vault hold); the hold is paid when the pick has aired, released if the
31// slot is dropped, and lapses by itself claimDays after the slot ends. The
32// funder withdraws the rest at any time with data.VaultWithdraw, whatever
33// this realm does: paused, replaced or retired. This realm only keeps the
34// artist's settings and stats (catalog/budgets).
35//
36// A budget has a funding generation, bumped when another wallet funds it
37// (the previous funder is refunded, holds included). A reservation carries
38// the generation it was made in, and pays or releases only in that one: the
39// vault pools an artist's holds per day, so a reservation of an earlier
40// funding is never paid from (nor released into) a later listener's hold,
41// even when the budget went back to the same funder.
42
43const (
44 defaultPickPay = 200_000 // ugnot per aired sponsored pick, 0.2 GNOT: the pick and its collect (above)
45 minPickPay = 10_000
46 maxPickPay = 500_000 // room for dearer gas, still a refund, never a wage
47 maxPickPerDay = 100 // the artist's own daily limit
48 defaultPerDay = 10 // the daily limit unless the artist sets one (0 = this default): a farming script drains a budget slowly
49 walletPicksDay = 3 // sponsored picks per listener per UTC day, one per artist
50 claimDays = 7 // a reservation lapses this long after the slot ends
51 daySecs = 24 * 3600
52)
53
54// dataPath is the data realm: its address holds the vault's coins.
55const dataPath = "gno.land/r/nym-alexiscolin000/gnoradio/data"
56
57type budget struct {
58 Pay int64 // per aired sponsored pick, 0 = paused
59 PerDay int // sponsored picks a day (stored 0 reads as defaultPerDay)
60 Day int64 // UTC day of Today
61 Today int
62 Funded int64
63 Paid int64
64 Picks int
65 Gen int // funding generation (see above)
66}
67
68// acct is an artist's vault account.
69func acct(artistID int) string { return "catalog/" + store.Pad(artistID) }
70
71func getBudget(t *tx, artistID int) (budget, bool) {
72 rec, ok := t.get(cBudgets, store.Pad(artistID))
73 if !ok {
74 return budget{}, false
75 }
76 f := store.Fields(rec)
77 b := budget{Pay: text.MustAtoi64(f[0]), PerDay: text.MustAtoi(f[1]), Day: text.MustAtoi64(f[2]), Today: text.MustAtoi(f[3]), Funded: text.MustAtoi64(f[4]),
78 Paid: text.MustAtoi64(f[5]), Picks: text.MustAtoi(f[6]), Gen: text.MustAtoi(store.Field(rec, 7))}
79 if b.PerDay == 0 {
80 b.PerDay = defaultPerDay
81 }
82 return b, true
83}
84
85func putBudget(t *tx, artistID int, b budget) {
86 t.set(cBudgets, store.Pad(artistID), store.Rec(text.Itoa64(b.Pay), text.Itoa(b.PerDay), text.Itoa64(b.Day), text.Itoa(b.Today),
87 text.Itoa64(b.Funded), text.Itoa64(b.Paid), text.Itoa(b.Picks), text.Itoa(b.Gen)))
88}
89
90func today() int64 { return now() / daySecs }
91
92// lapseDay is the last UTC day a pick ending at end can be paid.
93func lapseDay(end int64) int64 { return (end + claimDays*daySecs) / daySecs }
94
95// FundPromo adds the attached ugnot to the caller's sponsored-pick budget in
96// the vault. A new budget pays defaultPickPay per aired pick (SetPromoPay
97// changes it). If the profile changed hands since the last funding, the
98// previous funder gets their whole balance back first, reservations included,
99// and the budget starts a new funding generation. The caller's budget must
100// then hold at least one refund, or no pick could ever use it.
101func FundPromo(cur realm) {
102 caller := userCaller(cur)
103 amt := paid()
104 if amt < minTip || amt > maxTip {
105 panic("catalog: a promo budget takes 0.1 to 1000000 GNOT")
106 }
107 t := rd()
108 id, arec := ownArtist(t, caller)
109 if store.Field(arec, aVerified) == "" {
110 panic("catalog: verify your profile first")
111 }
112 b, ok := getBudget(t, id)
113 if !ok {
114 b.Pay = defaultPickPay
115 }
116 b.Funded += amt
117 ac := acct(id)
118 funder, bal, _ := data.VaultInfo(ac)
119 own := amt // what the caller will have in the budget
120 if funder == caller {
121 own += bal
122 }
123 if b.Pay > 0 && own < b.Pay {
124 panic("catalog: fund at least one pick's refund (" + text.Itoa64(b.Pay) + " ugnot)")
125 }
126 if funder != caller && funder != "" {
127 b.Gen++ // every reservation of the previous funding is void
128 if bal > 0 {
129 data.VaultRefund(cross(cur), ac)
130 emitEvent("PromoWithdrawn", "artist", text.Itoa(id), "to", funder.String(), "amount", text.Itoa64(bal))
131 }
132 }
133 putBudget(t, id, b)
134 // The coins go to the vault before data counts them (data checks it).
135 banker.NewBanker(banker.BankerTypeRealmSend, cur).SendCoins(cur.Address(), chain.PackageAddress(dataPath), chain.Coins{{Denom: denom, Amount: amt}})
136 data.VaultCredit(cross(cur), ac, caller, amt)
137 save(cur, t)
138 _, bal, _ = data.VaultInfo(ac)
139 emitEvent("PromoFunded", "artist", text.Itoa(id), "by", caller.String(), "amount", text.Itoa64(amt), "balance", text.Itoa64(bal))
140}
141
142// SetPromoPay sets what each aired sponsored pick of the caller's tracks
143// refunds (10 000..500 000 ugnot, 0 pauses) and the daily limit (1 to 100,
144// 0 = the default, 10).
145func SetPromoPay(cur realm, ugnotPerPick int64, perDay int) {
146 caller := userCaller(cur)
147 noPayment()
148 if ugnotPerPick != 0 && (ugnotPerPick < minPickPay || ugnotPerPick > maxPickPay) {
149 panic("catalog: a sponsored pick refunds 0.01 to 0.5 GNOT (0 pauses)")
150 }
151 if perDay < 0 || perDay > maxPickPerDay {
152 panic("catalog: the daily limit is 1 to 100 picks (0: the default, 10)")
153 }
154 t := rd()
155 id, _ := ownArtist(t, caller)
156 b, _ := getBudget(t, id)
157 b.Pay, b.PerDay = ugnotPerPick, perDay
158 putBudget(t, id, b)
159 save(cur, t)
160 emitEvent("PromoPay", "artist", text.Itoa(id), "pay", text.Itoa64(ugnotPerPick), "perDay", text.Itoa(perDay))
161}
162
163// PromoReserve reserves the refund of a sponsored pick of trackID by picker
164// whose slot ends at end. Only the radio realm may call it (radio.QueueSponsored);
165// it panics with the reason when the pick cannot be sponsored. It returns
166// the amount reserved and the budget's funding generation, which the radio
167// passes back to PromoClaim, PromoClaimable and PromoRelease.
168func PromoReserve(cur realm, trackID int, picker address, end int64) (int64, int) {
169 onlyRadio(cur)
170 t := rd()
171 tr := mustTrack(t, trackID)
172 a := getArtist(t, tr.Artist)
173 b, _ := getBudget(t, a.ID)
174 funder, _, free := data.VaultInfo(acct(a.ID))
175 if a.Hidden || !a.Verified || a.Owner == "" || funder != a.Owner || b.Pay == 0 {
176 panic("catalog: this artist does not sponsor picks right now")
177 }
178 own := picker == a.Owner || !role.Canonical(picker) || data.IsGnoRadio(picker)
179 for _, s := range tr.Splits {
180 own = own || s.To == picker
181 }
182 if own {
183 panic("catalog: the artist and collaborators cannot take a sponsored pick")
184 }
185 day := today()
186 if b.Day != day {
187 b.Day, b.Today = day, 0
188 }
189 if b.PerDay > 0 && b.Today >= b.PerDay {
190 panic("catalog: this artist's sponsored picks for today are taken, pick it normally")
191 }
192 if free < b.Pay {
193 panic("catalog: this artist's promo budget is empty, pick it normally")
194 }
195 wd := t.val(cWalletDay, picker.String())
196 d, list, _ := strings.Cut(wd, "|")
197 if text.Atoi64(d) != day {
198 list = ""
199 }
200 if strings.Contains(","+list, ","+text.Itoa(a.ID)+",") {
201 panic("catalog: one sponsored pick per artist a day, pick it normally")
202 }
203 if strings.Count(list, ",") >= walletPicksDay {
204 panic("catalog: three sponsored picks a day, pick normally")
205 }
206 t.set(cWalletDay, picker.String(), text.Itoa64(day)+"|"+list+text.Itoa(a.ID)+",")
207 b.Today++
208 putBudget(t, a.ID, b)
209 data.VaultHold(cross(cur), acct(a.ID), b.Pay, lapseDay(end))
210 save(cur, t)
211 emitEvent("PromoReserved", "artist", text.Itoa(a.ID), "track", text.Itoa(trackID), "picker", picker.String(), "amount", text.Itoa64(b.Pay))
212 return b.Pay, b.Gen
213}
214
215// PromoClaim pays picker a reservation of funding generation gen once their
216// sponsored pick ending at end has aired; the radio realm checks the slot. A
217// lapsed or cancelled reservation (the budget was refunded since) pays nothing.
218func PromoClaim(cur realm, trackID int, picker address, end, amount int64, gen int) {
219 onlyRadio(cur)
220 t := rd()
221 artistID, _, _, _, _ := brief(t, trackID)
222 e := lapseDay(end)
223 if data.VaultDay() > e { // the vault's clock: a pause never makes a refund lapse
224 panic("catalog: this sponsored pick was not collected within 7 days")
225 }
226 b, _ := getBudget(t, artistID)
227 if amount <= 0 || b.Gen != gen || data.VaultHeld(acct(artistID), e) < amount {
228 panic("catalog: this sponsorship was cancelled")
229 }
230 b.Paid += amount
231 b.Picks++
232 putBudget(t, artistID, b)
233 data.VaultPay(cross(cur), acct(artistID), picker, amount, e)
234 save(cur, t)
235 emitEvent("PromoPaid", "artist", text.Itoa(artistID), "track", text.Itoa(trackID), "to", picker.String(), "amount", text.Itoa64(amount))
236}
237
238// PromoClaimable reports whether PromoClaim would pay a sponsored pick's
239// refund: reserved for end in generation gen, neither lapsed nor cancelled.
240func PromoClaimable(trackID int, end, amount int64, gen int) bool {
241 t := rd()
242 artistID, _, _, _, _ := brief(t, trackID)
243 e := lapseDay(end)
244 b, _ := getBudget(t, artistID)
245 return data.VaultDay() <= e && amount > 0 && b.Gen == gen && data.VaultHeld(acct(artistID), e) >= amount
246}
247
248// PromoRelease returns a reservation to its budget: the slot was dropped or
249// cut before it aired in full. A reservation of an earlier funding
250// generation than gen's budget was refunded with it: nothing to release. A
251// pick that never aired, reserved at reserved (unix; 0 when it partly aired),
252// also gives back the daily counts it took, the artist's and picker's, if
253// that was today. Radio realm only.
254func PromoRelease(cur realm, trackID int, picker address, end, amount, reserved int64, gen int) {
255 onlyRadio(cur)
256 t := rd()
257 artistID, _, _, _, _ := brief(t, trackID)
258 b, _ := getBudget(t, artistID)
259 if amount > 0 && b.Gen == gen {
260 // lapsed or cancelled already: less, or nothing
261 if amount = data.VaultRelease(cross(cur), acct(artistID), amount, lapseDay(end)); amount > 0 {
262 emitEvent("PromoReleased", "artist", text.Itoa(artistID), "track", text.Itoa(trackID), "amount", text.Itoa64(amount))
263 }
264 }
265 day := today()
266 if reserved <= 0 || reserved/daySecs != day {
267 return
268 }
269 if b.Day == day && b.Today > 0 {
270 b.Today--
271 putBudget(t, artistID, b)
272 }
273 wd := t.val(cWalletDay, picker.String())
274 if d, list, _ := strings.Cut(wd, "|"); text.Atoi64(d) == day {
275 t.set(cWalletDay, picker.String(), d+"|"+strings.Replace(","+list, ","+text.Itoa(artistID)+",", ",", 1)[1:])
276 }
277 save(cur, t)
278}
279
280// PromoOffer is what a sponsored pick of the artist's tracks refunds right
281// now (0: none). Picker-specific limits are checked when picking.
282func PromoOffer(artistID int) int64 {
283 t := rd()
284 return offer(t, getArtist(t, artistID))
285}
286
287func offer(t *tx, a *Artist) int64 {
288 if a == nil {
289 return 0
290 }
291 if a.Hidden || !a.Verified || a.Owner == "" {
292 return 0
293 }
294 b, ok := getBudget(t, a.ID)
295 if !ok || b.Pay == 0 || b.PerDay > 0 && b.Day == today() && b.Today >= b.PerDay {
296 return 0
297 }
298 if funder, _, free := data.VaultInfo(acct(a.ID)); funder != a.Owner || free < b.Pay {
299 return 0
300 }
301 return b.Pay
302}
303
304// PromoJSON is an artist's sponsored-pick budget:
305// {"artist","funder","balance","reserved","free","pay","perDay","today","funded","paid","picks","offer"}.
306// The funder withdraws "free" with data.VaultWithdraw(acct) where acct is
307// "catalog/" and the artist id on 8 digits.
308func PromoJSON(artistID int) string {
309 t := rd()
310 b, _ := getBudget(t, artistID)
311 funder, bal, free := data.VaultInfo(acct(artistID))
312 if b.Day != today() {
313 b.Today = 0
314 }
315 return `{"artist":` + text.Itoa(artistID) + `,"funder":` + text.Str(funder.String()) + `,"balance":` + text.Itoa64(bal) +
316 `,"reserved":` + text.Itoa64(bal-free) + `,"free":` + text.Itoa64(free) + `,"pay":` + text.Itoa64(b.Pay) + `,"perDay":` + text.Itoa(b.PerDay) +
317 `,"today":` + text.Itoa(b.Today) + `,"funded":` + text.Itoa64(b.Funded) + `,"paid":` + text.Itoa64(b.Paid) + `,"picks":` + text.Itoa(b.Picks) +
318 `,"offer":` + text.Itoa64(offer(t, getArtist(t, artistID))) + `}`
319}