grc20reg_test.gno
12.48 Kb · 316 lines
1package grc20reg
2
3import (
4 "chain"
5 "strings"
6 "testing"
7
8 "gno.land/p/nt/grc20/v0"
9 "gno.land/p/nt/testutils/v0"
10 "gno.land/p/nt/uassert/v0"
11 "gno.land/p/nt/urequire/v0"
12)
13
14func TestRegistry(cur realm, t *testing.T) {
15 testing.SetRealm(testing.NewCodeRealm("gno.land/r/demo/foo"))
16 token, ledger := grc20.NewToken("TestToken", "TST", 4, 0, cur)
17 ledger.Mint(cur.Address(), 1234567)
18 // register
19 key := Register(cross(cur), token, "mySlug")
20 regToken := Get(key)
21 urequire.True(t, regToken != nil, "expected to find a token") // fixme: use urequire.NotNil
22 urequire.Equal(t, regToken.GetSymbol(), "TST")
23
24 expected := `- **TestToken** - [gno.land/r/demo/foo](/r/demo/foo).TST - [info](/r/nt/grc20reg/v0:gno.land/r/demo/foo.TST)
25`
26 got := Render("")
27 urequire.True(t, strings.Contains(got, expected))
28 // 404
29 invalidToken := Get("0xdeadbeef")
30 urequire.True(t, invalidToken == nil)
31
32 got = Render("")
33 urequire.True(t, strings.Contains(got, expected))
34
35 expected = `# TestToken
36- symbol: **TST**
37- realm: [gno.land/r/demo/foo](/r/demo/foo).TST
38- decimals: 4
39- total supply: 1234567
40`
41 got = Render(key)
42 urequire.Equal(t, expected, got)
43
44 // The registry keys by rlmPath.symbol, so a second token with the same
45 // symbol in the same realm is rejected even though its Token.ID() differs
46 // (distinct trailing sequence id). See TestRegisterRejectsOverwrite.
47 second, _ := grc20.NewToken("Second", "TST", 4, 1, cur)
48 urequire.NotEqual(t, token.ID(), second.ID()) // ids are decoupled from symbol
49 urequire.AbortsContains(t, cur, "token already registered", func() {
50 Register(cross(cur), second, "")
51 })
52}
53
54// TestRegistryLookupGrantsNoSpendAuthority pins the property that replaced the
55// former Transfer/Approve/TransferFrom wrappers: a registry lookup yields a
56// *Token, and a *Token alone carries no authority to debit anybody. The
57// frame-relative teller is reachable only from the ledger, which never leaves
58// the token's own realm, so this realm cannot act on behalf of its caller —
59// that shape was the confused deputy.
60//
61// The supported route for a realm that must move a user's funds is the one
62// exercised below: the user grants an allowance, and the realm spends it as
63// itself through a RealmTeller, which is eagerly bound to its own address.
64func TestRegistryLookupGrantsNoSpendAuthority(cur realm, t *testing.T) {
65 const (
66 tokenPath = "gno.land/r/demo/token"
67 consumerPath = "gno.land/r/demo/grc20reg_consumer"
68 )
69 alice := testutils.TestAddress("alice")
70 bob := testutils.TestAddress("bob")
71 consumer := chain.PackageAddress(consumerPath)
72
73 testing.SetRealm(testing.NewCodeRealm(tokenPath))
74 token, ledger := grc20.NewToken("TestToken", "TST", 4, 0, cur)
75 urequire.NoError(t, ledger.Mint(alice, 1_000))
76 tokenKey := Register(cross(cur), token, "")
77 uassert.Equal(t, "TestToken", MustGet(tokenKey).GetName())
78 uassert.Equal(t, int64(1_000), MustGet(tokenKey).BalanceOf(alice))
79 uassert.Equal(t, int64(0), MustGet(tokenKey).Allowance(alice, consumer))
80
81 // alice approves the consumer realm on the token. On chain she does this
82 // through the token realm's own entry point; the ledger stands in for it.
83 urequire.NoError(t, ledger.ImpersonateTeller(alice).Approve(0, cur, consumer, 300))
84 uassert.Equal(t, int64(300), MustGet(tokenKey).Allowance(alice, consumer))
85
86 // The consumer spends that allowance as ITSELF, over a token it found in
87 // the registry and does not own. The actor is fixed at construction, so it
88 // cannot be redirected by whoever calls in.
89 testing.SetRealm(testing.NewCodeRealm(consumerPath))
90 spender := MustGet(tokenKey).RealmTeller(0, cur)
91 urequire.NoError(t, spender.TransferFrom(0, cur, alice, bob, 200))
92 uassert.Equal(t, int64(800), MustGet(tokenKey).BalanceOf(alice))
93 uassert.Equal(t, int64(200), MustGet(tokenKey).BalanceOf(bob))
94 uassert.Equal(t, int64(100), MustGet(tokenKey).Allowance(alice, consumer))
95
96 // Beyond the allowance it stops: the balance is not reachable directly.
97 uassert.ErrorContains(t,
98 spender.TransferFrom(0, cur, alice, bob, 101),
99 "insufficient allowance")
100 uassert.Equal(t, int64(800), MustGet(tokenKey).BalanceOf(alice))
101 uassert.Equal(t, int64(200), MustGet(tokenKey).BalanceOf(bob))
102}
103
104// TestRegistryConsumerCannotRedirectItsActor is the negative half of the
105// property above: a realm holding a registered token pays out of its own
106// balance, not the signing user's. The actor is bound at construction, so the
107// transaction's origin caller has no bearing on who is debited — which is what
108// made the frame-relative teller in a foreign realm a phishing primitive.
109func TestRegistryConsumerCannotRedirectItsActor(cur realm, t *testing.T) {
110 const (
111 tokenPath = "gno.land/r/demo/token_actor_binding"
112 consumerPath = "gno.land/r/demo/grc20reg_actor_consumer"
113 )
114 alice := testutils.TestAddress("alice")
115 bob := testutils.TestAddress("bob")
116 consumer := chain.PackageAddress(consumerPath)
117
118 testing.SetRealm(testing.NewCodeRealm(tokenPath))
119 token, ledger := grc20.NewToken("ActorBinding", "ACTB", 4, 0, cur)
120 urequire.NoError(t, ledger.Mint(alice, 1_000))
121 urequire.NoError(t, ledger.Mint(consumer, 500))
122 tokenKey := Register(cross(cur), token, "")
123
124 // alice signs the transaction, and holds a balance the consumer would love
125 // to spend. The consumer pays out of its own instead.
126 testing.SetOriginCaller(alice)
127 testing.SetRealm(testing.NewCodeRealm(consumerPath))
128 urequire.NoError(t, MustGet(tokenKey).RealmTeller(0, cur).Transfer(0, cur, bob, 100))
129 uassert.Equal(t, int64(1_000), MustGet(tokenKey).BalanceOf(alice))
130 uassert.Equal(t, int64(400), MustGet(tokenKey).BalanceOf(consumer))
131 uassert.Equal(t, int64(100), MustGet(tokenKey).BalanceOf(bob))
132}
133
134func TestRegisterRejectsOverwrite(cur realm, t *testing.T) {
135 testing.SetRealm(testing.NewCodeRealm("gno.land/r/demo/grc20reg_overwrite"))
136 token, ledger := grc20.NewToken("Bar", "BAR", 4, 0, cur)
137 ledger.Mint(cur.Address(), 11)
138 key := Register(cross(cur), token, "")
139
140 urequire.Equal(t, "BAR", Get(key).GetSymbol())
141 urequire.Equal(t, int64(11), Get(key).BalanceOf(cur.Address()))
142
143 replacement, _ := grc20.NewToken("Replacement", "BAR", 6, 0, cur)
144 urequire.AbortsContains(t, cur, "token already registered", func() {
145 Register(cross(cur), replacement, "")
146 })
147}
148
149func TestRegisterRejectsAliasedTokenPaths(cur realm, t *testing.T) {
150 testing.SetRealm(testing.NewCodeRealm("gno.land/r/demo/grc20reg_alias"))
151 token, _ := grc20.NewToken("Aliased Token", "ALIAS", 4, 0, cur)
152 Register(cross(cur), token, "first")
153
154 urequire.AbortsContains(t, cur, "token already registered", func() {
155 Register(cross(cur), token, "second")
156 })
157}
158
159func TestRegisterRejectsTokenFromDifferentRealm(cur realm, t *testing.T) {
160 testing.SetRealm(testing.NewCodeRealm("gno.land/r/demo/grc20reg_id_source"))
161 token, _ := grc20.NewToken("Mismatch Token", "MISMATCH", 4, 0, cur)
162
163 testing.SetRealm(testing.NewCodeRealm("gno.land/r/demo/grc20reg_id_target"))
164 urequire.AbortsContains(t, cur, "token must be registered from its own realm", func() {
165 Register(cross(cur), token, "")
166 })
167}
168
169func TestValidateSlug(cur realm, t *testing.T) {
170 // Valid slugs — should not panic
171 valid := []string{"mytoken", "my-token", "my_token", "Token123", "a", "A-B_c", strings.Repeat("a", maxSlugLen)}
172 for _, slug := range valid {
173 validateSlug(slug) // no panic = pass
174 }
175}
176
177func TestValidateSlugPanicsOnTooLong(cur realm, t *testing.T) {
178 defer func() { recover() }()
179 validateSlug(strings.Repeat("a", maxSlugLen+1))
180 t.Errorf("should have panicked")
181}
182
183func TestValidateSlugPanicsOnSpace(cur realm, t *testing.T) {
184 defer func() { recover() }()
185 validateSlug("has space")
186 t.Errorf("should have panicked")
187}
188
189func TestValidateSlugPanicsOnDot(cur realm, t *testing.T) {
190 defer func() { recover() }()
191 validateSlug("has.dot")
192 t.Errorf("should have panicked")
193}
194
195func TestValidateSlugPanicsOnSlash(cur realm, t *testing.T) {
196 defer func() { recover() }()
197 validateSlug("has/slash")
198 t.Errorf("should have panicked")
199}
200
201func TestValidateSlugPanicsOnBrackets(cur realm, t *testing.T) {
202 defer func() { recover() }()
203 validateSlug("[brackets]")
204 t.Errorf("should have panicked")
205}
206
207func TestValidateSlugPanicsOnParens(cur realm, t *testing.T) {
208 defer func() { recover() }()
209 validateSlug("(parens)")
210 t.Errorf("should have panicked")
211}
212
213func TestValidateSlugPanicsOnInjection(cur realm, t *testing.T) {
214 defer func() { recover() }()
215 validateSlug(`) [Claim](https://evil.com`)
216 t.Errorf("should have panicked")
217}
218
219func TestRegisterRejectsNilToken(cur realm, t *testing.T) {
220 testing.SetRealm(testing.NewCodeRealm("gno.land/r/demo/grc20reg_nil"))
221 urequire.AbortsContains(t, cur, "nil token", func() {
222 Register(cross(cur), nil, "")
223 })
224}
225
226// TestWrappersBindActorToCallingRealm pins the property that makes the write
227// wrappers safe: they are non-crossing, so `rlm` reaches RealmTeller as the
228// caller's own live token and the debit lands on the caller. A crossing wrapper
229// would mint a fresh `cur` for grc20reg and spend the registry's balance
230// instead, which is why the convention is not a stylistic choice.
231//
232// Both the consumer and the registry hold a balance, so whichever one is
233// debited is observable rather than inferred.
234func TestWrappersBindActorToCallingRealm(cur realm, t *testing.T) {
235 const (
236 tokenPath = "gno.land/r/demo/token_wrapper_actor"
237 consumerPath = "gno.land/r/demo/grc20reg_wrapper_consumer"
238 )
239 bob := testutils.TestAddress("bob")
240 consumer := chain.PackageAddress(consumerPath)
241 registry := chain.PackageAddress("gno.land/r/nt/grc20reg/v0")
242
243 testing.SetRealm(testing.NewCodeRealm(tokenPath))
244 token, ledger := grc20.NewToken("WrapperActor", "WRPA", 4, 0, cur)
245 urequire.NoError(t, ledger.Mint(consumer, 1_000))
246 urequire.NoError(t, ledger.Mint(registry, 1_000))
247 tokenKey := Register(cross(cur), token, "")
248
249 testing.SetRealm(testing.NewCodeRealm(consumerPath))
250 Transfer(0, cur, tokenKey, bob, 100)
251
252 uassert.Equal(t, int64(900), MustGet(tokenKey).BalanceOf(consumer))
253 uassert.Equal(t, int64(1_000), MustGet(tokenKey).BalanceOf(registry))
254 uassert.Equal(t, int64(100), MustGet(tokenKey).BalanceOf(bob))
255}
256
257// TestWrapperActorIgnoresSigningUser is the negative half: the signing user has
258// a balance the calling realm would like to spend, and does not lose it. The
259// actor comes from rlm.Address(), so the transaction's origin has no say in who
260// pays — a hub cannot be induced to debit its caller's caller.
261func TestWrapperActorIgnoresSigningUser(cur realm, t *testing.T) {
262 const (
263 tokenPath = "gno.land/r/demo/token_wrapper_origin"
264 consumerPath = "gno.land/r/demo/grc20reg_wrapper_origin_consumer"
265 )
266 alice := testutils.TestAddress("alice")
267 bob := testutils.TestAddress("bob")
268 consumer := chain.PackageAddress(consumerPath)
269
270 testing.SetRealm(testing.NewCodeRealm(tokenPath))
271 token, ledger := grc20.NewToken("WrapperOrigin", "WRPO", 4, 0, cur)
272 urequire.NoError(t, ledger.Mint(alice, 1_000))
273 urequire.NoError(t, ledger.Mint(consumer, 500))
274 tokenKey := Register(cross(cur), token, "")
275
276 testing.SetOriginCaller(alice)
277 testing.SetRealm(testing.NewCodeRealm(consumerPath))
278 Transfer(0, cur, tokenKey, bob, 100)
279
280 uassert.Equal(t, int64(1_000), MustGet(tokenKey).BalanceOf(alice))
281 uassert.Equal(t, int64(400), MustGet(tokenKey).BalanceOf(consumer))
282}
283
284// TestTransferFromSpendsAllowanceGrantedToCallingRealm records the semantic
285// shift that comes with binding the actor to the caller: the owner must have
286// approved the CALLING REALM, not the signing user. An allowance granted to
287// anyone else does not authorize the wrapper.
288func TestTransferFromSpendsAllowanceGrantedToCallingRealm(cur realm, t *testing.T) {
289 const (
290 tokenPath = "gno.land/r/demo/token_wrapper_allowance"
291 consumerPath = "gno.land/r/demo/grc20reg_wrapper_allowance_consumer"
292 )
293 alice := testutils.TestAddress("alice")
294 bob := testutils.TestAddress("bob")
295 consumer := chain.PackageAddress(consumerPath)
296
297 testing.SetRealm(testing.NewCodeRealm(tokenPath))
298 token, ledger := grc20.NewToken("WrapperAllowance", "WRPL", 4, 0, cur)
299 urequire.NoError(t, ledger.Mint(alice, 1_000))
300 tokenKey := Register(cross(cur), token, "")
301
302 // No allowance yet: the wrapper cannot touch alice's balance.
303 testing.SetRealm(testing.NewCodeRealm(consumerPath))
304 uassert.PanicsContains(t, cur, "insufficient allowance", func() {
305 TransferFrom(0, cur, tokenKey, alice, bob, 100)
306 })
307 uassert.Equal(t, int64(1_000), MustGet(tokenKey).BalanceOf(alice))
308
309 // alice approves the consuming realm itself, and only then does it work.
310 urequire.NoError(t, ledger.Approve(alice, consumer, 100))
311
312 testing.SetRealm(testing.NewCodeRealm(consumerPath))
313 TransferFrom(0, cur, tokenKey, alice, bob, 100)
314 uassert.Equal(t, int64(900), MustGet(tokenKey).BalanceOf(alice))
315 uassert.Equal(t, int64(100), MustGet(tokenKey).BalanceOf(bob))
316}