Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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}