1package grc20reg23import (4 "chain"5 "strings"6 "testing"78 "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)1314func 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 // register19 key := Register(cross(cur), token, "mySlug")20 regToken := Get(key)21 urequire.True(t, regToken != nil, "expected to find a token") // fixme: use urequire.NotNil22 urequire.Equal(t, regToken.GetSymbol(), "TST")2324 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 // 40429 invalidToken := Get("0xdeadbeef")30 urequire.True(t, invalidToken == nil)3132 got = Render("")33 urequire.True(t, strings.Contains(got, expected))3435 expected = `# TestToken36- symbol: **TST**37- realm: [gno.land/r/demo/foo](/r/demo/foo).TST38- decimals: 439- total supply: 123456740`41 got = Render(key)42 urequire.Equal(t, expected, got)4344 // The registry keys by rlmPath.symbol, so a second token with the same45 // symbol in the same realm is rejected even though its Token.ID() differs46 // (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 symbol49 urequire.AbortsContains(t, cur, "token already registered", func() {50 Register(cross(cur), second, "")51 })52}5354// TestRegistryLookupGrantsNoSpendAuthority pins the property that replaced the55// former Transfer/Approve/TransferFrom wrappers: a registry lookup yields a56// *Token, and a *Token alone carries no authority to debit anybody. The57// frame-relative teller is reachable only from the ledger, which never leaves58// 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 one62// exercised below: the user grants an allowance, and the realm spends it as63// 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)7273 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))8081 // alice approves the consumer realm on the token. On chain she does this82 // 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))8586 // The consumer spends that allowance as ITSELF, over a token it found in87 // the registry and does not own. The actor is fixed at construction, so it88 // 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))9596 // 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}103104// TestRegistryConsumerCannotRedirectItsActor is the negative half of the105// property above: a realm holding a registered token pays out of its own106// balance, not the signing user's. The actor is bound at construction, so the107// transaction's origin caller has no bearing on who is debited — which is what108// 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)117118 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, "")123124 // alice signs the transaction, and holds a balance the consumer would love125 // 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}133134func 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, "")139140 urequire.Equal(t, "BAR", Get(key).GetSymbol())141 urequire.Equal(t, int64(11), Get(key).BalanceOf(cur.Address()))142143 replacement, _ := grc20.NewToken("Replacement", "BAR", 6, 0, cur)144 urequire.AbortsContains(t, cur, "token already registered", func() {145 Register(cross(cur), replacement, "")146 })147}148149func 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")153154 urequire.AbortsContains(t, cur, "token already registered", func() {155 Register(cross(cur), token, "second")156 })157}158159func 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)162163 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}168169func TestValidateSlug(cur realm, t *testing.T) {170 // Valid slugs — should not panic171 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 = pass174 }175}176177func TestValidateSlugPanicsOnTooLong(cur realm, t *testing.T) {178 defer func() { recover() }()179 validateSlug(strings.Repeat("a", maxSlugLen+1))180 t.Errorf("should have panicked")181}182183func TestValidateSlugPanicsOnSpace(cur realm, t *testing.T) {184 defer func() { recover() }()185 validateSlug("has space")186 t.Errorf("should have panicked")187}188189func TestValidateSlugPanicsOnDot(cur realm, t *testing.T) {190 defer func() { recover() }()191 validateSlug("has.dot")192 t.Errorf("should have panicked")193}194195func TestValidateSlugPanicsOnSlash(cur realm, t *testing.T) {196 defer func() { recover() }()197 validateSlug("has/slash")198 t.Errorf("should have panicked")199}200201func TestValidateSlugPanicsOnBrackets(cur realm, t *testing.T) {202 defer func() { recover() }()203 validateSlug("[brackets]")204 t.Errorf("should have panicked")205}206207func TestValidateSlugPanicsOnParens(cur realm, t *testing.T) {208 defer func() { recover() }()209 validateSlug("(parens)")210 t.Errorf("should have panicked")211}212213func TestValidateSlugPanicsOnInjection(cur realm, t *testing.T) {214 defer func() { recover() }()215 validateSlug(`) [Claim](https://evil.com`)216 t.Errorf("should have panicked")217}218219func 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}225226// TestWrappersBindActorToCallingRealm pins the property that makes the write227// wrappers safe: they are non-crossing, so `rlm` reaches RealmTeller as the228// caller's own live token and the debit lands on the caller. A crossing wrapper229// would mint a fresh `cur` for grc20reg and spend the registry's balance230// 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 is233// 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")242243 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, "")248249 testing.SetRealm(testing.NewCodeRealm(consumerPath))250 Transfer(0, cur, tokenKey, bob, 100)251252 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}256257// TestWrapperActorIgnoresSigningUser is the negative half: the signing user has258// a balance the calling realm would like to spend, and does not lose it. The259// actor comes from rlm.Address(), so the transaction's origin has no say in who260// 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)269270 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, "")275276 testing.SetOriginCaller(alice)277 testing.SetRealm(testing.NewCodeRealm(consumerPath))278 Transfer(0, cur, tokenKey, bob, 100)279280 uassert.Equal(t, int64(1_000), MustGet(tokenKey).BalanceOf(alice))281 uassert.Equal(t, int64(400), MustGet(tokenKey).BalanceOf(consumer))282}283284// TestTransferFromSpendsAllowanceGrantedToCallingRealm records the semantic285// shift that comes with binding the actor to the caller: the owner must have286// approved the CALLING REALM, not the signing user. An allowance granted to287// 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)296297 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, "")301302 // 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))308309 // alice approves the consuming realm itself, and only then does it work.310 urequire.NoError(t, ledger.Approve(alice, consumer, 100))311312 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}317Approve(int, rlm interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, tokenKey string, spender string, amount int64)
Get(key string) *gno.land/p/nt/grc20/v0.Token
GetRegistry() *gno.land/p/nt/avl/rotree/v0.ReadOnlyTree
MustGet(key string) *gno.land/p/nt/grc20/v0.Token
Register(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, token *gno.land/p/nt/grc20/v0.Token, slug string) string
Render(path string) string
Transfer(int, rlm interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, tokenKey string, to string, amount int64)
TransferFrom(int, rlm interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, tokenKey string, from string, to string, amount int64)
Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.
vm/qrender output, sanitized (docs/render-security.md) and displayed in an empty-sandbox iframe — scripts, forms and popups cannot run. Links stay inert in-preview; right-click to open.