premium.gno
8.67 Kb · 278 lines
1package nslogic
2
3import (
4 "strconv"
5 "strings"
6
7 "gno.land/r/nym-mnsrehearsal001/nsdata/v1"
8)
9
10// Global pricing layers that sit ACROSS every domain rather than inside
11// one — see SPEC.md §27.
12//
13// Two of them, and they work at different points:
14//
15// GLOBAL LENGTH RULES are a FLOOR beneath each domain's own length
16// rules, consulted only when the domain has nothing to say about that
17// length. "three-character names cost $5 everywhere" becomes one entry
18// instead of an edit to all twenty-seven domains, and a domain that
19// wants its own answer still overrides it.
20//
21// GLOBAL NAME PREMIUMS are an ADJUSTMENT applied after the price has
22// already resolved. Making `og` premium is one entry covering
23// `og*degen`, `og*meme` and every domain invented later. Deliberately
24// not the per-name override, which REPLACES a price outright: "this
25// name costs exactly X here" and "this name is worth more everywhere"
26// are different tools and both are worth having.
27//
28// Full order, most specific first:
29//
30// 1. per-name override (domain "nm")
31// 2. per-domain length rule (domain "len")
32// 3. global length rule (global "glen")
33// 4. domain base price (domain "price")
34// 5. global default (global "defaultPrice")
35// then the global name premium modifies the result,
36// then any referral discount comes off what is left.
37const (
38 keyGlobalLens = "glen" // global length rules
39 keyPremium = "premium" // premium1, premium2, ... see below
40
41 // The vault caps an extension value at maxExtraValueLen (512 bytes),
42 // which is roughly 40-60 premium entries. The list is therefore split
43 // across numbered keys rather than pretending one will do; a list
44 // that silently truncated at the 512th byte would drop exactly the
45 // entries nobody checked.
46 maxPremiumKeys = 8
47)
48
49// GlobalLengthRule returns the global rule for a label length, if any.
50func GlobalLengthRule(n int) (string, bool) {
51 return lookupRule(nsdata.GetGlobal(keyGlobalLens), strconv.Itoa(n))
52}
53
54// PremiumFor returns the raw modifier for a name across all premium
55// keys, or "" when the name is not premium anywhere.
56func PremiumFor(label string) string {
57 for i := 1; i <= maxPremiumKeys; i++ {
58 csv := nsdata.GetGlobal(keyPremium + strconv.Itoa(i))
59 if csv == "" {
60 continue
61 }
62 if v, ok := lookupRule(csv, label); ok {
63 return v
64 }
65 }
66 return ""
67}
68
69// applyPremium adjusts an already-resolved price.
70//
71// ZERO ALWAYS STAYS ZERO. A fixed "+$2.00" would otherwise silently undo
72// a giveaway tier set months earlier, and a free tier that quietly stops
73// being free is worse than never having offered one. This is decided
74// explicitly rather than inherited from the percentage case happening to
75// work out at zero.
76//
77// Forms: "%5" / "+%5" five percent more · "-%10" ten percent less ·
78// "2.00" two dollars more · "-0.25" twenty-five cents less ·
79// "=5.00" exactly five dollars, ignoring everything resolved above.
80func applyPremium(mod string, price int64) int64 {
81 mod = strings.TrimSpace(mod)
82 if mod == "" || price <= 0 {
83 return price
84 }
85
86 // "=X" replaces outright. Checked before the sign handling because
87 // an exact price is not a delta and has no direction.
88 if strings.HasPrefix(mod, "=") {
89 v, ok := parseUSD(mod[1:])
90 if !ok || v < 0 {
91 return price
92 }
93 return v
94 }
95
96 neg := false
97 switch {
98 case strings.HasPrefix(mod, "-"):
99 neg, mod = true, mod[1:]
100 case strings.HasPrefix(mod, "+"):
101 mod = mod[1:]
102 }
103
104 var delta int64
105 if strings.HasPrefix(mod, "%") {
106 pct, err := strconv.ParseInt(mod[1:], 10, 64)
107 // Out of range reads as malformed (see applyRule): an unbounded
108 // percentage would wrap into a wrong price rather than a high one.
109 if err != nil || pct < 0 || pct > maxRulePct || price > maxPriceUSD {
110 return price
111 }
112 delta = price * pct / 100
113 } else {
114 v, ok := parseUSD(mod)
115 if !ok || v < 0 {
116 return price
117 }
118 delta = v
119 }
120
121 if neg {
122 // A discount may take a name to free, but never below it: a
123 // negative price would be a payout, and this realm never pays
124 // anybody to register.
125 if delta >= price {
126 return 0
127 }
128 return price - delta
129 }
130 return price + delta
131}
132
133// parseUSD reads a human dollar figure — "2", "2.5", "2.50", "0.069" —
134// into micro-USD. Written by hand rather than with floats: a float would
135// make "0.07" cost a different number of micro-dollars depending on the
136// platform, and prices must be exact.
137func parseUSD(s string) (int64, bool) {
138 s = strings.TrimSpace(s)
139 if s == "" {
140 return 0, false
141 }
142 whole, frac := s, ""
143 if i := strings.IndexByte(s, '.'); i >= 0 {
144 whole, frac = s[:i], s[i+1:]
145 }
146 if whole == "" {
147 whole = "0"
148 }
149 w, err := strconv.ParseInt(whole, 10, 64)
150 // Capped at maxPriceUSD, so w*microUSD below cannot wrap.
151 if err != nil || w < 0 || w > maxPriceUSD/microUSD {
152 return 0, false
153 }
154 // Pad or truncate the fraction to exactly six digits of micro-USD.
155 if len(frac) > 6 {
156 frac = frac[:6]
157 }
158 for len(frac) < 6 {
159 frac += "0"
160 }
161 var f int64
162 if frac != "" {
163 f, err = strconv.ParseInt(frac, 10, 64)
164 if err != nil || f < 0 {
165 return 0, false
166 }
167 }
168 return w*microUSD + f, true
169}
170
171// SetGlobalLengthRules writes the project-wide length rules:
172// "1:300000000,2:150000000,3:60000000" prices one, two and three
173// character names everywhere a domain has not said otherwise.
174//
175// IT HAD NO SETTER UNTIL NOW, which meant the only way to write it was
176// the generic SetGlobal escape hatch — straight past normalizeRules, so
177// a typo stored fine and then vanished at lookup time, because
178// lookupRule skips a malformed pair in silence. A pricing layer whose
179// only writer cannot validate is a pricing layer that fails open.
180//
181// TIMELOCKED (timelock.gno), like every price rule set: "3:0" here makes
182// short names free on every domain that has no rule of its own.
183func SetGlobalLengthRules(cur realm, rules string) {
184 assertNoSend(cur)
185 assertIsAdmin(cur)
186 emitAdmin(cur, "SetGlobalLengthRules", "rules", rules)
187 norm := normalizeRules(rules, true)
188 requireAction(cur, actionID("SetGlobalLengthRules", norm))
189 setGlobal(cur, keyGlobalLens, norm)
190}
191
192// SetPremiumNames writes one page of the premium keyword list, 1-based.
193// The list is paged because the vault caps an extension value at 512
194// bytes; see maxPremiumKeys.
195//
196// Same gap as above: normalizePremiums existed and nothing exported
197// called it.
198func SetPremiumNames(cur realm, page int, csv string) {
199 assertNoSend(cur)
200 assertIsAdmin(cur)
201 emitAdmin(cur, "SetPremiumNames", "page", fmtI(page), "csv", csv)
202 if page < 1 || page > maxPremiumKeys {
203 panic("nslogic: premium page must be 1-" + strconv.Itoa(maxPremiumKeys))
204 }
205 v := normalizePremiums(csv)
206 if len(v) > 512 {
207 panic("nslogic: premium page " + strconv.Itoa(page) +
208 " is " + strconv.Itoa(len(v)) + " bytes; the vault caps a value at 512. Split it across pages.")
209 }
210 // TIMELOCKED: a premium of "=0" or "-%100" makes a name free everywhere.
211 requireAction(cur, actionID("SetPremiumNames", strconv.Itoa(page), v))
212 setGlobal(cur, keyPremium+strconv.Itoa(page), v)
213}
214
215// PremiumNames reads one page back, so the admin panel can show what is
216// stored rather than what it last sent.
217func PremiumNames(page int) string {
218 if page < 1 || page > maxPremiumKeys {
219 return ""
220 }
221 return nsdata.GetGlobal(keyPremium + strconv.Itoa(page))
222}
223
224// GlobalLengthRules returns the whole rule string, for the same reason.
225func GlobalLengthRules() string { return nsdata.GetGlobal(keyGlobalLens) }
226
227// normalizePremiums validates a premium list before storage. Same
228// reasoning as normalizeRules: a malformed entry must never reach the
229// vault, because every later lookup would carry it.
230func normalizePremiums(csv string) string {
231 if strings.TrimSpace(csv) == "" {
232 return ""
233 }
234 var out []string
235 seen := map[string]bool{}
236 for _, pair := range strings.Split(csv, ",") {
237 pair = strings.TrimSpace(pair)
238 if pair == "" {
239 continue
240 }
241 parts := strings.SplitN(pair, ":", 2)
242 if len(parts) != 2 {
243 panic("nslogic: bad premium, want name:modifier — got " + pair)
244 }
245 k, v := strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])
246 if k == "" || strings.ContainsAny(k, "*, \t\n") {
247 panic("nslogic: invalid name in premium — " + k)
248 }
249 if seen[k] {
250 panic("nslogic: duplicate premium name — " + k)
251 }
252 if !validPremiumValue(v) {
253 panic("nslogic: bad premium modifier for " + k + " — " + v)
254 }
255 seen[k] = true
256 out = append(out, k+":"+v)
257 }
258 return strings.Join(out, ",")
259}
260
261func validPremiumValue(v string) bool {
262 if v == "" {
263 return false
264 }
265 if strings.HasPrefix(v, "=") {
266 _, ok := parseUSD(v[1:])
267 return ok
268 }
269 if strings.HasPrefix(v, "-") || strings.HasPrefix(v, "+") {
270 v = v[1:]
271 }
272 if strings.HasPrefix(v, "%") {
273 p, err := strconv.ParseInt(v[1:], 10, 64)
274 return err == nil && p >= 0 && p <= maxRulePct
275 }
276 _, ok := parseUSD(v)
277 return ok
278}