attach.gno
10.37 Kb · 282 lines
1package nsskin
2
3import (
4 "gno.land/p/nt/grc721/v0"
5 "gno.land/p/nt/ufmt/v0"
6 "gno.land/r/nym-mnsrehearsal001/nsdata/v2"
7)
8
9func nameKey(label, domain string) string { return domain + "*" + label }
10
11// bindingIsLive reports whether a token's binding still points at the
12// registration it was made against. Same test ArtForName and Claim
13// already apply; named once so the four copies cannot drift.
14func bindingIsLive(tid string) bool {
15 v := bound.Get(tid)
16 if v == nil {
17 return false
18 }
19 b := v.(*binding)
20 if !nsdata.NameExists(b.label, b.domain) {
21 return false
22 }
23 _, registered, _, _, _, _ := nsdata.GetNameInfo(b.label, b.domain)
24 return registered == b.registered
25}
26
27// grcID saves every call site from importing grc721 just to cast.
28func grcID(tid string) grc721.TokenID { return grc721.TokenID(tid) }
29
30/*
31Attach binds a skin to a name and puts the token into this realm's
32custody. See the long note in types.gno for why custody rather than a
33lock in place: it is what makes a marketplace's escrow of the NAME
34irrelevant to the skin.
35
36ONE SKIN, ONE NAME, both ways. A token may be attached to one name, and
37a name may wear one skin. Letting one copy dress every name a person owns
38would make the second copy pointless, and the object stops meaning
39anything.
40*/
41func Attach(cur realm, tid, label, domain string) {
42 assertNoSend(cur)
43 wear(cur, tid, label, domain, cur.Previous().Address())
44}
45
46// wear is Attach's body, shared with Buy so that buying and wearing
47// settle in one transaction. Splitting them would let a buyer stop
48// halfway and end up holding a skin they paid for and did not put on.
49func wear(cur realm, tid, label, domain string, caller address) {
50 id, ok := skinOfToken(tid)
51 if !ok {
52 panic("nsskin: no such skin token: " + tid)
53 }
54 s := mustSkin(id)
55 if s.withdrawn {
56 panic("nsskin: " + id + " has been withdrawn and cannot be worn")
57 }
58 if bound.Has(tid) {
59 panic("nsskin: " + tid + " is already on a name — claim it back first")
60 }
61 /* A STALE BINDING IS NOT A BINDING, and this used to be the one
62 place in the realm that forgot it.
63 Every other consumer — ArtForName, Refresh, refresh, Claim — pins
64 on `registered` and treats a mismatch as no binding at all. This
65 guard tested existence only, so: somebody attaches a skin, lets
66 the name lapse, never claims. A stranger re-registers the label
67 and can NEVER wear a skin on it — their Attach panics here, and
68 Claim refuses them because the stale binding entitles its own
69 binder. No admin call could clear it either. One registration
70 permanently killed the cosmetic slot on any label, and an honest
71 holder who simply let a name go did it by accident.
72 So a dead binding is evicted rather than obeyed. Only the reverse
73 index goes: `bound[tid]` stays, so the old binder can still Claim
74 their own token back — they paid for it and it is not forfeit. */
75 nk := nameKey(label, domain)
76 if cur := nameBound.Get(nk); cur != nil {
77 if !bindingIsLive(cur.(string)) {
78 nameBound.Remove(nk)
79 } else {
80 panic("nsskin: " + label + "*" + domain + " already wears a skin")
81 }
82 }
83
84 owner, err := token.OwnerOf(grcID(tid))
85 if err != nil {
86 panic("nsskin: " + err.Error())
87 }
88 if owner != caller {
89 panic("nsskin: you do not own " + tid)
90 }
91
92 if !nsdata.NameExists(label, domain) {
93 panic("nsskin: no such name: " + label + "*" + domain)
94 }
95 if !nsdata.IsNameValid(label, domain) {
96 panic("nsskin: " + label + "*" + domain + " has expired")
97 }
98 nameOwner, registered, _, _, _, _ := nsdata.GetNameInfo(label, domain)
99 if nameOwner != caller {
100 panic("nsskin: you do not own " + label + "*" + domain)
101 }
102 if s.lock != "" && s.lock != domain {
103 panic(ufmt.Sprintf("nsskin: %s only goes on *%s names, not *%s", id, s.lock, domain))
104 }
105
106 // Into custody. From here the token is in nobody's wallet, so it
107 // cannot be listed anywhere or sold out from under the name.
108 if err := ledger.TransferFrom(caller, caller, cur.Address(), grcID(tid)); err != nil {
109 panic("nsskin: could not take custody: " + err.Error())
110 }
111 bound.Set(tid, &binding{label: label, domain: domain, registered: registered, binder: caller})
112 nameBound.Set(nameKey(label, domain), tid)
113}
114
115/*
116Refresh points a binding's `binder` at the name's CURRENT owner.
117
118WHY THIS EXISTS, and it is the fix for a real hole. `binder` decides who
119gets the skin back once the name record is gone. Set once at Attach and
120never updated, it names whoever ATTACHED the skin — so: Alice attaches,
121sells the name to Bob, the skin travels with it as intended, Bob lets the
122name lapse, and the skin goes back to ALICE. Bob paid for it. That is
123not a limitation, it is taking something from the person who bought it.
124
125nsdata cannot tell us a name changed hands — it is permanent, it has no
126hook, and it never will. So `binder` cannot be maintained; it can only be
127CAUGHT UP. This is that, and it is deliberately open to anyone:
128
129 it only ever writes the name's real current owner, so calling it can
130 never move the skin anywhere it should not go, and there is nothing
131 to gain by calling it on somebody else's binding
132
133 which means the site can call it on a purchase, a keeper can call it
134 on a schedule, the buyer can call it themselves, or nobody calls it
135 at all and the next Attach or Claim does it — all with the same
136 result and none of them needing to be trusted
137
138Nothing on chain is automatic here. gno has no scheduler and nsdata has
139no callback, so every version of "the skin follows the sale" is one extra
140call by someone. Making that call safe for ANYONE to make is as close to
141automatic as this can get, and it is close enough.
142*/
143func Refresh(cur realm, tid string) {
144 // Permissionless and routinely called by keepers, so it is exactly
145 // the entry point a script attaches coins to by accident — and this
146 // realm's only banker moves that transaction's own OriginSend, so
147 // anything left here is stranded at the realm address forever.
148 assertNoSend(cur)
149 v := bound.Get(tid)
150 if v == nil {
151 return
152 }
153 b := v.(*binding)
154 if !nsdata.NameExists(b.label, b.domain) {
155 return
156 }
157 owner, registered, _, _, frozen, _ := nsdata.GetNameInfo(b.label, b.domain)
158 // Only for THIS registration. If the name lapsed and somebody else
159 // took it, the current owner is a stranger to this binding and must
160 // not be recorded as entitled to anything. Nor while the name is
161 // frozen: a disputed or stolen name's holder is not who the skin
162 // travels with (re-audit 2026-10-16, L-5).
163 if registered != b.registered || frozen {
164 return
165 }
166 b.binder = owner
167}
168
169// refresh is Refresh without the realm ceremony, for the callers below.
170func refresh(b *binding) {
171 if !nsdata.NameExists(b.label, b.domain) {
172 return
173 }
174 owner, registered, _, _, frozen, _ := nsdata.GetNameInfo(b.label, b.domain)
175 if registered != b.registered || frozen {
176 return
177 }
178 b.binder = owner
179}
180
181/*
182claimEntitled is Claim's rule on its own, with the record handed in (exists
183false and zeros when the record is gone), so it can be tested without a
184registry under it. A live, FROZEN registration is refused outright: the
185admin freezes a stolen or disputed name so nothing moves while it is sorted
186out, and its skin used to be claimable and sellable all the same (re-audit
1872026-10-16, L-5). The record's frozen bit, not the ledger's holds, so a
188free-tier name's skin still travels.
189*/
190func claimEntitled(b *binding, caller address, exists bool, owner address, registered int64, frozen bool) (bool, string) {
191 if exists && registered == b.registered {
192 if frozen {
193 return false, b.label + "*" + b.domain + " is frozen; its skin stays put until the name is released"
194 }
195 return owner == caller, ""
196 }
197 // The name lapsed and was taken by somebody else, or the record is
198 // gone: back to whoever the binding last knew. Another registration's
199 // freeze is not this binding's.
200 return caller == b.binder, ""
201}
202
203/*
204Claim takes a skin out of custody and into the caller's wallet.
205
206WHO MAY, and this is the whole travels-with-the-name rule:
207
208 the CURRENT OWNER of the name, while the name record still exists.
209 Buy the name, claim the skin — including a name bought from someone
210 else, which is the point.
211
212 the LAST KNOWN NAME OWNER, once the record is gone entirely. That is
213 the "it comes back to your wallet when the name expires" case — and
214 "last known" rather than "whoever attached it", because after a sale
215 those are different people and only one of them paid for it. See
216 Refresh above for how that is kept current without a hook nsdata
217 does not have.
218
219The binding is pinned to one REGISTRATION. If a name lapsed and somebody
220else registered it, its timestamp has changed, so the new registrant is
221not the owner of anything here and cannot claim.
222*/
223func Claim(cur realm, tid string) {
224 assertNoSend(cur)
225 caller := cur.Previous().Address()
226 v := bound.Get(tid)
227 if v == nil {
228 panic("nsskin: " + tid + " is not on a name")
229 }
230 b := v.(*binding)
231
232 // Catch `binder` up before deciding, so a name that changed hands
233 // since it was attached settles on the person who holds it now
234 // rather than the person who first attached it.
235 refresh(b)
236
237 exists := nsdata.NameExists(b.label, b.domain)
238 var owner address
239 var registered int64
240 frozen := false
241 if exists {
242 owner, registered, _, _, frozen, _ = nsdata.GetNameInfo(b.label, b.domain)
243 }
244 entitled, why := claimEntitled(b, caller, exists, owner, registered, frozen)
245 if why != "" {
246 panic("nsskin: " + why)
247 }
248 if !entitled {
249 panic("nsskin: " + tid + " belongs to " + b.label + "*" + b.domain +
250 ", and you are neither its owner nor the person who attached this")
251 }
252
253 if err := ledger.TransferFrom(cur.Address(), cur.Address(), caller, grcID(tid)); err != nil {
254 panic("nsskin: could not release: " + err.Error())
255 }
256 bound.Remove(tid)
257 // Only clear the reverse entry if it still points at THIS token — a
258 // stale binding may have been superseded by a new registrant's own
259 // skin, and clearing it blindly would strip theirs.
260 if cur := nameBound.Get(nameKey(b.label, b.domain)); cur != nil && cur.(string) == tid {
261 nameBound.Remove(nameKey(b.label, b.domain))
262 }
263}
264
265// SkinOnName returns the token currently worn by a name, or "".
266func SkinOnName(label, domain string) string {
267 v := nameBound.Get(nameKey(label, domain))
268 if v == nil {
269 return ""
270 }
271 return v.(string)
272}
273
274// BindingOf reports where a token is attached, for a wallet or a shop.
275func BindingOf(tid string) (label, domain string, registered int64, binder address) {
276 v := bound.Get(tid)
277 if v == nil {
278 return "", "", 0, ""
279 }
280 b := v.(*binding)
281 return b.label, b.domain, b.registered, b.binder
282}