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

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}