handles.gno
5.50 Kb · 211 lines
1// Package handles is a tiny on-chain nickname registry.
2//
3// Any address may claim one unique handle (3-20 lowercase letters/digits,
4// starting with a letter), attach a short bio, transfer the handle to
5// another address, or release it back to the pool. It is deliberately
6// small: two AVL indexes (handle -> record, address -> record) and a
7// handful of guarded mutators.
8package handles
9
10import (
11 "strconv"
12 "strings"
13
14 "chain"
15 "chain/runtime"
16
17 "gno.land/p/moul/kit/ui/v0"
18 "gno.land/p/nt/avl/v0"
19)
20
21const maxBioLen = 140
22
23// record is the persisted state for one claimed handle.
24type record struct {
25 handle string
26 owner address
27 bio string
28 sinceBlk int64
29}
30
31var (
32 byHandle avl.Tree // handle (string) -> *record
33 byOwner avl.Tree // owner address.String() -> *record
34)
35
36// validHandle reports whether h is 3-20 chars, starts with a lowercase
37// letter, and contains only lowercase letters and digits thereafter.
38func validHandle(h string) bool {
39 if len(h) < 3 || len(h) > 20 {
40 return false
41 }
42 for i := 0; i < len(h); i++ {
43 c := h[i]
44 switch {
45 case c >= 'a' && c <= 'z':
46 case c >= '0' && c <= '9' && i > 0:
47 default:
48 return false
49 }
50 }
51 return true
52}
53
54// Register claims handle for the caller. Aborts if the handle is invalid,
55// already taken, or the caller already owns a handle (release it first).
56func Register(cur realm, handle string) {
57 if !cur.IsCurrent() {
58 panic("invalid realm")
59 }
60 if !cur.Previous().IsUserCall() {
61 panic("only a direct user call can register a handle")
62 }
63 if !validHandle(handle) {
64 panic("handle must be 3-20 lowercase letters/digits, starting with a letter")
65 }
66 caller := cur.Previous().Address()
67 if byOwner.Has(caller.String()) {
68 panic("this address already owns a handle; release it first")
69 }
70 if byHandle.Has(handle) {
71 panic("handle already taken")
72 }
73
74 r := &record{
75 handle: handle,
76 owner: caller,
77 sinceBlk: runtime.ChainHeight(),
78 }
79 byHandle.Set(handle, r)
80 byOwner.Set(caller.String(), r)
81 chain.Emit("HandleRegistered", "handle", handle, "owner", caller.String())
82}
83
84// SetBio updates the bio of the handle owned by the caller.
85func SetBio(cur realm, bio string) {
86 if !cur.IsCurrent() {
87 panic("invalid realm")
88 }
89 if len(bio) > maxBioLen {
90 panic("bio too long (max " + strconv.Itoa(maxBioLen) + " chars)")
91 }
92 r := ownRecord(cur)
93 r.bio = bio
94 chain.Emit("BioUpdated", "handle", r.handle)
95}
96
97// Release frees the handle owned by the caller, making it claimable again.
98func Release(cur realm) {
99 if !cur.IsCurrent() {
100 panic("invalid realm")
101 }
102 r := ownRecord(cur)
103 byHandle.Remove(r.handle)
104 byOwner.Remove(r.owner.String())
105 chain.Emit("HandleReleased", "handle", r.handle)
106}
107
108// Transfer moves the caller's handle to another address that does not
109// already own one.
110func Transfer(cur realm, to address) {
111 if !cur.IsCurrent() {
112 panic("invalid realm")
113 }
114 if !to.IsValid() {
115 panic("invalid recipient address")
116 }
117 r := ownRecord(cur)
118 if byOwner.Has(to.String()) {
119 panic("recipient already owns a handle")
120 }
121 byOwner.Remove(r.owner.String())
122 r.owner = to
123 byOwner.Set(to.String(), r)
124 chain.Emit("HandleTransferred", "handle", r.handle, "to", to.String())
125}
126
127// ownRecord resolves the record for the current caller, panicking if the
128// caller does not own a handle.
129func ownRecord(cur realm) *record {
130 caller := cur.Previous().Address()
131 v := byOwner.Get(caller.String())
132 if v == nil {
133 panic("this address does not own a handle")
134 }
135 return v.(*record)
136}
137
138// OwnerOf returns the owner of handle and whether it exists.
139func OwnerOf(handle string) (address, bool) {
140 v := byHandle.Get(handle)
141 if v == nil {
142 return address(""), false
143 }
144 return v.(*record).owner, true
145}
146
147// HandleOf returns the handle owned by addr, if any.
148func HandleOf(addr address) (string, bool) {
149 v := byOwner.Get(addr.String())
150 if v == nil {
151 return "", false
152 }
153 return v.(*record).handle, true
154}
155
156// Count returns the number of currently registered handles.
157func Count() int {
158 return byHandle.Size()
159}
160
161// Render renders Markdown. "/" lists every handle; "/<handle>" shows a
162// single record's detail.
163func Render(path string) string {
164 path = strings.TrimPrefix(path, "/")
165 if path == "" {
166 return renderIndex()
167 }
168 return renderHandle(path)
169}
170
171func renderIndex() string {
172 var b strings.Builder
173 b.WriteString("# 🪪 Handles\n\n")
174 b.WriteString("A tiny on-chain nickname registry. Claim a short handle, ")
175 b.WriteString("attach a bio, transfer it, or release it.\n\n")
176
177 if byHandle.Size() == 0 {
178 b.WriteString("_No handles registered yet._\n\n")
179 b.WriteString("Call `Register(handle)` to claim the first one.\n")
180 return b.String()
181 }
182
183 b.WriteString("| Handle | Owner | Since block |\n")
184 b.WriteString("|--------|-------|-------------|\n")
185 byHandle.Iterate("", "", func(key string, value interface{}) bool {
186 r := value.(*record)
187 b.WriteString("| [" + key + "](/" + key + ") | " + ui.Addr(r.owner) +
188 " | " + strconv.Itoa(int(r.sinceBlk)) + " |\n")
189 return false
190 })
191 b.WriteString("\nTotal handles: " + strconv.Itoa(byHandle.Size()) + "\n")
192 return b.String()
193}
194
195func renderHandle(handle string) string {
196 v := byHandle.Get(handle)
197 if v == nil {
198 return "# Handles\n\nNo handle named `" + handle + "`.\n\n[← all handles](/)\n"
199 }
200 r := v.(*record)
201
202 var b strings.Builder
203 b.WriteString("# 🪪 @" + r.handle + "\n\n")
204 b.WriteString("**Owner:** " + r.owner.String() + "\n\n")
205 if r.bio != "" {
206 b.WriteString("**Bio:** " + r.bio + "\n\n")
207 }
208 b.WriteString("**Registered at block:** " + strconv.Itoa(int(r.sinceBlk)) + "\n\n")
209 b.WriteString("[← all handles](/)\n")
210 return b.String()
211}