credits.gno
16.88 Kb · 451 lines
1package nslogic
2
3import (
4 "chain"
5 "strconv"
6 "strings"
7 "time"
8
9 "gno.land/r/nym-mnsrehearsal001/nsdata/v1"
10)
11
12/*
13CREDITS: a free registration, granted to an address ahead of time.
14
15WHY. Two plans need it (MAINNET.md §0):
16 - the migration grant: every wallet that registered a name on testnet
17 gets one free name on mainnet, of its own choosing;
18 - onboarding: somebody who holds no GNOT at all is given a name, and
19 the project registers it FOR them.
20
21WHERE IT LIVES. In the vault's per-address store, never in this realm, so
22a logic swap neither loses nor repeats anybody's credit — and not in
23globals, which cannot hold an address-sized key at all.
24
25THE RECORD is one field, "cr", holding "granted,used,expires,held,at":
26
27 granted credits issued in total. A grant SETS this number, it never
28 adds to it, so re-running a grant batch after people have
29 redeemed cannot hand anybody a second name;
30 used credits redeemed;
31 expires the unix second they lapse, 0 for none (the claim window
32 still bounds every credit);
33 held 1: the name is minted with the free-tier hold — usable and
34 renewable, not sellable until upgraded at list price. Only a
35 grant made with sellable=false writes 1;
36 at the unix second of the grant (re-audit 2026-10-16, M-2), so
37 recovery can void what a stolen admin key granted
38 (nsdata.VoidGrantsSince) and the honest admin can grant again
39 what was legitimate. A record written before this field reads
40 as granted at 0.
41
42AN INCIDENT CAN UNDO A GRANT. What a thief granted used to outlive its
43eviction: the credits live in the vault, and nothing could touch them while
44the logic was revoked and the registry paused, so the thief's wallets
45redeemed in the block that unpaused it. Now the vault's incident switches
46(nsdata incident.gno) hold: no credit is spent while the registry is
47paused, while recovery's quarantine runs, or once recovery has voided the
48time it was granted in; and the guardian's Disarm shuts the claim window.
49
50SELLABLE BY DEFAULT (Ely, 2026-10-16). A credit buys a real name, minted
51like a paid one and free to sell, unless the grant says otherwise: the
52per-grant flag is `sellable`, and every caller passes true unless it means
53to hand out a held name. The record keeps the hold bit, so its stored form
54did not change; the API speaks in the direction the default runs.
55
56WHO PAYS THE STORAGE DEPOSIT. Whoever SIGNS, and nobody else can. The
57chain charges a call's storage deposit and its gas to the transaction's
58signer (gno.land/pkg/sdk/vm keeper.go, lockStorageDeposit(ctx, caller,
59...)); a realm cannot pay either on somebody's behalf, and this one holds
60no coins to try. So there are two doors, and both spend the holder's
61credit:
62
63 RedeemCredit the holder signs. The name costs nothing; the holder
64 pays the storage deposit (about 0.57 GNOT) and gas.
65 RegisterFor the admin or the granter signs and the project pays
66 both; the name goes to the holder. This is the only
67 path for a holder with no GNOT, who cannot sign at all.
68
69WHAT A CREDIT BUYS. One ordinary registration at no price: any open
70domain, any label the ordinary rules allow, up to a list price cap
71(ClaimMaxPriceUSD) — the on-chain answer to MAINNET.md's "do premium names
72count?", left settable rather than decided here. UNSET, THE CAP COVERS
73NOTHING and no credit redeems (re-audit 2026-10-16, H-1): it used to fall
74back to the default name price, and the default rises fast. The deploy
75sets it explicitly (tools/deploy-gen.sh, CLAIMMAX).
76
77GRANTED BY THE ADMIN, AND NOT TIMELOCKED, deliberately. A credit can only
78create a registration of a name nobody holds: it never touches an
79existing holder, moves no money, works only inside the claim window and
80only under the price cap. A stolen admin key can gift itself cheap names
81until it is rotated out — the same order of harm as the free tier, and
82far smaller than anything the timelock guards. A 48-hour wait on every
83grant would make "register it for them now" impossible.
84*/
85
86const (
87 keyCredit = "cr" // account field: the record above
88 keyClaimEnd = "claimend" // global: unix second the claim window closes; unset means closed
89 keyClaimMax = "claimmax" // global: highest list price, micro-USD, a credit covers
90
91 maxCreditsPerAddress = 100
92 maxGrantBatch = 100
93 maxClaimWindowDays = 730 // one setting cannot promise more than two years
94)
95
96type credit struct {
97 granted, used, expires int64
98 held bool
99 at int64 // when granted; 0 for a record from before the field
100}
101
102// parseCredit FAILS CLOSED: anything it cannot read is no credit at all.
103// A malformed record must never be read as a free name. Four fields is the
104// record before the grant time existed; five carries it.
105func parseCredit(v string) credit {
106 if v == "" {
107 return credit{}
108 }
109 f := strings.Split(v, ",")
110 if (len(f) != 4 && len(f) != 5) || (f[3] != "0" && f[3] != "1") {
111 return credit{}
112 }
113 g, e1 := strconv.ParseInt(f[0], 10, 64)
114 u, e2 := strconv.ParseInt(f[1], 10, 64)
115 x, e3 := strconv.ParseInt(f[2], 10, 64)
116 if e1 != nil || e2 != nil || e3 != nil || g < 0 || u < 0 || x < 0 {
117 return credit{}
118 }
119 c := credit{granted: g, used: u, expires: x, held: f[3] == "1"}
120 if len(f) == 5 {
121 at, e4 := strconv.ParseInt(f[4], 10, 64)
122 if e4 != nil || at < 0 {
123 return credit{}
124 }
125 c.at = at
126 }
127 return c
128}
129
130func (c credit) encode() string {
131 h := "0"
132 if c.held {
133 h = "1"
134 }
135 return strconv.FormatInt(c.granted, 10) + "," + strconv.FormatInt(c.used, 10) + "," +
136 strconv.FormatInt(c.expires, 10) + "," + h + "," + strconv.FormatInt(c.at, 10)
137}
138
139// voided reports whether recovery voided the time this credit was granted.
140func (c credit) voided() bool { return c.granted > c.used && nsdata.GrantVoided(c.at) }
141
142// available is how many may be redeemed at `now`.
143func (c credit) available(now int64) int64 {
144 if c.expires != 0 && now >= c.expires {
145 return 0
146 }
147 if c.used >= c.granted {
148 return 0
149 }
150 return c.granted - c.used
151}
152
153func readCredit(owner address) credit { return parseCredit(nsdata.GetAccount(owner, keyCredit)) }
154
155func writeCredit(cur realm, owner address, c credit) {
156 v := c.encode()
157 // Nothing granted and nothing used is no record: free the storage.
158 // A record with redemptions stays, because `used` is what stops a
159 // repeated grant from handing out a second name.
160 if c.granted == 0 && c.used == 0 {
161 v = ""
162 }
163 if err := nsdata.SetAccountRecord(cross(cur), owner, keyCredit, v); err != nil {
164 panic("nslogic: " + err.Error())
165 }
166}
167
168// CreditsOf reports an address's credits: how many it may redeem right
169// now, and the record behind that answer. `sellable` is whether the names
170// they buy come without the free-tier hold — true unless the grant said not.
171// Credits recovery voided are not available; the window, a pause and a
172// quarantine are the registry's state, reported by CreditRefusal.
173func CreditsOf(owner address) (available, granted, used, expires int64, sellable bool) {
174 c := readCredit(owner)
175 a := c.available(time.Now().Unix())
176 if c.voided() {
177 a = 0
178 }
179 return a, c.granted, c.used, c.expires, !c.held
180}
181
182// CreditGrantedAt is when an address's credits were last granted (unix
183// seconds; 0 for none, or for a record from before the field existed).
184func CreditGrantedAt(owner address) int64 { return readCredit(owner).at }
185
186// -- the window and the cap --
187
188// ClaimWindowEnd is the unix second redemption closes, or 0 when no
189// window is set — which means CLOSED. An entitlement nobody bounded is a
190// liability that never ends, so the default is no window at all.
191func ClaimWindowEnd() int64 {
192 v := nsdata.GetGlobal(keyClaimEnd)
193 if v == "" {
194 return 0
195 }
196 end, err := strconv.ParseInt(v, 10, 64)
197 if err != nil || end < 0 {
198 return 0
199 }
200 return end
201}
202
203func ClaimWindowOpen() bool { return readClaimWindow(nsdata.GetGlobal(keyClaimEnd), time.Now().Unix()) }
204
205// readClaimWindow fails closed on anything it cannot read, the same rule
206// the free tier's window follows: a mistyped setting must not give names away.
207func readClaimWindow(v string, now int64) bool {
208 if v == "" {
209 return false
210 }
211 end, err := strconv.ParseInt(v, 10, 64)
212 if err != nil {
213 return false
214 }
215 return now < end
216}
217
218// SetClaimWindow opens redemption until `end` (unix seconds), at most two
219// years ahead, or closes it with 0. Closing is the one call to reach for
220// on a Sunday when something about the grants looks wrong.
221func SetClaimWindow(cur realm, end int64) {
222 assertNoSend(cur)
223 assertIsAdmin(cur)
224 emitAdmin(cur, "SetClaimWindow", "end", fmtInt(end))
225 if end == 0 {
226 setGlobal(cur, keyClaimEnd, "")
227 return
228 }
229 now := time.Now().Unix()
230 if end <= now || end > now+maxClaimWindowDays*86400 {
231 panic("nslogic: a claim window ends in the future and at most " +
232 strconv.Itoa(maxClaimWindowDays) + " days ahead; 0 closes it")
233 }
234 setGlobal(cur, keyClaimEnd, strconv.FormatInt(end, 10))
235}
236
237/*
238ClaimMaxPriceUSD is the highest list price, in micro-USD, a credit can be
239spent on, or 0 while no cap is set — and while none is set no credit
240redeems at all (ClaimCapSet).
241
242IT NEVER FOLLOWS THE DEFAULT PRICE (re-audit 2026-10-16, H-1). It used to
243fall back to DefaultPriceUSD while unset, which is how a registry is born.
244The default moves fast in its "dearer" direction — four times, every ten
245minutes, instantly — and premium prices do not move with it, so each
246dearer step on the default carried the cap past another tier of premium
247names: a stolen admin key took the $300 x*doge with a free credit in forty
248minutes, never touching SetClaimMaxPrice, whose raise is meant to be the
249slow step. So the cap stands on its own now, an unset or unreadable one
250covers nothing, and the first cap is a decision (SetClaimMaxPrice).
251*/
252func ClaimMaxPriceUSD() int64 {
253 p, _ := claimCap()
254 return p
255}
256
257// ClaimCapSet reports whether a cap is set at all. Unset — or unreadable,
258// which is read the same way — no credit can be spent on anything.
259func ClaimCapSet() bool {
260 _, ok := claimCap()
261 return ok
262}
263
264func claimCap() (int64, bool) {
265 v := nsdata.GetGlobal(keyClaimMax)
266 if v == "" {
267 return 0, false
268 }
269 p, err := strconv.ParseInt(v, 10, 64)
270 if err != nil || p < 0 {
271 return 0, false
272 }
273 return p, true
274}
275
276// SetClaimMaxPrice is BOUNDED like a price (timelock.gno), with the
277// direction turned round: RAISING the cap is what makes names cheaper — a
278// credit then covers pricier names, premium ones in the end — so a raise is
279// the slow step (25%, once an hour) and a cut the fast one (down to a
280// quarter, every ten minutes). Anything else is a timelocked proposal.
281//
282// THE FIRST CAP IS NOT A STEP. From no cap there is nothing to step from,
283// so setting one is always the timelocked proposal: instant at a fresh
284// vault's delay of zero, which is when the deploy sets it, and recovery's
285// to veto once the delay is raised.
286func SetClaimMaxPrice(cur realm, microUsd int64) {
287 assertNoSend(cur)
288 assertIsAdmin(cur)
289 emitAdmin(cur, "SetClaimMaxPrice", "microUsd", fmtInt(microUsd))
290 if microUsd < 0 || microUsd > maxPriceUSD {
291 panic("nslogic: the claim cap must be 0.." + strconv.FormatInt(maxPriceUSD, 10) + " micro-USD")
292 }
293 v := strconv.FormatInt(microUsd, 10)
294 id := actionID("SetClaimMaxPrice", v)
295 old, set := claimCap()
296 if !set {
297 requireAction(cur, id)
298 // A cap from nothing is the cheaper direction for claimants: the
299 // next raise waits the cheap clock's hour like any other.
300 lastTweak.Set(clockKey(keyClaimMax, false), time.Now().Unix())
301 } else {
302 authorizeTweak(cur, keyClaimMax, old, microUsd, microUsd < old, id)
303 }
304 setGlobal(cur, keyClaimMax, v)
305}
306
307// -- granting --
308
309// GrantCredits SETS how many free registrations `to` is entitled to in
310// total, with an optional expiry (0 for none) and whether the names they
311// buy are SELLABLE — true is the default to pass; false mints them with
312// the free-tier hold. 0 revokes whatever is unredeemed. See the note at
313// the top of this file on why a grant sets rather than adds.
314func GrantCredits(cur realm, to address, n, expires int64, sellable bool) {
315 assertNoSend(cur)
316 assertIsAdmin(cur)
317 emitAdmin(cur, "GrantCredits", "to", to.String(), "n", fmtInt(n), "expires", fmtInt(expires), "sellable", fmtBool(sellable))
318 grantCredits(cur, to, n, expires, sellable, time.Now().Unix())
319}
320
321// GrantCreditsBatch is GrantCredits for a comma-separated list of
322// addresses, up to 100 a call — the migration grant is one entry per
323// testnet wallet. Returns how many were written. One bad address fails
324// the whole batch, so a typo cannot silently leave somebody out.
325func GrantCreditsBatch(cur realm, addrs string, n, expires int64, sellable bool) int {
326 assertNoSend(cur)
327 assertIsAdmin(cur)
328 emitAdmin(cur, "GrantCreditsBatch", "addrs", addrs, "n", fmtInt(n), "expires", fmtInt(expires), "sellable", fmtBool(sellable))
329 parts := strings.Split(addrs, ",")
330 if len(parts) > maxGrantBatch {
331 panic("nslogic: at most " + strconv.Itoa(maxGrantBatch) + " addresses per batch")
332 }
333 now := time.Now().Unix()
334 done := 0
335 for _, p := range parts {
336 p = strings.TrimSpace(p)
337 if p == "" {
338 continue
339 }
340 grantCredits(cur, address(p), n, expires, sellable, now)
341 done++
342 }
343 return done
344}
345
346func grantCredits(cur realm, to address, n, expires int64, sellable bool, now int64) {
347 if !to.IsValid() {
348 panic("nslogic: not an address: " + to.String())
349 }
350 if n < 0 || n > maxCreditsPerAddress {
351 panic("nslogic: a grant is 0.." + strconv.Itoa(maxCreditsPerAddress) + " credits")
352 }
353 if expires != 0 && expires <= now {
354 panic("nslogic: credits that have already expired are not a grant; pass 0 for no expiry")
355 }
356 c := readCredit(to)
357 c.granted, c.expires, c.held, c.at = n, expires, !sellable, now
358 writeCredit(cur, to, c)
359}
360
361// -- redeeming --
362
363// RedeemCredit registers label*domain to the caller and spends one of the
364// caller's credits. The caller signs, so the caller pays the storage
365// deposit and gas; the name itself costs nothing.
366func RedeemCredit(cur realm, label, domainLabel string) {
367 assertNoSend(cur)
368 redeemCredit(cur, cur.Previous().Address(), label, domainLabel)
369}
370
371// RegisterFor registers label*domain to `owner` and spends one of
372// OWNER'S credits — the project signs and pays the storage and gas, which
373// is the only way a holder with no GNOT can get a name at all. Admin or
374// granter: the same machine that issues free names relays these, and it
375// can spend only credits the admin granted, only into the holder's own
376// wallet.
377func RegisterFor(cur realm, owner address, label, domainLabel string) {
378 assertNoSend(cur)
379 assertMayGrant(cur)
380 emitAdmin(cur, "RegisterFor", "owner", owner.String(), "label", label, "domain", domainLabel)
381 if !owner.IsValid() {
382 panic("nslogic: a registration needs a recipient")
383 }
384 redeemCredit(cur, owner, label, domainLabel)
385}
386
387// CreditRefusal is why `owner` could not spend a credit on label*domain
388// right now, or "" when nothing about the credit stands in the way. For
389// the site, before anybody signs; the label rules are checked separately.
390func CreditRefusal(owner address, label, domainLabel string) string {
391 return creditRefusal(owner, label, domainLabel, time.Now().Unix())
392}
393
394func creditRefusal(owner address, label, domainLabel string, now int64) string {
395 if nsdata.IsPaused() {
396 return "the registry is paused"
397 }
398 if q := nsdata.QuarantineUntil(); now < q {
399 return "recovery has quarantined giveaways until unix " + strconv.FormatInt(q, 10)
400 }
401 if !readClaimWindow(nsdata.GetGlobal(keyClaimEnd), now) {
402 return "the claim window is closed"
403 }
404 c := readCredit(owner)
405 if c.available(now) <= 0 {
406 if c.granted > c.used && c.expires != 0 && now >= c.expires {
407 return "this address's credits have expired"
408 }
409 return "this address has no credit to spend"
410 }
411 if c.voided() {
412 return "recovery voided the credits granted to this address at unix " + strconv.FormatInt(c.at, 10) +
413 "; the admin may grant them again"
414 }
415 most, set := claimCap()
416 if !set {
417 return "credits are off: no claim cap is set, and an unset cap covers nothing"
418 }
419 if p := NamePriceUSD(domainLabel, label); p > most {
420 return "a credit covers names listed at up to " + strconv.FormatInt(most, 10) +
421 " micro-USD, and this one lists at " + strconv.FormatInt(p, 10)
422 }
423 return ""
424}
425
426/*
427redeemCredit is both doors. Every check runs before anything is written,
428the registration lands before the credit is spent, and either failing
429reverts the other: the credit cannot be spent on a name that did not
430arrive, and a name cannot arrive without its credit being spent.
431*/
432func redeemCredit(cur realm, owner address, label, domainLabel string) {
433 assertNotRestoring()
434 validateLabel(label)
435 assertNotBlocked(label)
436 if !DomainOpen(domainLabel) {
437 panic("nslogic: this domain is not accepting new names")
438 }
439 now := time.Now().Unix()
440 if r := creditRefusal(owner, label, domainLabel, now); r != "" {
441 panic("nslogic: " + r)
442 }
443 c := readCredit(owner)
444 if err := nsdata.RegisterNameRecord(cross(cur), label, domainLabel, owner, now, !c.held); err != nil {
445 panic(err)
446 }
447 c.used++
448 writeCredit(cur, owner, c)
449 chain.Emit("CreditRedeemed", "tid", label+"*"+domainLabel, "owner", owner.String(),
450 "by", cur.Previous().Address().String())
451}