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.

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/v2"
  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}