referral.gno
18.58 Kb · 492 lines
1package nslogic
2
3import (
4 "strconv"
5 "strings"
6 "time"
7
8 "gno.land/r/nym-mnsrehearsal001/nsdata/v1"
9)
10
11// The referral programme.
12//
13// THE RULES, decided by the owner on 2026-10-16 and bounded in code:
14//
15// ONE FLAT TIER. A referrer's cut and a buyer's discount are two fixed
16// percentages. Nothing escalates with how old a name is or how long
17// its holder has been here: tenure could be bought (an old name keeps
18// its registration date through a sale), and an escalating table was
19// the reason a second wallet's code was worth up to 37% off.
20//
21// AT MOST 10% TOGETHER. Cut plus discount never exceeds maxRefPercent,
22// whatever an admin stores; SetReferralRules refuses more, and the
23// reader clamps anything stored before. An admin can lower it, never
24// raise it.
25//
26// FIRST YEAR ONLY. The cut and the discount apply to the registration,
27// which buys the first term. Renewals pay full price and pay nobody.
28//
29// A CODE MUST HAVE BEEN HELD. A code earns only when it names a PAID
30// (not free-tier) name that its holder has kept for refHoldDays, by
31// the registry's own holding clock (nsdata.GetNameHeldSince). A wallet
32// made this morning, with a name bought this morning, is no referrer.
33//
34// What that does to the second-wallet trick: a buyer who quotes the code
35// of a second wallet they control saves the discount and recovers the cut
36// — at most 10% of the price, after waiting thirty days with a paid name
37// in that wallet. TestASecondWalletEarnsAtMostTenPercent pins the number.
38//
39// SINGLE LEVEL, ALWAYS. One buyer, one referrer, one cut, taken out of
40// an actual sale. There is no downline, no override on somebody else's
41// referrals, and no bonus for recruiting other referrers — and there is
42// no code here that could be extended into one without being obvious
43// about it. That is the line between an affiliate programme and the
44// other thing, and this realm stays on the near side of it.
45//
46// WHERE THE LINK LIVES. The referrer is recorded on the REFERRED NAME,
47// in its extension slot, at the moment it is registered — a record of
48// who brought the buyer, nothing that pays out any more. The vault
49// destroys a name's extras when a lapsed name is re-registered and hides
50// them the moment it changes hands, so a name that lapses or is sold
51// forgets its referrer with no code here to make it so.
52//
53// WHO GETS PAID. The referrer's own address, fixed, at the moment of the
54// sale (see PayoutFor). Nothing accrues and nothing is owed later: the
55// cut is paid inside the registration or not at all.
56//
57// SELF-REFERRAL IS REFUSED. Otherwise the informed apply their own code
58// and the cut becomes a silent discount for people who read the source.
59//
60// EVERYTHING SETTLES IN ONE TRANSACTION. Buyer pays, treasury is paid,
61// referrer is paid, overpayment goes back — all inside the message the
62// buyer signed. No escrow, no float, not for a block.
63const (
64 keyRef = "ref" // on a NAME: the referrer's address
65 keyRefCut = "refcut" // global: the referrer's percentage, stored "0:N"
66 keyRefDisc = "refdisc" // global: the buyer's discount, stored "0:N"
67 keyRefCount = "rc" // ACCOUNT field (nsdata's per-address store): how many referred
68 keyRefWin = "refwin" // global: days a captured code stays valid
69
70 maxRefPercent = 10 // cut plus discount, together; an admin may lower it, never exceed it
71 refHoldDays = 30 // a code earns only once its holder has kept a paid name this long
72)
73
74// -- the rates --
75
76// tierPercent reads a stored rate. The canonical form is a one-tier table
77// at year zero, "0:N", which every reader of the old escalating tables —
78// the site among them — reads as N at any tenure. A legacy multi-tier
79// table counts only at year zero (RefRates asks for nothing else), so a
80// table that once escalated cannot escalate now. Junk entries are
81// skipped, not fatal, so one bad character cannot stop every sale; a
82// percentage above the cap is clamped.
83func tierPercent(spec string, years int64) int64 {
84 best := int64(0)
85 bestAt := int64(-1) // the threshold of the tier `best` came from
86 for _, part := range strings.Split(spec, ",") {
87 kv := strings.SplitN(strings.TrimSpace(part), ":", 2)
88 if len(kv) != 2 {
89 continue
90 }
91 at, err1 := strconv.ParseInt(strings.TrimSpace(kv[0]), 10, 64)
92 pct, err2 := strconv.ParseInt(strings.TrimSpace(kv[1]), 10, 64)
93 if err1 != nil || err2 != nil || at < 0 || pct < 0 {
94 continue
95 }
96 if pct > maxRefPercent {
97 pct = maxRefPercent
98 }
99 // The highest tier that applies, not the biggest number in the
100 // table, so a table that tapers means what it reads as.
101 if years >= at && at >= bestAt {
102 bestAt = at
103 best = pct
104 }
105 }
106 return best
107}
108
109// RefRates is the flat cut and discount in force, in percent, clamped so
110// that together they never exceed maxRefPercent whatever is stored. The
111// discount is kept and the cut gives way: what a buyer was quoted is the
112// promise, the cut is the treasury's to shrink.
113func RefRates() (cutPct, discountPct int64) {
114 return clampRates(tierPercent(RefCutRules(), 0), tierPercent(RefDiscountRules(), 0))
115}
116
117// clampRates is the 10% ceiling on its own, so it can be tested without a
118// vault: negative inputs are zero, the discount is capped first, and the
119// cut takes whatever room is left.
120func clampRates(cut, disc int64) (int64, int64) {
121 if cut < 0 {
122 cut = 0
123 }
124 if disc < 0 {
125 disc = 0
126 }
127 if disc > maxRefPercent {
128 disc = maxRefPercent
129 }
130 if cut > maxRefPercent-disc {
131 cut = maxRefPercent - disc
132 }
133 return cut, disc
134}
135
136// THE PRESET THE PROJECT LAUNCHES WITH: five percent to the referrer, five
137// off for the buyer — the whole 10% the owner allowed, split evenly.
138// Written here rather than typed into the admin panel at launch because a
139// number that exists only in somebody's memory of a conversation gets set
140// wrong once and noticed a month later. It is a DEFAULT, not a policy:
141// SetReferralRules overwrites it, and empty rates switch the programme off.
142const (
143 defaultRefCut = "0:5"
144 defaultRefDiscount = "0:5"
145)
146
147// DefaultReferralRules lets the site show the presets before anybody
148// commits them.
149func DefaultReferralRules() (cut, discount string) {
150 return defaultRefCut, defaultRefDiscount
151}
152
153// SeedReferralRules writes the presets, and ONLY into empty rates. Not an
154// init(): at init a fresh logic realm is not yet trusted by the vault.
155// Refusing to overwrite makes it safe to call twice and safe to call by
156// mistake.
157func SeedReferralRules(cur realm) {
158 assertNoSend(cur)
159 assertIsAdmin(cur)
160 if nsdata.GetGlobal(keyRefCut) != "" || nsdata.GetGlobal(keyRefDisc) != "" {
161 panic("nslogic: referral rules are already set; use SetReferralRules to change them")
162 }
163 SetReferralRules(cur, defaultRefCut, defaultRefDiscount)
164}
165
166func RefCutRules() string { return nsdata.GetGlobal(keyRefCut) }
167func RefDiscountRules() string { return nsdata.GetGlobal(keyRefDisc) }
168
169// RefWindowDays is how long a clicked link keeps counting for. It is
170// enforced by the browser that captured the code, not by this realm —
171// the chain never sees the click, only the registration that quotes a
172// code — so it is stored here purely to be ONE number rather than a
173// constant baked separately into the site, the FAQ and the rules page.
174// Zero means the default; the site treats an absent value the same way.
175func RefWindowDays() int64 {
176 d := parseInt64(nsdata.GetGlobal(keyRefWin))
177 if d <= 0 {
178 return 30
179 }
180 return d
181}
182
183func SetReferralWindow(cur realm, days int64) {
184 assertNoSend(cur)
185 assertIsAdmin(cur)
186 emitAdmin(cur, "SetReferralWindow", "days", fmtInt(days))
187 if days < 0 || days > 3650 {
188 panic("nslogic: window must be 0-3650 days")
189 }
190 setGlobal(cur, keyRefWin, strconv.FormatInt(days, 10))
191}
192
193// ReferralQuote is what a code is worth, answered before anybody signs
194// anything. One call rather than four, because the site needs all of it
195// at once to show a price.
196//
197// A referrer of "" means the code does not earn — it did not resolve, it
198// is the buyer's own, it names a free-tier name, or its holder has not
199// kept it for refHoldDays — and both percentages are then zero. `tenure`
200// is the referrer's years held, for display only: it no longer moves
201// either percentage.
202func ReferralQuote(refName string, buyer address) (referrer address, tenure, discountPct, cutPct int64) {
203 ref := resolveReferrer(refName, buyer)
204 if ref == "" {
205 return "", 0, 0, 0
206 }
207 cut, disc := RefRates()
208 return ref, TenureYears(ref), disc, cut
209}
210
211// SetReferralRules sets both rates at once, because setting one without
212// the other is how a cut ends up larger than the margin. Each is "N" or
213// "0:N" (percent), or empty for none; two empties switch the programme
214// off. Together they may not exceed maxRefPercent. A multi-tier table is
215// refused: the programme has one tier.
216func SetReferralRules(cur realm, cutSpec, discountSpec string) {
217 assertNoSend(cur)
218 assertIsAdmin(cur)
219 emitAdmin(cur, "SetReferralRules", "cut", cutSpec, "discount", discountSpec)
220 cut, cutPct := normalizeRate(cutSpec)
221 disc, discPct := normalizeRate(discountSpec)
222 if cutPct+discPct > maxRefPercent {
223 panic("nslogic: a referrer's cut plus a buyer's discount may be at most " +
224 strconv.Itoa(maxRefPercent) + "% — got " + strconv.FormatInt(cutPct+discPct, 10) + "%")
225 }
226 setGlobal(cur, keyRefCut, cut)
227 setGlobal(cur, keyRefDisc, disc)
228}
229
230// normalizeRate reads one rate as an admin types it — "5", "0:5", or ""
231// — and returns its stored form, "0:5" or "" for none, and the percent.
232// Anything else panics before it can reach storage.
233func normalizeRate(spec string) (stored string, pct int64) {
234 s := strings.TrimSpace(spec)
235 if s == "" {
236 return "", 0
237 }
238 if kv := strings.SplitN(s, ":", 2); len(kv) == 2 {
239 if strings.TrimSpace(kv[0]) != "0" {
240 panic("nslogic: the referral programme has one flat tier; write N or 0:N — got " + spec)
241 }
242 s = strings.TrimSpace(kv[1])
243 }
244 p, err := strconv.ParseInt(s, 10, 64)
245 if err != nil || p < 0 || p > maxRefPercent {
246 panic("nslogic: a referral rate is a whole percent, 0-" + strconv.Itoa(maxRefPercent) + " — got " + spec)
247 }
248 if p == 0 {
249 return "", 0
250 }
251 return "0:" + strconv.FormatInt(p, 10), p
252}
253
254// -- tenure, for display --
255
256// TenureYears is how long a wallet counts as having been here, measured
257// from its PRIMARY name. It used to pick the referral tier; it moves no
258// money now, and stays because the referral page shows it.
259//
260// A lapsed or missing primary is zero years, not an error.
261func TenureYears(who address) int64 {
262 /* RESOLVED, NOT READ RAW. GetPrimary reports what an address last
263 SELECTED, not what it still holds — and a sale on the token ledger
264 no longer clears the seller's pointer. ResolveAddress re-checks the
265 owner and the expiry. */
266 tid := ResolveAddress(who)
267 at := strings.Index(tid, "*")
268 if at < 1 {
269 return 0
270 }
271 _, _, _, streak, _, _ := nsdata.GetNameInfo(tid[:at], tid[at+1:])
272 return streak
273}
274
275// -- where it gets paid --
276
277// Commissions go to the referring wallet. There is no way to redirect
278// them, and that is a decision rather than an omission.
279//
280// There WAS a redirect — SetReferralPayout, "a forwarding address, not a
281// transferable right". It does not survive the obvious question: how
282// would this realm tell somebody moving money between their own two
283// wallets from somebody who has sold the stream to a stranger? It
284// cannot. Nothing on chain distinguishes those, and a rule that cannot
285// be enforced is not a rule, it is a sentence in a document.
286//
287// So the destination is the referrer's own address, fixed. Selling the
288// entitlement now means handing over private keys, which is a deterrent
289// no contract clause could match.
290func PayoutFor(who address) address { return who }
291
292// -- the record on a name --
293
294// ReferrerOf reads the wallet credited with a name, or the empty address.
295func ReferrerOf(label, domainLabel string) address {
296 v := nsdata.GetNameExtra(label, domainLabel, keyRef)
297 if a := address(v); v != "" && a.IsValid() {
298 return a
299 }
300 return ""
301}
302
303/*
304resolveReferrer turns a claimed referring NAME into the wallet to credit,
305or the empty address if the claim does not stand up. Every rejection is
306silent on purpose: a mistyped code should not fail somebody's
307registration, it should simply earn nobody anything.
308
309THE CODE ITSELF must qualify: a paid name, live, held by its current
310holder for refHoldDays. Checking the code rather than "any name the
311referrer has" is one lookup instead of a registry walk, and the site
312builds a holder's link from a name that qualifies.
313*/
314func resolveReferrer(refName string, buyer address) address {
315 at := strings.Index(refName, "*")
316 if refName == "" || at < 1 || at == len(refName)-1 {
317 return ""
318 }
319 label, domainLabel := refName[:at], refName[at+1:]
320 if !nsdata.NameExists(label, domainLabel) || !nsdata.IsNameValid(label, domainLabel) {
321 return "" // a lapsed name is not a working code
322 }
323 owner := nsdata.GetNameOwner(label, domainLabel)
324 if owner == buyer {
325 return "" // no referring yourself
326 }
327 if !nsdata.IsNamePaid(label, domainLabel) {
328 return "" // a free-tier name is not a code
329 }
330 if !heldLongEnough(nsdata.GetNameHeldSince(label, domainLabel), time.Now().Unix()) {
331 return "" // not held long enough, or bought and not yet seen by the registry
332 }
333 return owner
334}
335
336// heldLongEnough is the thirty-day rule on its own. A zero `since` means
337// the registry has not seen the current holder yet — never long enough.
338func heldLongEnough(since, now int64) bool {
339 return since > 0 && now >= since && now-since >= refHoldDays*86400
340}
341
342/*
343StartReferralClock starts the holding clock on a name the caller BOUGHT,
344so that thirty days later it can be a referral code.
345
346A sale that settles on the token ledger never calls the registry, so the
347registry first sees a buyer at their first write to the name. A buyer who
348never edits a profile would otherwise never qualify. This is that first
349write, with nothing in it: it clears the seller's stale data, which every
350read already hides, and stamps the clock. Names you registered yourself
351start their clock at registration and need nothing.
352*/
353func StartReferralClock(cur realm, label, domainLabel string) {
354 assertNoSend(cur)
355 assertNameOwner(cur, label, domainLabel)
356 if err := nsdata.FreshenNameRecord(cross(cur), label, domainLabel); err != nil {
357 panic("nslogic: " + err.Error())
358 }
359}
360
361// -- the money --
362
363// PriceWithReferral is what a buyer actually pays given the code they
364// arrived with. Exported so the site can display the number the chain
365// will charge instead of computing its own and hoping they agree.
366func PriceWithReferral(domainLabel, label, refName string, buyer address) int64 {
367 price := NamePrice(domainLabel, label)
368 if price <= 0 {
369 return 0 // free is free, and a percentage of nothing is nothing
370 }
371 if resolveReferrer(refName, buyer) == "" {
372 return price
373 }
374 _, disc := RefRates()
375 return price - price*disc/100
376}
377
378// referralCut is what leaves for a qualifying referrer out of a
379// registration's payment of `price` (already discounted). Returns the
380// destination and the amount; the caller pays it in the same call that
381// pays the treasury. Registration only: renewals pay nobody.
382func referralCut(ref address, price int64) (address, int64) {
383 if ref == "" || price <= 0 {
384 return "", 0
385 }
386 cutPct, _ := RefRates()
387 cut := price * cutPct / 100
388 if cut <= 0 {
389 return "", 0
390 }
391 return PayoutFor(ref), cut
392}
393
394// -- the scoreboard --
395
396// creditReferral records one paid referral against a wallet.
397//
398// The COUNT and nothing else. There was a running total of micro-USD
399// beside it, and it is gone, because a number stored in the vault is a
400// number anyone with an RPC endpoint can read — there is no such thing
401// as an admin-only global on a public chain, and building the site as
402// though there were would be the kind of privacy that works right up
403// until somebody checks.
404//
405// Not storing it is the only real reduction available. The payments are
406// still in the block history and always will be, so anyone determined
407// can add them up; what we can decline to do is publish a convenient
408// running total ourselves. tools/referrals.py reconstructs it from the
409// chain when we want it, which costs no storage and publishes nothing.
410//
411// The registrant's transaction pays for this entry: one small field,
412// written once per referrer and overwritten after — the price of the
413// discount they just received.
414//
415// IT LIVES IN THE VAULT'S PER-ADDRESS STORE, and until it did, no referred
416// registration ever succeeded. It was the global "ref:" + address, 44
417// bytes against a 32-byte cap on global keys; the vault refused it, this
418// panicked, and the whole purchase reverted after the price was taken —
419// on every chain the programme ran on. A key that short would only have
420// moved the failure: globals held at most 24 entries, shared with the
421// price list, so it would have broken again at about the twenty-first
422// referrer. An address is a key there now, and each wallet's fields are
423// bounded on their own.
424func creditReferral(cur realm, ref address) {
425 if ref == "" {
426 return
427 }
428 if err := nsdata.SetAccountRecord(cross(cur), ref, keyRefCount,
429 strconv.FormatInt(ReferralCount(ref)+1, 10)); err != nil {
430 panic("nslogic: " + err.Error())
431 }
432}
433
434// ReferralCount is how many paid registrations a wallet has been
435// credited with. Public, like everything in the vault.
436func ReferralCount(who address) int64 {
437 return parseInt64(nsdata.GetAccount(who, keyRefCount))
438}
439
440func parseInt64(s string) int64 {
441 v, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
442 if err != nil {
443 return 0
444 }
445 return v
446}
447
448// How many vault pages one ReferralBoard call may scan.
449const refBoardPages = 5
450
451// ReferralBoard pages through every credited wallet for the
452// leaderboard: one "address,count" row per line.
453//
454// It walks the vault's per-address store, where other fields (credits)
455// sit beside the counters, so a page can scan more keys than it returns.
456// `next` is an opaque cursor — pass it back as `after` — and is non-empty
457// whenever the walk stopped before the end, including when it stopped on
458// its scanning budget with fewer rows than asked for. A caller keeps
459// going until `next` is empty.
460func ReferralBoard(after string, limit int) (rows, next string) {
461 if limit <= 0 || limit > 200 {
462 limit = 200
463 }
464 var out []string
465 from := after
466 for page := 0; page < refBoardPages; page++ {
467 keys, more := nsdata.ExportAccounts(from, 200)
468 if keys == "" {
469 return strings.Join(out, "\n"), ""
470 }
471 for _, k := range strings.Split(keys, ",") {
472 from = k
473 at := strings.Index(k, ":")
474 if at < 0 || k[at+1:] != keyRefCount {
475 continue
476 }
477 addr := k[:at]
478 n := ReferralCount(address(addr))
479 if n <= 0 {
480 continue
481 }
482 out = append(out, addr+","+strconv.FormatInt(n, 10))
483 if len(out) >= limit {
484 return strings.Join(out, "\n"), k
485 }
486 }
487 if more == "" {
488 return strings.Join(out, "\n"), ""
489 }
490 }
491 return strings.Join(out, "\n"), from
492}