freetier.gno
13.54 Kb · 352 lines
1package nslogic
2
3import (
4 "strconv"
5 "time"
6
7 "gno.land/r/nym-mnsrehearsal001/nsdata/v1"
8)
9
10/*
11THE FREE TIER: a name somebody can hold without ever owning a wallet.
12
13The vault has supported this since it was written — RegisterNameRecord
14takes mintNFT=false and stores a record with no GRC-721 token, and
15UpgradeFreeRecord is described there as "the paid free-tier upgrade".
16Nothing in the logic realm ever reached either. This is that wiring.
17
18WHY A RECORD WITHOUT A TOKEN IS THE RIGHT SHAPE, and not a compromise:
19
20 IT IS A REAL NAME. Owned, resolvable, renewable, on chain, visible
21 to anyone. Not a promise, not a queue position, not a row in our
22 database. Everything the paid tier does except be sold.
23
24 IT CANNOT BE SOLD, and that is what makes giving it away safe.
25 nsmarket escrows a TOKEN, and there is no token — so a squatter who
26 farms a thousand of these has farmed a thousand things nobody can
27 buy from them. The anti-abuse property falls out of the data model
28 rather than out of a rule somebody has to enforce.
29
30 IT BECOMES SELLABLE BY BECOMING PAID. UpgradeFreeName takes the full
31 list price and mints the token. So the same name costs nothing and
32 costs full price, and which one depends entirely on whether you want
33 to sell it.
34
35WHY THE PROJECT PAYS. The whole point is somebody with no GNOT, and
36possibly no wallet, who is going to put this name in front of an
37audience. Measured on Pearl: a registration is about 0.04 GNOT of fee
38plus a small storage deposit, and a free-tier record is smaller than a
39paid one because there is no token and no ledger entry. That is a
40marketing cost per name, bounded by a daily cap we apply off chain,
41where it can be changed without a deploy.
42
43WHAT IS DELIBERATELY NOT HERE. No wordlist: storage is 100 ugnot a byte,
44so a ten-thousand-word dictionary is about 57 GNOT paid forever to
45encode a marketing decision that will change. The contract carries the
46structural rules in reserved.gno and nothing else; the lists live off
47chain with the issuer, which is us, because the free path is granted
48rather than bought. See tools/gen-reserved.py.
49*/
50
51const (
52 keyFreeDomain = "free" // per-domain: "1" means the free tier is open here
53 keyGrantEnd = "grantend" // global: unix seconds, or "never"
54 keyGranter = "granter" // global: the address allowed to issue grants
55)
56
57/*
58THE GRANTER IS NOT THE ADMIN, and that separation is the point.
59
60Free names are issued by a machine — something has to sign the grant
61after a person proves an X account, and that key has to sit somewhere
62warm. If that key were the admin key, a compromise of the issuing
63machine would be a compromise of the registry: DeleteDomain destroys
64every name under a domain and burns their tokens, instantly, with no
65timelock.
66
67So there is a second role that can do exactly one kind of thing: give
68names away on the project's behalf — free-tier grants and renewals, and
69RegisterFor, which spends a holder's own credit into the holder's own
70wallet (credits.gno). Worst case for a stolen granter key is that
71somebody gives away free names, or spends people's credits on names they
72did not pick, until we notice and clear the role — a bounded, reversible,
73embarrassing loss rather than an unbounded one.
74
75Stored in the vault's globals rather than in this realm, so it survives
76a logic redeploy. Unset means nobody but the admin may grant, which is
77the right default for a role that exists to be delegated deliberately.
78*/
79func Granter() address {
80 return address(nsdata.GetGlobal(keyGranter))
81}
82
83// SetGranter delegates grant issuance, or withdraws it. Passing the
84// empty address is the revocation, and it is the first thing to reach
85// for if the issuing machine is ever in doubt — instant, always.
86//
87// NAMING a granter is TIMELOCKED (timelock.gno): a stolen admin key would
88// otherwise hand itself a second, quieter key that gives names away and
89// spends people's credits, and keep it after the admin key is rotated out.
90func SetGranter(cur realm, a address) {
91 assertNoSend(cur)
92 assertIsAdmin(cur)
93 emitAdmin(cur, "SetGranter", "addr", a.String())
94 if a != "" && !a.IsValid() {
95 panic("nslogic: not an address")
96 }
97 if a != "" {
98 requireAction(cur, actionID("SetGranter", a.String()))
99 }
100 setGlobal(cur, keyGranter, a.String())
101}
102
103/*
104SetFreeLengths tunes which lengths the free tier will issue.
105
106In the panel rather than in a redeploy: nslogic is about 22 GNOT to
107deploy, and "should four-character names be free this month" is a
108marketing question that should cost a signature, not a deployment.
109
110IT DOES NOT REACH BACKWARDS. Raising the minimum stops NEW issuance of
111short names; every one already granted stays granted and keeps renewing
112free. FreeLabelAllowed is called by GrantFreeName and by nothing else,
113which is what makes that true — see the note on it in reserved.gno.
114*/
115func SetFreeLengths(cur realm, min, max int) {
116 assertNoSend(cur)
117 assertIsAdmin(cur)
118 emitAdmin(cur, "SetFreeLengths", "min", fmtI(min), "max", fmtI(max))
119 if min < 1 || max > 63 || min > max {
120 panic("nslogic: a free-tier length range must sit inside 1..63 and be the right way round")
121 }
122 setGlobal(cur, keyFreeMin, strconv.Itoa(min))
123 setGlobal(cur, keyFreeMax, strconv.Itoa(max))
124}
125
126func assertMayGrant(cur realm) {
127 caller := cur.Previous().Address()
128 if caller == nsdata.GetAdmin() {
129 return
130 }
131 g := Granter()
132 if g == "" || caller != g {
133 panic("nslogic: only the admin or the granter may issue free names")
134 }
135}
136
137// DomainFree reports whether a domain accepts free-tier registrations.
138// Off by default, and per-domain on purpose: the free tier is a
139// promotion aimed at a few domains, not a property of the registry.
140func DomainFree(domainLabel string) bool {
141 return nsdata.GetDomainExtra(domainLabel, keyFreeDomain) == "1"
142}
143
144// SetDomainFree opens or closes the free tier for one domain.
145func SetDomainFree(cur realm, domainLabel string, on bool) {
146 assertNoSend(cur)
147 assertIsAdmin(cur)
148 emitAdmin(cur, "SetDomainFree", "label", domainLabel, "on", fmtBool(on))
149 if !nsdata.DomainExists(domainLabel) {
150 panic("nslogic: no such domain")
151 }
152 v := ""
153 if on {
154 v = "1"
155 }
156 if err := nsdata.SetDomainExtraRecord(cross(cur), domainLabel, keyFreeDomain, v); err != nil {
157 panic(err)
158 }
159}
160
161/*
162GrantWindowOpen reports whether free grants are being issued at all.
163
164A single global switch that closes everything at once, because the
165failure this guards against is not a clever attack — it is noticing on a
166Sunday that something is wrong and wanting one call that stops it. An
167unset value means open; "never" means open with no end date, which is
168what the admin panel already writes.
169*/
170func GrantWindowOpen() bool {
171 return readGrantWindow(nsdata.GetGlobal(keyGrantEnd), time.Now().Unix())
172}
173
174// readGrantWindow is GrantWindowOpen with the clock and the stored value
175// handed in, so the reading can be tested without a chain.
176func readGrantWindow(v string, now int64) bool {
177 if v == "" || v == "never" {
178 return true
179 }
180 end, err := strconv.ParseInt(v, 10, 64)
181 if err != nil {
182 // An unparseable window is a misconfiguration, and the safe
183 // reading of a misconfigured gate is "closed". Giving names away
184 // because a setting was mistyped is the wrong way to fail.
185 return false
186 }
187 return now < end
188}
189
190/*
191GrantFreeName registers a free-tier record to somebody else.
192
193ADMIN OR GRANTER, and that is the whole trust model: the eligibility rules —
194which Twitter account, how old, how many followers, whether the label is
195a dictionary word — live off chain with the issuer. Putting them here
196would freeze a marketing policy into a realm and cost storage to do it.
197What the chain enforces is the part that must not depend on our
198correctness: the label is structurally allowed, the domain opted in, and
199the window is open.
200
201`owner` may be an address the recipient generated in their browser
202seconds ago and has never funded. That is the point.
203*/
204func GrantFreeName(cur realm, label, domainLabel string, owner address) {
205 assertNoSend(cur)
206 assertMayGrant(cur)
207 emitAdmin(cur, "GrantFreeName", "label", label, "domain", domainLabel, "owner", owner.String())
208 assertNotRestoring()
209
210 if !GrantWindowOpen() {
211 panic("nslogic: free grants are closed")
212 }
213 // Recovery's quarantine holds every giveaway shut, whatever the window
214 // says (nsdata incident.gno, re-audit 2026-10-16, M-2).
215 if q := nsdata.QuarantineUntil(); time.Now().Unix() < q {
216 panic("nslogic: recovery has quarantined giveaways until unix " + strconv.FormatInt(q, 10))
217 }
218 if !DomainFree(domainLabel) {
219 panic("nslogic: *" + domainLabel + " does not offer free names")
220 }
221 if !DomainOpen(domainLabel) {
222 panic("nslogic: this domain is not accepting new names")
223 }
224 validateLabel(label)
225 assertNotBlocked(label)
226 // The structural backstop. The issuer has already applied the full
227 // policy; this is what stops a bug there giving away a four-letter
228 // name or a pure-numeric one.
229 if !FreeLabelAllowed(label) {
230 panic("nslogic: " + label + " is not available on the free tier")
231 }
232 if !owner.IsValid() {
233 panic("nslogic: a grant needs a recipient")
234 }
235
236 // paid=false — a token minted with the free-tier hold already on it,
237 // so it can never be approved or sold until UpgradeFreeName lifts it.
238 if err := nsdata.RegisterNameRecord(cross(cur), label, domainLabel, owner,
239 time.Now().Unix(), false); err != nil {
240 panic(err)
241 }
242}
243
244/*
245UpgradeFreeName mints the token for a free-tier record, at full price.
246
247This is the only way a free name becomes sellable, and the price is the
248ordinary list price with no referral discount — a discount is for
249bringing somebody new, and this person is already here.
250
251`newOwner` exists because the common case is somebody who has decided to
252sell and, in the same moment, wants the name in a wallet they actually
253control rather than the browser key they were given. The vault handles
254that move inside UpgradeFreeRecord.
255
256Callable by the record's current owner only. Anyone may PAY for
257somebody else's upgrade in the sense that they can hand over the money,
258but they cannot perform it — a stranger promoting your name into a token
259and choosing where it lands is a theft with a receipt.
260*/
261func UpgradeFreeName(cur realm, label, domainLabel string, newOwner address) {
262 // Not during a restore window: the price rules may not be back yet, and
263 // the name would be made sellable at the genesis price (restore.gno).
264 assertNotRestoring()
265 caller := cur.Previous().Address()
266 if nsdata.GetNameOwner(label, domainLabel) != caller {
267 panic("nslogic: restricted to name owner")
268 }
269 if !nsdata.IsNameValid(label, domainLabel) {
270 panic("nslogic: this name has expired")
271 }
272 if newOwner == "" {
273 newOwner = caller
274 }
275 if !newOwner.IsValid() {
276 panic("nslogic: invalid destination address")
277 }
278
279 // Full list price, resolved now rather than at grant time — the
280 // name's worth is what it is worth today, and a free name held for a
281 // year should not be upgradable at last year's rate.
282 takePaymentSplit(cur, NamePrice(domainLabel, label), "", 0)
283
284 if err := nsdata.UpgradeFreeRecord(cross(cur), label, domainLabel, newOwner); err != nil {
285 panic(err)
286 }
287}
288
289/*
290GrantFreeRenewal extends a free-tier name at no charge.
291
292THE DEAL IS THE DISPLAY, and it is checked off chain: the issuer signs
293somebody in on X, reads their display name, and finds the name in it.
294That single act proves both halves at once — that a real account still
295exists, and that it is still carrying the name — which is why renewal is
296one click a year rather than a form.
297
298FREE-TIER RECORDS ONLY. A paid name renewed for nothing is revenue given
299away by accident, and the two are told apart by whether a token was ever
300minted — now, whether the token still carries the free-tier hold. The
301hold is the registry's flag, read through GetNameInfo's `paid`.
302
303NO OWNERSHIP PROOF IS ASKED FOR, and that is deliberate rather than
304missing. Renewing somebody else's name is a GIFT — it takes nothing,
305moves nothing, and grants the caller no power over it. The only cost of
306allowing it is ours, and the vault's renewal cap already bounds how far
307any single name can be pushed. A proof requirement here would buy
308nothing and would cost the one-click renewal that makes the deal work.
309
310RenewNameRecord insists the caller is the owner, so the owner is looked
311up and passed. That is not a bypass: this realm is the trusted logic,
312and what it is asserting is exactly true — this renewal is on that
313owner's behalf.
314*/
315func GrantFreeRenewal(cur realm, label, domainLabel string) {
316 assertNoSend(cur)
317 assertMayGrant(cur)
318 emitAdmin(cur, "GrantFreeRenewal", "label", label, "domain", domainLabel)
319 // A free renewal is a giveaway too: not during a restore window, and not
320 // while recovery's quarantine holds (review follow-up).
321 assertNotRestoring()
322 if q := nsdata.QuarantineUntil(); time.Now().Unix() < q {
323 panic("nslogic: recovery has quarantined giveaways until unix " + strconv.FormatInt(q, 10))
324 }
325
326 if !nsdata.NameExists(label, domainLabel) {
327 panic("nslogic: no such name")
328 }
329 owner, _, _, _, _, paid := nsdata.GetNameInfo(label, domainLabel)
330 if paid {
331 panic("nslogic: " + label + "*" + domainLabel +
332 " is a paid name — free renewal is for free-tier records")
333 }
334 if RenewalsLeft(label, domainLabel) <= 0 {
335 panic("nslogic: this name is already paid as far ahead as a name may run")
336 }
337 if err := nsdata.RenewNameRecord(cross(cur), label, domainLabel, owner,
338 time.Now().Unix()); err != nil {
339 panic(err)
340 }
341}
342
343// IsFreeTier reports whether a name is a record without a token — the
344// free tier. Exported because the site and the issuer both need to ask
345// before offering a free renewal, and neither should infer it.
346func IsFreeTier(label, domainLabel string) bool {
347 if !nsdata.NameExists(label, domainLabel) {
348 return false
349 }
350 _, _, _, _, _, paid := nsdata.GetNameInfo(label, domainLabel)
351 return !paid
352}