Transaction

9AAF5B08FAEB5A…2DAAC220B8F0

Block 747,764 · index 0 · indexed

Summary

Hash
9AAF5B08FAEB5A595CD48A15467BC1B3E44BCE518D1440A430492DAAC220B8F0
Block
747,764
Size
40966 bytes
Gas used
57,783,890 / 115,567,700
Fee
115568 ugnot
Status
success

Messages

#1AddPackagegno.land/r/akkadia/beta/craft17 arguments

Arguments · 17

  1. #1craft
  2. #2craft.gno
  3. #3package craft import ( "chain" "chain/banker" "chain/runtime/unsafe" "strconv" "gno.land/p/akkadia/beta/accesscontrol" "gno.land/p/nt/bptree/v0" "gno.land/r/akkadia/beta/admin" "gno.land/r/akkadia/beta/equipment" "gno.land/r/akkadia/beta/item" ) const ( SetRecipeEvent = "SetRecipe" DeleteRecipeEvent = "DeleteRecipe" DeleteRecipesEvent = "DeleteRecipes" SetListLimitEvent = "SetListLimit" SetCraftFeeEvent = "SetCraftFee" CraftEvent = "Craft" ) var ( recipes = bptree.NewBPTree32() listLimit = defaultRecipeListLimit craftFee = defaultCraftFee ) func init() { resetCraftState() } func resetCraftState() { recipes = bptree.NewBPTree32() listLimit = defaultRecipeListLimit craftFee = defaultCraftFee } // SetRecipe stores the item definition ID produced by a canonical recipe. func SetRecipe(cur realm, recipe string, itemID uint32) { accesscontrol.AssertIsAdmin(0, cur, admin.IsAdmin) assertCraftNoCoins() canonicalRecipe, _ := mustParseRecipe(recipe) setRecipe(canonicalRecipe, itemID) } // SetRecipes stores up to 100 canonical recipe and item ID pairs in one transaction. func SetRecipes(cur realm, recipesCSV, itemIDsCSV string) { accesscontrol.AssertIsAdmin(0, cur, admin.IsAdmin) assertCraftNoCoins() canonicalRecipes, itemIDs := mustParseRecipeCSV(recipesCSV, itemIDsCSV) for i, recipe := range canonicalRecipes { setRecipe(recipe, itemIDs[i]) } } func setRecipe(canonicalRecipe string, itemID uint32) { recipes.Set(canonicalRecipe, itemID) chain.Emit( SetRecipeEvent, "recipe", canonicalRecipe, "itemId", strconv.FormatUint(uint64(itemID), 10), ) } func DeleteRecipe(cur realm, recipe string) { accesscontrol.AssertIsAdmin(0, cur, admin.IsAdmin) assertCraftNoCoins() canonicalRecipe := mustCanonicalRecipe(recipe) deleteRecipe(canonicalRecipe) chain.Emit(DeleteRecipeEvent, "recipe", canonicalRecipe) } func DeleteRecipes(cur realm, recipesCSV string) { accesscontrol.AssertIsAdmin(0, cur, admin.IsAdmin) assertCraftNoCoins() canonicalRecipes := mustParseRecipeKeyCSV(recipesCSV) for _, recipe := range canonicalRecipes { if !recipes.Has(recipe) { panic("recipe not found: " + recipe) } } for _, recipe := range canonicalRecipes { deleteRecipe(recipe) } chain.Emit(DeleteRecipesEvent, "count", strconv.Itoa(len(canonicalRecipes))) } func deleteRecipe(canonicalRecipe string) { if _, removed := recipes.Remove(canonicalRecipe); !removed { panic("recipe not found: " + canonicalRecipe) } } func HasRecipe(recipe string) bool { canonical, _, errMsg := parseRecipe(recipe) if errMsg != "" { return false } return recipes.Has(canonical) } func GetRecipe(recipe string) uint32 { return mustGetRecipeItemID(mustCanonicalRecipe(recipe)) } func GetRecipeCount() int { return recipes.Size() } func ListRecipes(page, count int) []map[string]string { assertRecipeListPageCount(page, count) result := []map[string]string{} recipes.IterateByOffset((page-1)*count, count, func(recipe string, value any) bool { result = append(result, map[string]string{ "recipe": recipe, "itemId": strconv.FormatUint(uint64(value.(uint32)), 10), }) return false }) return result } func SetListLimit(cur realm, limit int) { accesscontrol.AssertIsAdmin(0, cur, admin.IsAdmin) assertCraftNoCoins() if limit < 1 { panic("limit must be at least 1") } oldLimit := listLimit listLimit = limit chain.Emit( SetListLimitEvent, "oldLimit", strconv.Itoa(oldLimit), "newLimit", strconv.Itoa(limit), ) } func GetListLimit() int { return listLimit } func SetCraftFee(cur realm, fee int64) { accesscontrol.AssertIsAdmin(0, cur, admin.IsAdmin) assertCraftNoCoins() if fee < 0 { panic("fee must be non-negative") } oldFee := craftFee craftFee = fee chain.Emit( SetCraftFeeEvent, "oldFee", strconv.FormatInt(oldFee, 10), "newFee", strconv.FormatInt(fee, 10), ) } func GetCraftFee() int64 { return craftFee } // Craft burns a canonical recipe and mints the next GRC721 equipment token. func Craft(cur realm, recipe string) uint64 { caller := accesscontrol.MustGetUserCaller(0, cur) canonicalRecipe, ingredients := mustParseRecipe(recipe) itemID := mustGetRecipeItemID(canonicalRecipe) assertExactUgnotPayment(craftFee) for _, ingredient := range ingredients { item.BurnFromRealm(cross(cur), caller, ingredient.TokenID, ingredient.Amount) } tokenID := equipment.MintFromRealm(cross(cur), caller, itemID, "", "") if craftFee > 0 { bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur) bnk.SendCoins(cur.Address(), admin.GetFeeCollector(), chain.Coins{{"ugnot", craftFee}}) } chain.Emit( CraftEvent, "owner", caller.String(), "recipe", canonicalRecipe, "equipmentTokenId", strconv.FormatUint(tokenID, 10), "itemId", strconv.FormatUint(uint64(itemID), 10), "fee", strconv.FormatInt(craftFee, 10), ) return tokenID } func mustCanonicalRecipe(recipe string) string { canonical, _ := mustParseRecipe(recipe) return canonical } func mustGetRecipeItemID(recipe string) uint32 { value := recipes.Get(recipe) if value == nil { panic("recipe not found: " + recipe) } return value.(uint32) } func assertCraftNoCoins() { if len(unsafe.OriginSend()) != 0 { panic("attached coins not allowed") } } func assertExactUgnotPayment(expected int64) { coins := unsafe.OriginSend() if expected == 0 { if len(coins) != 0 { panic("exact payment required: expected no coins, got " + coins.String()) } return } if len(coins) != 1 || coins[0].Denom != "ugnot" || coins[0].Amount != expected { panic("exact payment required: expected " + strconv.FormatInt(expected, 10) + "ugnot, got " + coins.String()) } }
  4. #4craft_test.gno
  5. #5package craft import ( "chain" "testing" "gno.land/p/nt/uassert/v0" "gno.land/r/akkadia/beta/equipment" "gno.land/r/akkadia/beta/item" ) func TestSetRecipeCreatesAndUpdatesRecipe(cur realm, t *testing.T) { setupCraftTest(cross(cur), t) testing.SetOriginCaller(craftTest.Admin) testing.SetRealm(testing.NewUserRealm(craftTest.Admin)) SetRecipe(cross(cur), "0001|minecraft:iron_ingot:01", 100) uassert.True(t, HasRecipe("0001|minecraft:iron_ingot:1")) uassert.True(t, HasRecipe("0001|minecraft:iron_ingot:01")) uassert.True(t, HasRecipe("0001|0:minecraft:iron_ingot:1")) uassert.Equal(t, uint32(100), GetRecipe("0001|minecraft:iron_ingot:01")) uassert.Equal(t, 1, GetRecipeCount()) SetRecipe(cross(cur), "0001|0:minecraft:iron_ingot:1", 101) uassert.Equal(t, uint32(101), GetRecipe("0001|minecraft:iron_ingot:1")) uassert.Equal(t, 1, GetRecipeCount()) SetRecipe(cross(cur), "000A|craft-hex-a:1|craft-hex-b:2", 102) uassert.True(t, HasRecipe("000a|craft-hex-a:1|craft-hex-b:2")) uassert.Equal(t, uint32(102), GetRecipe("000A|craft-hex-a:1|craft-hex-b:2")) uassert.Equal(t, 2, GetRecipeCount()) } func TestSetRecipeRequiresDirectAdminWithoutCoins(cur realm, t *testing.T) { setupCraftTest(cross(cur), t) recipe := "0001|craft-security-item:1" testing.SetOriginCaller(craftTest.Owner) testing.SetRealm(testing.NewUserRealm(craftTest.Owner)) uassert.AbortsWithMessage(t, cur, "admin access required", func() { SetRecipe(cross(cur), recipe, 200) }) uassert.False(t, HasRecipe(recipe)) testing.SetOriginCaller(craftTest.Admin) testing.SetRealm(testing.NewCodeRealm("gno.land/r/example/craft_admin_relay")) uassert.AbortsWithMessage(t, cur, "admin access required", func() { SetRecipe(cross(cur), recipe, 200) }) uassert.False(t, HasRecipe(recipe)) testing.SetOriginCaller(craftTest.Admin) testing.SetRealm(testing.NewUserRealm(craftTest.Admin)) testing.SetOriginSend(chain.Coins{{"ugnot", 1}}) uassert.AbortsWithMessage(t, cur, "attached coins not allowed", func() { SetRecipe(cross(cur), recipe, 200) }) testing.SetOriginSend(chain.Coins{}) uassert.False(t, HasRecipe(recipe)) } func TestSetRecipesStoresMultipleRecipes(cur realm, t *testing.T) { setupCraftTest(cross(cur), t) testing.SetOriginCaller(craftTest.Admin) testing.SetRealm(testing.NewUserRealm(craftTest.Admin)) SetRecipes( cross(cur), "0001|craft-batch-iron:1,0002|craft-batch-gold:2", "300,301", ) uassert.Equal(t, uint32(300), GetRecipe("0001|craft-batch-iron:1")) uassert.Equal(t, uint32(301), GetRecipe("0002|craft-batch-gold:2")) } func TestSetRecipesStoresOccupancyBatch(cur realm, t *testing.T) { setupCraftTest(cross(cur), t) testing.SetOriginCaller(craftTest.Admin) testing.SetRealm(testing.NewUserRealm(craftTest.Admin)) SetRecipes( cross(cur), "0013|test-iron:1|test-iron:2|test-stick:2,"+ "0003|test-wood:2|test-stone:1,"+ "0001|test-herb:3,"+ "0007|test-copper:1|test-leather:2|test-cloth:1", "1101000,1101001,1101002,1101003", ) uassert.Equal(t, 4, GetRecipeCount()) uassert.Equal(t, uint32(1101000), GetRecipe("0013|test-iron:1|test-iron:2|test-stick:2")) uassert.Equal(t, uint32(1101001), GetRecipe("0003|test-wood:2|test-stone:1")) uassert.Equal(t, uint32(1101002), GetRecipe("0001|test-herb:3")) uassert.Equal(t, uint32(1101003), GetRecipe("0007|test-copper:1|test-leather:2|test-cloth:1")) uassert.True(t, HasRecipe("0013|0:test-iron:1|1:test-iron:2|4:test-stick:2")) uassert.Equal(t, 4, len(ListRecipes(1, 20))) } func TestSetRecipesAllowsLastDuplicateToWin(cur realm, t *testing.T) { setupCraftTest(cross(cur), t) testing.SetOriginCaller(craftTest.Admin) testing.SetRealm(testing.NewUserRealm(craftTest.Admin)) recipe := "0001|craft-batch-duplicate:1" SetRecipes(cross(cur), recipe+","+recipe, "310,311") uassert.Equal(t, uint32(311), GetRecipe(recipe)) } func TestSetRecipesRequiresDirectAdmin(cur realm, t *testing.T) { setupCraftTest(cross(cur), t) recipe := "0001|craft-batch-security:1" testing.SetOriginCaller(craftTest.Owner) testing.SetRealm(testing.NewUserRealm(craftTest.Owner)) uassert.AbortsWithMessage(t, cur, "admin access required", func() { SetRecipes(cross(cur), recipe, "320") }) uassert.False(t, HasRecipe(recipe)) testing.SetOriginCaller(craftTest.Admin) testing.SetRealm(testing.NewCodeRealm("gno.land/r/example/craft_batch_relay")) uassert.AbortsWithMessage(t, cur, "admin access required", func() { SetRecipes(cross(cur), recipe, "320") }) uassert.False(t, HasRecipe(recipe)) } func TestSetRecipesRejectsCountMismatchAndRollsBack(cur realm, t *testing.T) { setupCraftTest(cross(cur), t) testing.SetOriginCaller(craftTest.Admin) testing.SetRealm(testing.NewUserRealm(craftTest.Admin)) firstRecipe := "0001|craft-mismatch-a:1" secondRecipe := "0001|craft-mismatch-b:1" uassert.AbortsWithMessage(t, cur, "recipes and item ids count mismatch", func() { SetRecipes(cross(cur), firstRecipe+","+secondRecipe, "400") }) uassert.False(t, HasRecipe(firstRecipe)) uassert.False(t, HasRecipe(secondRecipe)) } func TestSetRecipesRejectsInvalidItemID(cur realm, t *testing.T) { setupCraftTest(cross(cur), t) testing.SetOriginCaller(craftTest.Admin) testing.SetRealm(testing.NewUserRealm(craftTest.Admin)) recipe := "0001|craft-invalid-id:1" uassert.AbortsWithMessage(t, cur, "invalid item id: invalid", func() { SetRecipes(cross(cur), recipe, "invalid") }) uassert.False(t, HasRecipe(recipe)) } func TestSetRecipesRejectsEmptyTokensAndTrailingComma(cur realm, t *testing.T) { setupCraftTest(cross(cur), t) testing.SetOriginCaller(craftTest.Admin) testing.SetRealm(testing.NewUserRealm(craftTest.Admin)) recipe := "0001|craft-empty-token:1" uassert.AbortsWithMessage(t, cur, "empty recipe not allowed", func() { SetRecipes(cross(cur), recipe+",", "410,411") }) uassert.False(t, HasRecipe(recipe)) uassert.AbortsWithMessage(t, cur, "empty item id not allowed", func() { SetRecipes(cross(cur), recipe+",0001|craft-empty-id:1", "412,") }) uassert.False(t, HasRecipe(recipe)) } func TestSetRecipesRejectsBatchAboveLimit(cur realm, t *testing.T) { setupCraftTest(cross(cur), t) testing.SetOriginCaller(craftTest.Admin) testing.SetRealm(testing.NewUserRealm(craftTest.Admin)) recipe := "0001|craft-batch-limit:1" recipesCSV := recipe itemIDsCSV := "500" for i := 1; i < 101; i++ { recipesCSV += "," + recipe itemIDsCSV += ",500" } uassert.AbortsWithMessage(t, cur, "recipes exceeds 100", func() { SetRecipes(cross(cur), recipesCSV, itemIDsCSV) }) uassert.False(t, HasRecipe(recipe)) } func TestDeleteRecipeRemovesRecipe(cur realm, t *testing.T) { setupCraftTest(cross(cur), t) testing.SetOriginCaller(craftTest.Admin) testing.SetRealm(testing.NewUserRealm(craftTest.Admin)) recipe := "0001|craft-delete-item:1" SetRecipe(cross(cur), recipe, 600) DeleteRecipe(cross(cur), "0001|craft-delete-item:01") uassert.False(t, HasRecipe(recipe)) uassert.Equal(t, 0, GetRecipeCount()) } func TestDeleteRecipesRemovesBatchAndRequiresAdmin(cur realm, t *testing.T) { setupCraftTest(cross(cur), t) testing.SetOriginCaller(craftTest.Admin) testing.SetRealm(testing.NewUserRealm(craftTest.Admin)) firstRecipe := "0001|craft-delete-a:1" secondRecipe := "0002|craft-delete-b:1" SetRecipe(cross(cur), firstRecipe, 601) SetRecipe(cross(cur), secondRecipe, 602) testing.SetOriginCaller(craftTest.Owner) testing.SetRealm(testing.NewUserRealm(craftTest.Owner)) uassert.AbortsWithMessage(t, cur, "admin access required", func() { DeleteRecipes(cross(cur), firstRecipe) }) uassert.True(t, HasRecipe(firstRecipe)) testing.SetOriginCaller(craftTest.Admin) testing.SetRealm(testing.NewUserRealm(craftTest.Admin)) DeleteRecipes(cross(cur), firstRecipe+","+secondRecipe) uassert.False(t, HasRecipe(firstRecipe)) uassert.False(t, HasRecipe(secondRecipe)) } func TestDeleteRecipesRejectsMissingAndRollsBack(cur realm, t *testing.T) { setupCraftTest(cross(cur), t) testing.SetOriginCaller(craftTest.Admin) testing.SetRealm(testing.NewUserRealm(craftTest.Admin)) firstRecipe := "0001|craft-delete-missing-a:1" secondRecipe := "0002|craft-delete-missing-b:1" SetRecipe(cross(cur), firstRecipe, 603) SetRecipe(cross(cur), secondRecipe, 604) uassert.AbortsWithMessage(t, cur, "recipe not found: 0001|0:craft-delete-missing-c:1", func() { DeleteRecipes(cross(cur), firstRecipe+",0001|craft-delete-missing-c:1") }) uassert.True(t, HasRecipe(firstRecipe)) uassert.True(t, HasRecipe(secondRecipe)) } func TestDeleteRecipeRejectsMissingRecipe(cur realm, t *testing.T) { setupCraftTest(cross(cur), t) testing.SetOriginCaller(craftTest.Admin) testing.SetRealm(testing.NewUserRealm(craftTest.Admin)) uassert.AbortsWithMessage(t, cur, "recipe not found: 0001|0:craft-missing-delete:1", func() { DeleteRecipe(cross(cur), "0001|craft-missing-delete:1") }) } func TestListRecipesPagesAndCopiesRows(cur realm, t *testing.T) { setupCraftTest(cross(cur), t) testing.SetOriginCaller(craftTest.Admin) testing.SetRealm(testing.NewUserRealm(craftTest.Admin)) SetRecipe(cross(cur), "0001|craft-list-a:1", 701) SetRecipe(cross(cur), "0002|craft-list-b:1", 702) SetRecipe(cross(cur), "0004|craft-list-c:1", 703) uassert.Equal(t, 3, GetRecipeCount()) page1 := ListRecipes(1, 2) uassert.Equal(t, 2, len(page1)) page1[0]["itemId"] = "hacked" uassert.Equal(t, "701", ListRecipes(1, 2)[0]["itemId"]) page2 := ListRecipes(2, 2) uassert.Equal(t, 1, len(page2)) uassert.Equal(t, 0, len(ListRecipes(3, 2))) uassert.PanicsWithMessage(t, cur, "page must be at least 1", func() { ListRecipes(0, 1) }) uassert.PanicsWithMessage(t, cur, "count must be at least 1", func() { ListRecipes(1, 0) }) uassert.PanicsWithMessage(t, cur, "count exceeds listLimit (max: 100)", func() { ListRecipes(1, 101) }) } func TestSetListLimit(cur realm, t *testing.T) { setupCraftTest(cross(cur), t) testing.SetOriginCaller(craftTest.Admin) testing.SetRealm(testing.NewUserRealm(craftTest.Admin)) SetListLimit(cross(cur), 1) uassert.Equal(t, 1, GetListLimit()) uassert.PanicsWithMessage(t, cur, "count exceeds listLimit (max: 1)", func() { ListRecipes(1, 2) }) uassert.AbortsWithMessage(t, cur, "limit must be at least 1", func() { SetListLimit(cross(cur), 0) }) testing.SetOriginCaller(craftTest.Owner) testing.SetRealm(testing.NewUserRealm(craftTest.Owner)) uassert.AbortsWithMessage(t, cur, "admin access required", func() { SetListLimit(cross(cur), 50) }) } func TestMustParseRecipeCanonicalizesSlotsAndCountsIngredients(cur realm, t *testing.T) { canonical, ingredients := mustParseRecipe("0013|minecraft:iron_ingot:01|minecraft:iron_ingot:2|minecraft:stick:3") uassert.Equal(t, "0013|0:minecraft:iron_ingot:1|1:minecraft:iron_ingot:2|4:minecraft:stick:3", canonical) uassert.Equal(t, 3, len(ingredients)) uassert.Equal(t, uint(0), ingredients[0].Slot) uassert.Equal(t, "minecraft:iron_ingot", ingredients[0].TokenID) uassert.Equal(t, int64(1), ingredients[0].Amount) uassert.Equal(t, uint(4), ingredients[2].Slot) uassert.Equal(t, "minecraft:stick", ingredients[2].TokenID) uassert.Equal(t, int64(3), ingredients[2].Amount) roundTrip, _ := mustParseRecipe(canonical) uassert.Equal(t, canonical, roundTrip) hexCanonical, hexIngredients := mustParseRecipe("000A|craft-hex-a:1|craft-hex-b:2") uassert.Equal(t, "000a|1:craft-hex-a:1|3:craft-hex-b:2", hexCanonical) uassert.Equal(t, uint(1), hexIngredients[0].Slot) uassert.Equal(t, uint(3), hexIngredients[1].Slot) } func TestMustParseRecipeRejectsInvalidRecipes(cur realm, t *testing.T) { tests := []struct { recipe, panicMsg string }{ {"", "invalid recipe"}, {"0000|item:1", "invalid recipe occupancy"}, {"0001|item", "invalid recipe ingredient"}, {"0001|item:0", "ingredient amount must be positive"}, {"0003|item:1", "occupied slots and ingredients count mismatch"}, {"0001|1:item:1", "ingredient slot mismatch"}, {"0013|4:stick:1|0:iron:1|1:iron:1", "ingredient slot mismatch"}, {"0001|%zz:1", "invalid item token id"}, } for _, tc := range tests { recipe, panicMsg := tc.recipe, tc.panicMsg uassert.PanicsWithMessage(t, cur, panicMsg, func() { mustParseRecipe(recipe) }) } } func TestHasRecipeReturnsFalseForInvalidAndMissingRecipes(cur realm, t *testing.T) { setupCraftTest(cross(cur), t) uassert.False(t, HasRecipe("")) uassert.False(t, HasRecipe("not-a-recipe")) uassert.False(t, HasRecipe("0001|craft-missing-query:1")) uassert.PanicsWithMessage(t, cur, "recipe not found: 0001|0:craft-missing-query:1", func() { GetRecipe("0001|craft-missing-query:1") }) } func TestCraftBurnsItemsAndMintsEquipment(cur realm, t *testing.T) { setupCraftTest(cross(cur), t) recipe := "0013|craft-success-iron:1|craft-success-iron:2|craft-success-stick:2" testing.SetOriginCaller(craftTest.Admin) testing.SetRealm(testing.NewUserRealm(craftTest.Admin)) SetRecipe(cross(cur), recipe, 1101000) mintCraftItem(cross(cur), "craft-success-iron", 3) mintCraftItem(cross(cur), "craft-success-stick", 2) testing.SetOriginCaller(craftTest.Owner) testing.SetRealm(testing.NewUserRealm(craftTest.Owner)) before := equipment.GetLatestTokenID() collectorBefore := ugnotBalanceForTest(craftTest.FeeCollector) realmBefore := ugnotBalanceForTest(craftTest.PkgAddr) payCraftFee(defaultCraftFeeCoins()) tokenID := Craft(cross(cur), "0013|craft-success-iron:01|craft-success-iron:2|craft-success-stick:2") uassert.Equal(t, before+1, tokenID) uassert.Equal(t, int64(0), item.BalanceOf(craftTest.Owner, "craft-success-iron")) uassert.Equal(t, int64(0), item.BalanceOf(craftTest.Owner, "craft-success-stick")) uassert.Equal(t, craftTest.Owner, equipment.OwnerOf(tokenID)) uassert.Equal(t, "1101000", equipment.GetMetadata(tokenID)["definitionId"]) uassert.Equal(t, defaultCraftFee, ugnotBalanceForTest(craftTest.FeeCollector)-collectorBefore) uassert.Equal(t, realmBefore, ugnotBalanceForTest(craftTest.PkgAddr)) } func TestCraftAllocatesSequentialEquipmentTokenIDs(cur realm, t *testing.T) { setupCraftTest(cross(cur), t) recipe := "0001|craft-skip-id:1" testing.SetOriginCaller(craftTest.Admin) testing.SetRealm(testing.NewUserRealm(craftTest.Admin)) SetRecipe(cross(cur), recipe, 1101003) mintCraftItem(cross(cur), "craft-skip-id", 2) before := equipment.GetLatestTokenID() equipment.Mint(cross(cur), craftTest.Other, 1101998, "", "") testing.SetOriginCaller(craftTest.Owner) testing.SetRealm(testing.NewUserRealm(craftTest.Owner)) payCraftFee(defaultCraftFeeCoins()) tokenID := Craft(cross(cur), recipe) uassert.Equal(t, before+2, tokenID) uassert.Equal(t, craftTest.Other, equipment.OwnerOf(before+1)) uassert.Equal(t, craftTest.Owner, equipment.OwnerOf(tokenID)) } func TestCraftRequiresEquipmentMinterRealm(cur realm, t *testing.T) { setupCraftTest(cross(cur), t) recipe := "0001|craft-mint-rollback:2" testing.SetOriginCaller(craftTest.Admin) testing.SetRealm(testing.NewUserRealm(craftTest.Admin)) SetRecipe(cross(cur), recipe, 1101001) mintCraftItem(cross(cur), "craft-mint-rollback", 2) equipment.UnsetMinterRealm(cross(cur), "gno.land/r/akkadia/beta/craft") testing.SetOriginCaller(craftTest.Owner) testing.SetRealm(testing.NewUserRealm(craftTest.Owner)) uassert.AbortsWithMessage(t, cur, "minter realm required", func() { payCraftFee(defaultCraftFeeCoins()) Craft(cross(cur), recipe) }) } func TestCraftRequiresItemMinterRealm(cur realm, t *testing.T) { setupCraftTest(cross(cur), t) recipe := "0001|craft-item-minter:1" testing.SetOriginCaller(craftTest.Admin) testing.SetRealm(testing.NewUserRealm(craftTest.Admin)) SetRecipe(cross(cur), recipe, 1101005) mintCraftItem(cross(cur), "craft-item-minter", 1) item.UnsetMinterRealm(cross(cur), "gno.land/r/akkadia/beta/craft") testing.SetOriginCaller(craftTest.Owner) testing.SetRealm(testing.NewUserRealm(craftTest.Owner)) uassert.AbortsWithMessage(t, cur, "minter realm required", func() { payCraftFee(defaultCraftFeeCoins()) Craft(cross(cur), recipe) }) uassert.Equal(t, int64(1), item.BalanceOf(craftTest.Owner, "craft-item-minter")) } func TestCraftRejectsUnknownRecipeAndInsufficientItems(cur realm, t *testing.T) { setupCraftTest(cross(cur), t) recipe := "0001|craft-fail-item:2" testing.SetOriginCaller(craftTest.Owner) testing.SetRealm(testing.NewUserRealm(craftTest.Owner)) uassert.AbortsWithMessage(t, cur, "recipe not found: 0001|0:craft-fail-item:2", func() { Craft(cross(cur), recipe) }) uassert.AbortsWithMessage(t, cur, "invalid recipe", func() { Craft(cross(cur), "bad") }) testing.SetOriginCaller(craftTest.Admin) testing.SetRealm(testing.NewUserRealm(craftTest.Admin)) SetRecipe(cross(cur), recipe, 1101004) mintCraftItem(cross(cur), "craft-fail-item", 1) testing.SetOriginCaller(craftTest.Owner) testing.SetRealm(testing.NewUserRealm(craftTest.Owner)) uassert.AbortsWithMessage(t, cur, "item burn amount exceeds supply", func() { payCraftFee(defaultCraftFeeCoins()) Craft(cross(cur), recipe) }) uassert.Equal(t, int64(1), item.BalanceOf(craftTest.Owner, "craft-fail-item")) } func TestCraftRequiresDirectUserAndExactFee(cur realm, t *testing.T) { setupCraftTest(cross(cur), t) recipe := "0001|craft-craft-security:1" testing.SetOriginCaller(craftTest.Admin) testing.SetRealm(testing.NewUserRealm(craftTest.Admin)) SetRecipe(cross(cur), recipe, 1101002) before := equipment.GetLatestTokenID() testing.SetOriginCaller(craftTest.Owner) testing.SetRealm(testing.NewCodeRealm("gno.land/r/example/craft_craft_relay")) uassert.AbortsWithMessage(t, cur, "contract calls not allowed: function must be called directly by user", func() { Craft(cross(cur), recipe) }) testing.SetOriginCaller(craftTest.Owner) testing.SetRealm(testing.NewUserRealm(craftTest.Owner)) uassert.AbortsWithMessage(t, cur, "exact payment required: expected 1000000ugnot, got ", func() { Craft(cross(cur), recipe) }) uassert.AbortsWithMessage(t, cur, "exact payment required: expected 1000000ugnot, got 1ugnot", func() { payCraftFee(chain.Coins{{"ugnot", 1}}) Craft(cross(cur), recipe) }) testing.SetOriginSend(chain.Coins{}) uassert.Equal(t, before, equipment.GetLatestTokenID()) } func TestCraftRejectsInexactPayment(cur realm, t *testing.T) { setupCraftTest(cross(cur), t) recipe := "0001|craft-inexact-fee:1" testing.SetOriginCaller(craftTest.Admin) testing.SetRealm(testing.NewUserRealm(craftTest.Admin)) SetRecipe(cross(cur), recipe, 1101006) mintCraftItem(cross(cur), "craft-inexact-fee", 3) before := equipment.GetLatestTokenID() testing.SetOriginCaller(craftTest.Owner) testing.SetRealm(testing.NewUserRealm(craftTest.Owner)) uassert.AbortsWithMessage(t, cur, "exact payment required: expected 1000000ugnot, got 999999ugnot", func() { payCraftFee(chain.Coins{{"ugnot", 999_999}}) Craft(cross(cur), recipe) }) uassert.AbortsWithMessage(t, cur, "exact payment required: expected 1000000ugnot, got 1000001ugnot", func() { payCraftFee(chain.Coins{{"ugnot", 1_000_001}}) Craft(cross(cur), recipe) }) uassert.AbortsWithMessage(t, cur, "exact payment required: expected 1000000ugnot, got 1000000ugnot,1foo", func() { payCraftFee(chain.Coins{{"ugnot", defaultCraftFee}, {"foo", 1}}) Craft(cross(cur), recipe) }) uassert.Equal(t, before, equipment.GetLatestTokenID()) uassert.Equal(t, int64(3), item.BalanceOf(craftTest.Owner, "craft-inexact-fee")) } func TestSetCraftFeeUpdatesFeeAndAllowsFreeCraft(cur realm, t *testing.T) { setupCraftTest(cross(cur), t) uassert.Equal(t, defaultCraftFee, GetCraftFee()) testing.SetOriginCaller(craftTest.Admin) testing.SetRealm(testing.NewUserRealm(craftTest.Admin)) SetCraftFee(cross(cur), 2_000_000) uassert.Equal(t, int64(2_000_000), GetCraftFee()) SetCraftFee(cross(cur), 0) uassert.Equal(t, int64(0), GetCraftFee()) recipe := "0001|craft-free-fee:1" SetRecipe(cross(cur), recipe, 1101007) mintCraftItem(cross(cur), "craft-free-fee", 1) testing.SetOriginCaller(craftTest.Owner) testing.SetRealm(testing.NewUserRealm(craftTest.Owner)) uassert.AbortsWithMessage(t, cur, "exact payment required: expected no coins, got 1ugnot", func() { payCraftFee(chain.Coins{{"ugnot", 1}}) Craft(cross(cur), recipe) }) collectorBefore := ugnotBalanceForTest(craftTest.FeeCollector) payCraftFee(chain.Coins{}) tokenID := Craft(cross(cur), recipe) uassert.Equal(t, craftTest.Owner, equipment.OwnerOf(tokenID)) uassert.Equal(t, collectorBefore, ugnotBalanceForTest(craftTest.FeeCollector)) } func TestSetCraftFeeRequiresDirectAdminWithoutCoins(cur realm, t *testing.T) { setupCraftTest(cross(cur), t) uassert.Equal(t, defaultCraftFee, GetCraftFee()) testing.SetOriginCaller(craftTest.Owner) testing.SetRealm(testing.NewUserRealm(craftTest.Owner)) uassert.AbortsWithMessage(t, cur, "admin access required", func() { SetCraftFee(cross(cur), 50) }) uassert.Equal(t, defaultCraftFee, GetCraftFee()) testing.SetOriginCaller(craftTest.Admin) testing.SetRealm(testing.NewCodeRealm("gno.land/r/example/craft_fee_relay")) uassert.AbortsWithMessage(t, cur, "admin access required", func() { SetCraftFee(cross(cur), 50) }) uassert.Equal(t, defaultCraftFee, GetCraftFee()) testing.SetOriginCaller(craftTest.Admin) testing.SetRealm(testing.NewUserRealm(craftTest.Admin)) testing.SetOriginSend(chain.Coins{{"ugnot", 1}}) uassert.AbortsWithMessage(t, cur, "attached coins not allowed", func() { SetCraftFee(cross(cur), 50) }) testing.SetOriginSend(chain.Coins{}) uassert.Equal(t, defaultCraftFee, GetCraftFee()) uassert.AbortsWithMessage(t, cur, "fee must be non-negative", func() { SetCraftFee(cross(cur), -1) }) uassert.Equal(t, defaultCraftFee, GetCraftFee()) }
  6. #6gnomod.toml
  7. #7module = "gno.land/r/akkadia/beta/craft" gno = "0.9"
  8. #8render.gno
  9. #9package craft import ( "net/url" "strconv" "strings" ) func Render(iURL string) string { u, err := url.Parse(iURL) if err != nil { return renderRecipeError("Invalid URL") } switch u.Path { case "": return renderRecipeHome() case "recipe": return renderRecipeResult(u.Query().Get("recipe")) case "recipes": return renderRecipeList(u.Query().Get("page")) default: return renderRecipeError("Not found") } } func renderRecipeHome() string { return `# AKKADIA Recipe Ingredients are bound to occupied occupancy bits from slot 0 upward. Current craft fee is ` + strconv.FormatInt(GetCraftFee(), 10) + `ugnot. Enter a recipe to find its item ID. <gno-form path="recipe"> <gno-input name="recipe" placeholder="0013|iron:1|iron:2|stick:3" /> </gno-form> * [Browse Recipes](./craft:recipes) ` } func renderRecipeList(rawPage string) string { page := 1 if rawPage != "" { parsed, err := strconv.Atoi(rawPage) if err != nil || parsed < 1 { return renderRecipeError("Invalid page") } page = parsed } count := 20 if count > listLimit { count = listLimit } output := `# Recipes [← Back](./craft:) ` total := GetRecipeCount() if total == 0 { return output + `*No recipes stored yet.* ` } rows := ListRecipes(page, count) if len(rows) == 0 { return renderRecipeError("Invalid page") } for _, row := range rows { output += "* " + formatRecipeCode(row["recipe"]) + " → " + row["itemId"] + "\n" } return output } func renderRecipeResult(recipe string) string { if recipe == "" { return renderRecipeError("Recipe is required") } canonical, _, errMsg := parseRecipe(recipe) if errMsg != "" { return renderRecipeError("Invalid recipe") } if !recipes.Has(canonical) { return renderRecipeError("Recipe not found") } return `# Recipe Result * **Recipe**: ` + formatRecipeCode(canonical) + ` * **Item ID**: ` + strconv.FormatUint(uint64(mustGetRecipeItemID(canonical)), 10) + ` [← Back](./craft:) ` } func renderRecipeError(message string) string { return `# Error > ` + message + ` [← Back](./craft:) ` } // Recipes use | and - as syntax. A code span keeps those characters // literal so gnoweb does not show backslash escapes. func formatRecipeCode(recipe string) string { recipe = strings.ReplaceAll(recipe, "\n", " ") fence := "`" for strings.Contains(recipe, fence) { fence += "`" } return fence + recipe + fence }
  10. #10render_test.gno
  11. #11package craft import ( "strings" "testing" "gno.land/p/nt/uassert/v0" ) func TestRenderRecipeLookup(cur realm, t *testing.T) { setupCraftTest(cross(cur), t) testing.SetOriginCaller(craftTest.Admin) testing.SetRealm(testing.NewUserRealm(craftTest.Admin)) SetRecipe(cross(cur), "0001|craft-render-item:01", 1200) output := Render("recipe?recipe=0001%7Ccraft-render-item%3A1") uassert.True(t, strings.Contains(output, "Item ID**: 1200"), "should render the mapped item ID") uassert.True(t, strings.Contains(output, "`0001|0:craft-render-item:1`"), "should render the canonical recipe as a code span") uassert.False(t, strings.Contains(output, `\|`), "recipe code span should keep pipes literal") } func TestRenderRecipeErrors(cur realm, t *testing.T) { setupCraftTest(cross(cur), t) empty := Render("recipe") uassert.True(t, strings.Contains(empty, "Recipe is required"), "should require a recipe") invalid := Render("recipe?recipe=bad") uassert.True(t, strings.Contains(invalid, "Invalid recipe"), "should reject an invalid recipe") missing := Render("recipe?recipe=0001%7Ccraft-missing-item%3A1") uassert.True(t, strings.Contains(missing, "Recipe not found"), "should report a missing recipe") unknown := Render("unknown") uassert.True(t, strings.Contains(unknown, "Not found"), "should reject an unknown route") } func TestRenderRecipeHomeAndList(cur realm, t *testing.T) { setupCraftTest(cross(cur), t) home := Render("") uassert.True(t, strings.Contains(home, `<gno-form path="recipe">`), "should render the recipe lookup form") uassert.True(t, strings.Contains(home, `name="recipe"`), "should render the recipe input") uassert.True(t, strings.Contains(home, `./craft:recipes`), "should link to the recipe list") uassert.True(t, strings.Contains(home, "1000000ugnot"), "should render the default craft fee") emptyList := Render("recipes") uassert.True(t, strings.Contains(emptyList, "No recipes stored yet"), "should render an empty list") testing.SetOriginCaller(craftTest.Admin) testing.SetRealm(testing.NewUserRealm(craftTest.Admin)) SetRecipe(cross(cur), "0001|craft-list-render:1", 1300) listed := Render("recipes") uassert.True(t, strings.Contains(listed, "1300"), "should render stored item IDs") uassert.True(t, strings.Contains(listed, "`0001|0:craft-list-render:1`"), "should render listed recipes as a code span") uassert.False(t, strings.Contains(listed, `\|`), "listed recipe code span should keep pipes literal") }
  12. #12setup_test.gno
  13. #13package craft import ( "chain" "chain/banker" "testing" "gno.land/p/nt/testutils/v0" "gno.land/r/akkadia/beta/admin" "gno.land/r/akkadia/beta/equipment" "gno.land/r/akkadia/beta/item" ) var craftTest = struct { Admin, Owner, Other, FeeCollector, PkgAddr address SetupDone bool }{ Admin: testutils.TestAddress("craftAdmin"), Owner: testutils.TestAddress("craftOwner"), Other: testutils.TestAddress("craftOther"), FeeCollector: testutils.TestAddress("craftFeeCollector"), PkgAddr: chain.PackageAddress("gno.land/r/akkadia/beta/craft"), } func setupCraftTest(cur realm, t *testing.T) { t.Helper() resetCraftState() testing.SetOriginSend(chain.Coins{}) if !craftTest.SetupDone { originalAdmin := admin.GetAdmin() testing.SetOriginCaller(originalAdmin) testing.SetRealm(testing.NewUserRealm(originalAdmin)) admin.TransferAdmin(cross(cur), craftTest.Admin) testing.SetOriginCaller(craftTest.Admin) testing.SetRealm(testing.NewUserRealm(craftTest.Admin)) admin.AcceptAdmin(cross(cur)) admin.SetFeeCollector(cross(cur), craftTest.FeeCollector) craftTest.SetupDone = true } testing.SetOriginCaller(craftTest.Admin) testing.SetRealm(testing.NewUserRealm(craftTest.Admin)) item.SetMinterRealm(cross(cur), "gno.land/r/akkadia/beta/craft") equipment.SetMinterRealm(cross(cur), "gno.land/r/akkadia/beta/craft") } func mintCraftItem(cur realm, tokenID string, amount int64) { testing.SetOriginCaller(craftTest.Admin) testing.SetRealm(testing.NewUserRealm(craftTest.Admin)) item.SafeMint(cross(cur), craftTest.Owner, tokenID, amount) } func defaultCraftFeeCoins() chain.Coins { return chain.Coins{{"ugnot", defaultCraftFee}} } func payCraftFee(coins chain.Coins) { if len(coins) > 0 { testing.IssueCoins(craftTest.PkgAddr, coins) } testing.SetOriginSend(coins) } func ugnotBalanceForTest(addr address) int64 { return banker.NewReadonlyBanker().GetCoins(addr).AmountOf("ugnot") }
  14. #14validation.gno
  15. #15package craft import ( "net/url" "strconv" ) const ( recipeBatchLimit = 100 maxRecipeIngredients = 16 defaultRecipeListLimit = 100 defaultCraftFee int64 = 1_000_000 ) type craftIngredient struct { Slot uint TokenID string Amount int64 } func mustParseRecipeCSV(recipesCSV, itemIDsCSV string) ([]string, []uint32) { if recipesCSV == "" { panic("recipes is required") } if itemIDsCSV == "" { panic("item ids is required") } canonicalRecipes := []string{} itemIDs := []uint32{} recipeStart, recipeEnd := 0, 0 itemIDStart, itemIDEnd := 0, 0 for { for recipeEnd < len(recipesCSV) && recipesCSV[recipeEnd] != ',' { recipeEnd++ } for itemIDEnd < len(itemIDsCSV) && itemIDsCSV[itemIDEnd] != ',' { itemIDEnd++ } recipeToken := recipesCSV[recipeStart:recipeEnd] itemIDText := itemIDsCSV[itemIDStart:itemIDEnd] if recipeToken == "" { panic("empty recipe not allowed") } if itemIDText == "" { panic("empty item id not allowed") } canonicalRecipe, _ := mustParseRecipe(recipeToken) itemIDValue, err := strconv.ParseUint(itemIDText, 10, 32) if err != nil { panic("invalid item id: " + itemIDText) } canonicalRecipes = append(canonicalRecipes, canonicalRecipe) itemIDs = append(itemIDs, uint32(itemIDValue)) if len(canonicalRecipes) > recipeBatchLimit { panic("recipes exceeds 100") } recipeDone := recipeEnd >= len(recipesCSV) itemIDDone := itemIDEnd >= len(itemIDsCSV) if recipeDone != itemIDDone { panic("recipes and item ids count mismatch") } if recipeDone { return canonicalRecipes, itemIDs } recipeEnd++ itemIDEnd++ recipeStart = recipeEnd itemIDStart = itemIDEnd } } func mustParseRecipeKeyCSV(recipesCSV string) []string { if recipesCSV == "" { panic("recipes is required") } canonicalRecipes := []string{} recipeStart, recipeEnd := 0, 0 for { for recipeEnd < len(recipesCSV) && recipesCSV[recipeEnd] != ',' { recipeEnd++ } recipeToken := recipesCSV[recipeStart:recipeEnd] if recipeToken == "" { panic("empty recipe not allowed") } canonicalRecipe, _ := mustParseRecipe(recipeToken) canonicalRecipes = append(canonicalRecipes, canonicalRecipe) if len(canonicalRecipes) > recipeBatchLimit { panic("recipes exceeds 100") } if recipeEnd >= len(recipesCSV) { return canonicalRecipes } recipeEnd++ recipeStart = recipeEnd } } func mustParseRecipe(recipe string) (string, []craftIngredient) { canonical, ingredients, errMsg := parseRecipe(recipe) if errMsg != "" { panic(errMsg) } return canonical, ingredients } // A valid recipe is "<4-hex occupancy>|<ingredient>|...". Occupancy bits // choose slots 0-15 in order; each ingredient is "tokenId:amount" or // "slot:tokenId:amount". Example: "0013|iron:1|iron:2|stick:2" (bits 0,1,4) // canonicalizes to "0013|0:iron:1|1:iron:2|4:stick:2". func parseRecipe(recipe string) (string, []craftIngredient, string) { if len(recipe) < 7 || recipe[4] != '|' { return "", nil, "invalid recipe" } occupancyValue, err := strconv.ParseUint(recipe[:4], 16, 16) if err != nil || occupancyValue == 0 { return "", nil, "invalid recipe occupancy" } occupancy := uint16(occupancyValue) slots := occupiedSlots(occupancy) ingredients := []craftIngredient{} entryStart, entryEnd := 5, 5 for { for entryEnd < len(recipe) && recipe[entryEnd] != '|' { entryEnd++ } if len(ingredients) == len(slots) { return "", nil, "occupied slots and ingredients count mismatch" } ingredient, errMsg := parseRecipeIngredient( recipe[entryStart:entryEnd], slots[len(ingredients)], ) if errMsg != "" { return "", nil, errMsg } ingredients = append(ingredients, ingredient) if len(ingredients) > maxRecipeIngredients { return "", nil, "recipe ingredients exceeds 16" } if entryEnd >= len(recipe) { break } entryEnd++ entryStart = entryEnd } if len(ingredients) != len(slots) { return "", nil, "occupied slots and ingredients count mismatch" } canonicalRecipe := formatOccupancy(occupancy) for _, ingredient := range ingredients { canonicalRecipe += "|" + strconv.FormatUint(uint64(ingredient.Slot), 10) + ":" + url.PathEscape(ingredient.TokenID) + ":" + strconv.FormatInt(ingredient.Amount, 10) } return canonicalRecipe, ingredients, "" } func parseRecipeIngredient(entry string, expectedSlot uint) (craftIngredient, string) { amountSeparator := len(entry) - 1 for amountSeparator > 0 && entry[amountSeparator] != ':' { amountSeparator-- } if amountSeparator == 0 || entry[amountSeparator] != ':' { return craftIngredient{}, "invalid recipe ingredient" } tokenField := entry[:amountSeparator] amountText := entry[amountSeparator+1:] encodedTokenID := tokenField slotPrefixEnd := 0 for slotPrefixEnd < len(tokenField) && tokenField[slotPrefixEnd] != ':' { slotPrefixEnd++ } if slotPrefixEnd < len(tokenField) && tokenField[slotPrefixEnd] == ':' { slotValue, err := strconv.ParseUint(tokenField[:slotPrefixEnd], 10, 8) if err == nil { if uint(slotValue) != expectedSlot { return craftIngredient{}, "ingredient slot mismatch" } encodedTokenID = tokenField[slotPrefixEnd+1:] } } tokenID, err := url.PathUnescape(encodedTokenID) if err != nil || tokenID == "" { return craftIngredient{}, "invalid item token id" } amount, err := strconv.ParseInt(amountText, 10, 64) if err != nil { return craftIngredient{}, "invalid ingredient amount: " + amountText } if amount <= 0 { return craftIngredient{}, "ingredient amount must be positive" } return craftIngredient{ Slot: expectedSlot, TokenID: tokenID, Amount: amount, }, "" } func occupiedSlots(occupancy uint16) []uint { slots := []uint{} for slot := uint(0); slot < 16; slot++ { if occupancy&(uint16(1)<<slot) != 0 { slots = append(slots, slot) } } return slots } func formatOccupancy(occupancy uint16) string { value := strconv.FormatUint(uint64(occupancy), 16) return "0000"[:4-len(value)] + value } func assertRecipeListPageCount(page, count int) { if page < 1 { panic("page must be at least 1") } if count < 1 { panic("count must be at least 1") } if count > listLimit { panic("count exceeds listLimit (max: " + strconv.Itoa(listLimit) + ")") } }
  16. #16/gno.MemPackageType
  17. #17 MPUserAll

Result log

msg:0,success:true,log:,events:[]