pricing.gno
9.33 Kb · 261 lines
1package nslogic
2
3import (
4 "math"
5 "strconv"
6 "strings"
7
8 "gno.land/r/nym-mnsrehearsal001/nsdata/v1"
9)
10
11// Pricing lives here, not in the vault. The vault stores numbers as
12// opaque strings and has no opinion about what they mean, so the whole
13// model can be replaced by swapping this realm.
14//
15// PRICES ARE DENOMINATED IN USD, held as MICRO-USD (1 USD = 1_000_000)
16// so fractions of a cent are expressible — a name that costs about a
17// gumball needs that. Nothing is stored in ugnot: the chain's token is
18// just the settlement rail, converted at payment time from a stored
19// rate. Accepting a second token later is then only a second rate, not
20// a second price list.
21//
22// PRECEDENCE — most specific wins:
23//
24// 1. per-name override (keyNames: "420:5000000,gm:%500")
25// 2. per-length rule (keyLens: "3:2000000,4:%150,5:0")
26// 3. GLOBAL length rule (global keyGlobalLens — see premium.gno)
27// 4. domain base price (keyPrice: "500000" = $0.50)
28// 5. global default (global keyDefaultPrice)
29//
30// then the GLOBAL NAME PREMIUM adjusts the result, and a referral
31// discount comes off last. Both live in premium.gno.
32//
33// A rule value is either an absolute micro-USD amount or "%N", meaning N
34// percent of that domain's base — so repricing a domain once carries
35// every relative rule under it. "0" means free, which is how giveaway
36// tiers are expressed: "all five-character names under *pleb are free"
37// is just a length rule resolving to zero.
38//
39// Rules are stored as CSV in ONE key each rather than one key per rule.
40// The vault caps extension keys at 32 bytes and 24 entries per record; a
41// key like "nm:somelongname" breaks the first and a few dozen premium
42// names break the second. Two CSV keys sidestep both.
43const (
44 keyPrice = "price" // domain base, micro-USD
45 keyLens = "len" // length rules
46 keyNames = "nm" // per-name overrides
47 keyOpen = "open" // "0" closes the domain to new names
48 keyDefaultPrice = "defaultPrice"
49 keyRate = "rateUgnotPerUsd" // ugnot equal to 1 USD
50
51 microUSD = int64(1_000_000)
52 fallbackPriceUSD = int64(500_000) // $0.50
53 // TESTNET PEG: $0.069 per GNOT, so 1 USD = 1/0.069 GNOT = 14.492753
54 // GNOT = 14_492_753 ugnot. Deliberately a fixed number while we are on
55 // a testnet whose token has no market price at all -- a "real" rate
56 // there would be fiction dressed as precision. Replaced with a genuine
57 // conversion before mainnet; tracked in MAINNET.md.
58 fallbackRate = int64(14_492_753)
59 maxRules = 64
60
61 // The most any single USD price setting may say: one million dollars.
62 // Far above any name this registry will list, and low enough that the
63 // arithmetic on it stays far from overflow (see NamePrice).
64 maxPriceUSD = int64(1_000_000) * microUSD
65
66 // The largest "%N" a price rule or premium may carry: a hundredfold.
67 maxRulePct = 10_000
68)
69
70// DomainOpen reports whether names may currently be registered under a
71// domain. An absent key means open — a domain created before this
72// existed must not silently close.
73func DomainOpen(domainLabel string) bool {
74 return nsdata.GetDomainExtra(domainLabel, keyOpen) != "0"
75}
76
77// -- USD side --
78
79func DefaultPriceUSD() int64 {
80 if v := nsdata.GetGlobal(keyDefaultPrice); v != "" {
81 if p, err := strconv.ParseInt(v, 10, 64); err == nil && p >= 0 {
82 return p
83 }
84 }
85 return fallbackPriceUSD
86}
87
88func DomainBasePriceUSD(domainLabel string) int64 {
89 if v := nsdata.GetDomainExtra(domainLabel, keyPrice); v != "" {
90 if p, err := strconv.ParseInt(v, 10, 64); err == nil && p >= 0 {
91 return p
92 }
93 }
94 return DefaultPriceUSD()
95}
96
97// NamePriceUSD resolves the four inputs in precedence order and returns
98// micro-USD. This is the authoritative price; ugnot is derived from it.
99func NamePriceUSD(domainLabel, label string) int64 {
100 return applyPremium(PremiumFor(label), resolveBeforePremium(domainLabel, label))
101}
102
103// resolveBeforePremium runs steps 1-5. Separated from NamePriceUSD so
104// the admin panel can show WHY a price is what it is: the figure before
105// the premium and the figure after are both meaningful, and a single
106// function returning only the total makes a wrong price impossible to
107// diagnose.
108func resolveBeforePremium(domainLabel, label string) int64 {
109 base := DomainBasePriceUSD(domainLabel)
110 if v, ok := lookupRule(nsdata.GetDomainExtra(domainLabel, keyNames), label); ok {
111 return applyRule(v, base)
112 }
113 if v, ok := lookupRule(nsdata.GetDomainExtra(domainLabel, keyLens), strconv.Itoa(len(label))); ok {
114 return applyRule(v, base)
115 }
116 // The global length rule is a floor, not a replacement: it applies
117 // only where the domain itself had nothing to say about this length.
118 if v, ok := GlobalLengthRule(len(label)); ok {
119 return applyRule(v, base)
120 }
121 return base
122}
123
124// ExplainPriceUSD returns each stage of the resolution, so the control
125// panel can show the arithmetic rather than assert a total. Returns the
126// price before any premium, the premium modifier that applied (empty if
127// none), and the final price.
128func ExplainPriceUSD(domainLabel, label string) (beforePremium int64, modifier string, final int64) {
129 before := resolveBeforePremium(domainLabel, label)
130 mod := PremiumFor(label)
131 return before, mod, applyPremium(mod, before)
132}
133
134// -- settlement side --
135
136// RateUgnotPerUsd is how many ugnot one USD buys.
137//
138// This is a MANUALLY MAINTAINED rate, not a market oracle: an admin
139// writes it and it sits there until rewritten. That is a real
140// operational hazard — a stale rate during a sharp move sells names at
141// the wrong price in whichever direction hurts — and the mitigation is
142// keeping it fresh, not anything this code can do. Kept as one global so
143// a future logic realm can swap in a real oracle without touching the
144// vault or restating a single price.
145func RateUgnotPerUsd() int64 {
146 if v := nsdata.GetGlobal(keyRate); v != "" {
147 if r, err := strconv.ParseInt(v, 10, 64); err == nil && r > 0 {
148 return r
149 }
150 }
151 return fallbackRate
152}
153
154// NamePrice converts the USD price to ugnot for settlement. Rounds UP,
155// so rounding never sells a name below its listed price; a free name
156// stays exactly free.
157//
158// IT REFUSES TO OVERFLOW. usd*rate is int64 arithmetic, which wraps
159// silently, and a wrapped product is a price of millions of GNOT or a
160// negative one — and a negative price made takePaymentSplit refund more
161// than was paid (audit L5). A price that cannot be computed is refused,
162// which fails closed: no sale, never a wrong one.
163func NamePrice(domainLabel, label string) int64 {
164 usd := NamePriceUSD(domainLabel, label)
165 if usd <= 0 {
166 return 0
167 }
168 rate := RateUgnotPerUsd()
169 if usd > (math.MaxInt64-microUSD)/rate {
170 panic("nslogic: this name's price does not fit in a ugnot amount; the price list needs fixing")
171 }
172 return (usd*rate + microUSD - 1) / microUSD
173}
174
175// -- rule plumbing --
176
177// applyRule reads a stored rule. An out-of-range one is treated like a
178// malformed one — the base applies — because stored values can arrive by
179// the restore window, unvalidated, and an unbounded percentage would wrap
180// the multiplication into a small, wrong, price.
181func applyRule(v string, base int64) int64 {
182 if strings.HasPrefix(v, "%") {
183 pct, err := strconv.ParseInt(v[1:], 10, 64)
184 if err != nil || pct < 0 || pct > maxRulePct || base > maxPriceUSD {
185 return base
186 }
187 return base * pct / 100
188 }
189 n, err := strconv.ParseInt(v, 10, 64)
190 if err != nil || n < 0 || n > maxPriceUSD {
191 return base
192 }
193 return n
194}
195
196func lookupRule(csv, want string) (string, bool) {
197 if csv == "" {
198 return "", false
199 }
200 for _, pair := range strings.Split(csv, ",") {
201 parts := strings.SplitN(strings.TrimSpace(pair), ":", 2)
202 if len(parts) != 2 {
203 continue
204 }
205 if strings.TrimSpace(parts[0]) == want {
206 return strings.TrimSpace(parts[1]), true
207 }
208 }
209 return "", false
210}
211
212// normalizeRules validates and canonicalises a rule string before it is
213// stored, so a malformed entry can never reach storage and poison every
214// later lookup.
215func normalizeRules(csv string, numericKeys bool) string {
216 if strings.TrimSpace(csv) == "" {
217 return ""
218 }
219 var out []string
220 seen := map[string]bool{}
221 for _, pair := range strings.Split(csv, ",") {
222 pair = strings.TrimSpace(pair)
223 if pair == "" {
224 continue
225 }
226 parts := strings.SplitN(pair, ":", 2)
227 if len(parts) != 2 {
228 panic("nslogic: bad rule, want key:value — got " + pair)
229 }
230 k, v := strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])
231 if k == "" {
232 panic("nslogic: empty rule key")
233 }
234 if numericKeys {
235 if n, err := strconv.Atoi(k); err != nil || n <= 0 {
236 panic("nslogic: length must be a positive number — got " + k)
237 }
238 } else if strings.ContainsAny(k, "*, \t\n") {
239 panic("nslogic: invalid name in rule — " + k)
240 }
241 // Bounded as well as well-formed: an amount above maxPriceUSD or a
242 // percentage above maxRulePct is a typo, and an unbounded one could
243 // overflow the price arithmetic downstream.
244 if strings.HasPrefix(v, "%") {
245 if p, err := strconv.ParseInt(v[1:], 10, 64); err != nil || p < 0 || p > maxRulePct {
246 panic("nslogic: bad percentage (0-" + strconv.Itoa(maxRulePct) + ") — " + v)
247 }
248 } else if n, err := strconv.ParseInt(v, 10, 64); err != nil || n < 0 || n > maxPriceUSD {
249 panic("nslogic: bad amount — " + v)
250 }
251 if seen[k] {
252 panic("nslogic: duplicate rule key — " + k)
253 }
254 seen[k] = true
255 out = append(out, k+":"+v)
256 if len(out) > maxRules {
257 panic("nslogic: too many rules (max 64)")
258 }
259 }
260 return strings.Join(out, ",")
261}