diff --git a/examples/gno.land/p/demo/grc/grc1155/basic_grc1155_token.gno b/examples/gno.land/p/demo/grc/grc1155/basic_grc1155_token.gno index 1ed5f93d5fd..f3eb4bfc809 100644 --- a/examples/gno.land/p/demo/grc/grc1155/basic_grc1155_token.gno +++ b/examples/gno.land/p/demo/grc/grc1155/basic_grc1155_token.gno @@ -345,3 +345,9 @@ func (s *basicGRC1155Token) RenderHome() (str string) { return } + +func (mt *basicGRC1155Token) Getter() MultiTokenGetter { + return func() IGRC1155 { + return mt + } +} diff --git a/examples/gno.land/p/demo/grc/grc1155/igrc1155.gno b/examples/gno.land/p/demo/grc/grc1155/igrc1155.gno index 5d524e36773..0e7b947cd29 100644 --- a/examples/gno.land/p/demo/grc/grc1155/igrc1155.gno +++ b/examples/gno.land/p/demo/grc/grc1155/igrc1155.gno @@ -38,3 +38,5 @@ type ApprovalForAllEvent struct { type UpdateURIEvent struct { URI string } + +type MultiTokenGetter func() IGRC1155 diff --git a/examples/gno.land/p/demo/grc/grc721/basic_nft.gno b/examples/gno.land/p/demo/grc/grc721/basic_nft.gno index ed7f96dd598..eb8dcd84308 100644 --- a/examples/gno.land/p/demo/grc/grc721/basic_nft.gno +++ b/examples/gno.land/p/demo/grc/grc721/basic_nft.gno @@ -395,3 +395,10 @@ func (s *basicNFT) RenderHome() (str string) { return } + +// Then add the Getter method to your NFT types +func (n *basicNFT) Getter() NFTGetter { + return func() IGRC721 { + return n + } +} diff --git a/examples/gno.land/p/demo/grc/grc721/igrc721.gno b/examples/gno.land/p/demo/grc/grc721/igrc721.gno index 6c26c953d51..054dc322f31 100644 --- a/examples/gno.land/p/demo/grc/grc721/igrc721.gno +++ b/examples/gno.land/p/demo/grc/grc721/igrc721.gno @@ -26,3 +26,5 @@ const ( ApprovalEvent = "Approval" ApprovalForAllEvent = "ApprovalForAll" ) + +type NFTGetter func() IGRC721 diff --git a/examples/gno.land/r/demo/grc20reg/grc20reg.gno b/examples/gno.land/r/demo/grc20reg/grc20reg.gno index ba59019985a..f09063ee6bd 100644 --- a/examples/gno.land/r/demo/grc20reg/grc20reg.gno +++ b/examples/gno.land/r/demo/grc20reg/grc20reg.gno @@ -4,6 +4,7 @@ import ( "std" "gno.land/p/demo/avl" + "gno.land/p/demo/avl/rotree" "gno.land/p/demo/fqname" "gno.land/p/demo/grc/grc20" "gno.land/p/demo/ufmt" @@ -22,6 +23,22 @@ func Register(tokenGetter grc20.TokenGetter, slug string) { ) } +func RegisterWithTokenhub(tokenGetter grc20.TokenGetter, key string) { + prevRealmPath := std.PreviousRealm().PkgPath() + if prevRealmPath != "gno.land/r/matijamarjanovic/tokenhub" { + return + } + + registry.Set(key, tokenGetter) + + rlmPath, slug := fqname.Parse(key) + std.Emit( + registerEvent, + "pkgpath", rlmPath, + "slug", slug, + ) +} + func Get(key string) grc20.TokenGetter { tokenGetter, ok := registry.Get(key) if !ok { @@ -74,3 +91,7 @@ func Render(path string) string { } const registerEvent = "register" + +func GetRegistry() *rotree.ReadOnlyTree { + return rotree.Wrap(registry, nil) +} diff --git a/examples/gno.land/r/matijamarjanovic/gnoxchange/getters.gno b/examples/gno.land/r/matijamarjanovic/gnoxchange/getters.gno new file mode 100644 index 00000000000..a616b34aa3f --- /dev/null +++ b/examples/gno.land/r/matijamarjanovic/gnoxchange/getters.gno @@ -0,0 +1,353 @@ +package gnoxchange + +import ( + "errors" + "std" + "time" + + "gno.land/p/demo/avl" + "gno.land/p/demo/avl/pager" + "gno.land/p/demo/ufmt" + "gno.land/r/matijamarjanovic/tokenhub" +) + +// --------------------------------------------- Ticket Getters --------------------------------------------- + +// GetTicket allows a user to get a ticket by its ID. All tickets are named swap-. +func GetTicket(ticketID string) (*Ticket, error) { + ticketInterface, exists := ticketRegistry.allTickets.Get(ticketID) + if !exists { + return nil, errors.New("ticket not found") + } + + return ticketInterface.(*Ticket), nil +} + +// GetAllTickets returns a paginated list of all tickets. +func GetAllTickets(path string) *pager.Page { + pager := pager.NewPager(ticketRegistry.allTickets, 1000000, true) + return pager.MustGetPageByPath(path) +} + +// GetOpenTickets returns a paginated list of open, non-expired tickets. +func GetOpenTickets(path string) *pager.Page { + pager := pager.NewPager(ticketRegistry.openTickets, 1000000, true) + return pager.MustGetPageByPath(path) +} + +func GetAllNFTTickets(path string) *pager.Page { + nftTickets := avl.NewTree() + + ticketRegistry.allTickets.Iterate("", "", func(key string, value interface{}) bool { + ticket := value.(*Ticket) + if ticket.AssetIn.Type == AssetTypeNFT { + nftTickets.Set(key, ticket) + } + return false + }) + + pager := pager.NewPager(nftTickets, 5, true) + return pager.MustGetPageByPath(path) +} + +func GetOpenNFTTickets(path string) *pager.Page { + pager := pager.NewPager(ticketRegistry.openNFTTickets, 1000000, true) + return pager.MustGetPageByPath(path) +} + +func GetAllOpenNonNFTTickets(path string) *pager.Page { + nonNFTTickets := avl.NewTree() + + ticketRegistry.allTickets.Iterate("", "", func(key string, value interface{}) bool { + ticket := value.(*Ticket) + if ticket.Status == "open" && + !time.Now().After(ticket.ExpiresAt) && + ticket.AssetIn.Type != AssetTypeNFT && + ticket.AssetOut.Type != AssetTypeNFT { + nonNFTTickets.Set(key, ticket) + } + return false + }) + + pager := pager.NewPager(nonNFTTickets, 1000000, true) + return pager.MustGetPageByPath(path) +} + +// GetTicketsPageInfoString returns a string representation of ticket information for the given page +// e.g. ticketsStr, err := GetTicketsPageInfoString("?page=1&size=10") +func GetTicketsPageInfoString(path string) (string, error) { + pager := pager.NewPager(ticketRegistry.allTickets, 1000000, true) + page := pager.MustGetPageByPath(path) + var result string + + for _, item := range page.Items { + ticket := item.Value.(*Ticket) + + assetInInfo := getDetailedAssetInfo(ticket.AssetIn) + assetOutInfo := getDetailedAssetInfo(ticket.AssetOut) + + info := ufmt.Sprintf("%s>Creator:%s,AssetIn:{%s},AssetOut:{%s},AmountIn:%d,MinAmountOut:%d,CreatedAt:%s,ExpiresAt:%s,Status:%s;", + ticket.ID, + ticket.Creator, + assetInInfo, + assetOutInfo, + ticket.AmountIn, + ticket.MinAmountOut, + ticket.CreatedAt.String(), + ticket.ExpiresAt.String(), + ticket.Status, + ) + result += info + } + + return result, nil +} + +// GetOpenTicketsPageInfoString returns a string representation of open ticket information for the given page +// e.g. ticketsStr, err := GetOpenTicketsPageInfoString("?page=1&size=10") +func GetOpenTicketsPageInfoString(path string) (string, error) { + pager := pager.NewPager(ticketRegistry.openTickets, 1000000, true) + page := pager.MustGetPageByPath(path) + var result string + + for _, item := range page.Items { + ticket := item.Value.(*Ticket) + + assetInInfo := getDetailedAssetInfo(ticket.AssetIn) + assetOutInfo := getDetailedAssetInfo(ticket.AssetOut) + + info := ufmt.Sprintf("%s>Creator:%s,AssetIn:{%s},AssetOut:{%s},AmountIn:%d,MinAmountOut:%d,CreatedAt:%s,ExpiresAt:%s,Status:%s;", + ticket.ID, + ticket.Creator, + assetInInfo, + assetOutInfo, + ticket.AmountIn, + ticket.MinAmountOut, + ticket.CreatedAt.String(), + ticket.ExpiresAt.String(), + ticket.Status, + ) + result += info + } + + return result, nil +} + +// GetAllNFTTicketsPageInfoString returns a string representation of all NFT ticket information for the given page +// e.g. ticketsStr, err := GetAllNFTTicketsPageInfoString("?page=1&size=10") +func GetAllNFTTicketsPageInfoString(path string) (string, error) { + nftTickets := avl.NewTree() + + ticketRegistry.allTickets.Iterate("", "", func(key string, value interface{}) bool { + ticket := value.(*Ticket) + if ticket.AssetIn.Type == AssetTypeNFT { + nftTickets.Set(key, ticket) + } + return false + }) + + pager := pager.NewPager(nftTickets, 1000000, true) + page := pager.MustGetPageByPath(path) + var result string + + for _, item := range page.Items { + ticket := item.Value.(*Ticket) + + assetInInfo := getDetailedAssetInfo(ticket.AssetIn) + assetOutInfo := getDetailedAssetInfo(ticket.AssetOut) + + info := ufmt.Sprintf("%s>Creator:%s,AssetIn:{%s},AssetOut:{%s},AmountIn:%d,MinAmountOut:%d,CreatedAt:%s,ExpiresAt:%s,Status:%s;", + ticket.ID, + ticket.Creator, + assetInInfo, + assetOutInfo, + ticket.AmountIn, + ticket.MinAmountOut, + ticket.CreatedAt.String(), + ticket.ExpiresAt.String(), + ticket.Status, + ) + result += info + } + + return result, nil +} + +// GetOpenNFTTicketsPageInfoString returns a string representation of open NFT ticket information for the given page +// e.g. ticketsStr, err := GetOpenNFTTicketsPageInfoString("?page=1&size=10") +func GetOpenNFTTicketsPageInfoString(path string) (string, error) { + pager := pager.NewPager(ticketRegistry.openNFTTickets, 1000000, true) + page := pager.MustGetPageByPath(path) + var result string + + for _, item := range page.Items { + ticket := item.Value.(*Ticket) + + assetInInfo := getDetailedAssetInfo(ticket.AssetIn) + assetOutInfo := getDetailedAssetInfo(ticket.AssetOut) + + info := ufmt.Sprintf("%s>Creator:%s,AssetIn:{%s},AssetOut:{%s},AmountIn:%d,MinAmountOut:%d,CreatedAt:%s,ExpiresAt:%s,Status:%s;", + ticket.ID, + ticket.Creator, + assetInInfo, + assetOutInfo, + ticket.AmountIn, + ticket.MinAmountOut, + ticket.CreatedAt.String(), + ticket.ExpiresAt.String(), + ticket.Status, + ) + result += info + } + + return result, nil +} + +func GetOpenTicketsCount() int { + return ticketRegistry.openTickets.Size() +} + +func GetOpenNFTTicketsCount() int { + return ticketRegistry.openNFTTickets.Size() +} + +func GetAllTicketsCount() int { + return ticketRegistry.allTickets.Size() +} + +// --------------------------------------------- Pool Getters --------------------------------------------- + +func (p *Pool) GetLPBalance(address std.Address) uint64 { + teller := p.lpToken.CallerTeller() + return teller.BalanceOf(address) +} + +func (p *Pool) GetSharePercentage(address std.Address) float64 { + if p.totalSupplyLp == 0 { + return 0 + } + lpBalance := p.GetLPBalance(address) + return float64(lpBalance) / float64(p.totalSupplyLp) * 100 +} + +func GetPoolsPage(path string) *pager.Page { + pager := pager.NewPager(poolRegistry.pools, 5, false) + return pager.MustGetPageByPath(path) +} + +func GetAllPoolNames() []string { + var poolNames []string + + poolRegistry.pools.Iterate("", "", func(key string, value interface{}) bool { + poolNames = append(poolNames, key) + return false + }) + + return poolNames +} + +func GetAllPoolNamesCount() int { + return poolRegistry.pools.Size() +} + +// GetPoolInfo returns basic information about a specific pool +func GetPoolInfo(poolKey string) (string, string, uint64, uint64, error) { + poolInterface, exists := poolRegistry.pools.Get(poolKey) + if !exists { + return "", "", 0, 0, errors.New("pool not found") + } + + pool := poolInterface.(*Pool) + return pool.tokenA, pool.tokenB, pool.reserveA, pool.reserveB, nil +} + +// GetPoolsPageInfo returns detailed information about pools for the given page +func GetPoolsPageInfo(path string) ([]PoolInfo, string, error) { + pager := pager.NewPager(poolRegistry.pools, 1000000, false) + page := pager.MustGetPageByPath(path) + pools := make([]PoolInfo, 0, len(page.Items)) + + for _, item := range page.Items { + pool := item.Value.(*Pool) + + tokenA := tokenhub.MustGetToken(pool.tokenA) + tokenB := tokenhub.MustGetToken(pool.tokenB) + + pools = append(pools, PoolInfo{ + PoolKey: item.Key, + TokenAInfo: TokenInfo{ + Path: pool.tokenA, + Name: tokenA.GetName(), + Symbol: tokenA.GetSymbol(), + Decimals: tokenA.GetDecimals(), + }, + TokenBInfo: TokenInfo{ + Path: pool.tokenB, + Name: tokenB.GetName(), + Symbol: tokenB.GetSymbol(), + Decimals: tokenB.GetDecimals(), + }, + ReserveA: pool.reserveA, + ReserveB: pool.reserveB, + TotalSupplyLP: pool.totalSupplyLp, + }) + } + + return pools, page.Picker(), nil +} + +// GetPoolsPageInfoString returns a string representation of pool information for the given page +// e.g. poolsStr, err := GetPoolsPageInfoString("?page=1&size=10") +func GetPoolsPageInfoString(path string) (string, error) { + pager := pager.NewPager(poolRegistry.pools, 1000000, false) + page := pager.MustGetPageByPath(path) + var result string + + for _, item := range page.Items { + pool := item.Value.(*Pool) + + tokenA := tokenhub.MustGetToken(pool.tokenA) + tokenB := tokenhub.MustGetToken(pool.tokenB) + + info := ufmt.Sprintf("%s>TokenA:{Path:%s,Name:%s,Symbol:%s,Decimals:%d},TokenB:{Path:%s,Name:%s,Symbol:%s,Decimals:%d},ReserveA:%d,ReserveB:%d,TotalSupplyLP:%d;", + item.Key, + pool.tokenA, tokenA.GetName(), tokenA.GetSymbol(), tokenA.GetDecimals(), + pool.tokenB, tokenB.GetName(), tokenB.GetSymbol(), tokenB.GetDecimals(), + pool.reserveA, + pool.reserveB, + pool.totalSupplyLp, + ) + result += info + } + + return result, nil +} + +// --------------------------------------------- Helper Functions --------------------------------------------- + +// Helper function to get detailed asset information including token metadata when applicable +func getDetailedAssetInfo(asset Asset) string { + switch asset.Type { + case AssetTypeCoin: + return ufmt.Sprintf("Type:coin,Denom:%s", asset.Denom) + case AssetTypeToken: + token := tokenhub.GetToken(asset.Token) + if token == nil { + return ufmt.Sprintf("Type:token,Path:%s,Error:TokenNotFound", asset.Token) + } + return ufmt.Sprintf("Type:token,Path:%s,Name:%s,Symbol:%s,Decimals:%d", + asset.Token, + token.GetName(), + token.GetSymbol(), + token.GetDecimals(), + ) + case AssetTypeNFT: + nft := tokenhub.GetNFT(asset.NFTPath) + if nft == nil { + return ufmt.Sprintf("Type:nft,Path:%s,Error:NFTNotFound", asset.NFTPath) + } + return ufmt.Sprintf("Type:nft,TokenhubPath:%s", asset.NFTPath) + default: + return "Type:unknown" + } +} diff --git a/examples/gno.land/r/matijamarjanovic/gnoxchange/gno.mod b/examples/gno.land/r/matijamarjanovic/gnoxchange/gno.mod new file mode 100644 index 00000000000..70b24197cb8 --- /dev/null +++ b/examples/gno.land/r/matijamarjanovic/gnoxchange/gno.mod @@ -0,0 +1 @@ +module gno.land/r/matijamarjanovic/gnoxchange diff --git a/examples/gno.land/r/matijamarjanovic/gnoxchange/gnoxchange.gno b/examples/gno.land/r/matijamarjanovic/gnoxchange/gnoxchange.gno new file mode 100644 index 00000000000..d5e5132788c --- /dev/null +++ b/examples/gno.land/r/matijamarjanovic/gnoxchange/gnoxchange.gno @@ -0,0 +1,74 @@ +// GnoXchange is a simple decentralized exchange for Gno.land. +// It allows swapping tokens in 2 ways while keeping the spirit of decentralization. +// +// 1. AMM - allows users to create pools with any 2 tokens. +// - When pools are created, creator gets minted LP tokens. +// - Anyone can add liquidity to any existing pool but the liquidity has to be in the same ratio as the pool reserves. +// When adding liquidity, the amount of LP tokens received depends on the ratio of the pool reserves. +// - LP holders are able to withdraw their liquidity and receive their share of the pool's tokens. +// Share is in the ratio of the pool's reserves. +// - Swapping tokens adjusts their price in the pool automatically. +// - Small fee of 0.3% is taken when tokens are swapped, the fee remains in the pool making LP tokens more valuable. +// +// 2. Peer to peer - allows users to create tickets for a fixed amount of tokens for a certain token +// - Anyone can create a ticket for a fixed amount of tokens for a certain token. +// - On ticket creation, the funds that creator wishes to swap are locked in the realm, +// this ensures that the swaps are atomic and provides insurance for the fulfiller. +// - There are 3 types of tickets: Coin (ugnot) to Token, Token to Coin, Token to Token. +// - When creating tickets that aim to swap Coin to Token, the amount of Coin to be swapped is sent in the tx. +// - All tickets are set to have an expiration time set on creation (in hours); all tickets are public +// - If user wants to fulfill a ticket, he triggers FulfilTicket() with amount he wishes to offer +// (has to be bigger than requested miniumum) +// - If fulfilling a ticket requires coins to be sent, user has to match the amount of coins sent +// in the transaction and the amount argument (in denominations) +// - Tickets can be cancelled by the creator before they are fulfilled or expired which refunds the funds +// to the creator. +// +// GnoXchange offers one more functionality - NFT market. +// - Anyone can create a ticket for a fixed amount of tokens for a certain NFT. +// - When creating a ticket for an NFT, the NFT has to be approved first by the creator. +// - On ticket creation NFT is transferred from the creator to the realm. +// - When fulfilling a ticket for an NFT, the NFT is transferred from the realm to the fulfiller (buyer). +// +// Notes: +// All actions with tokens require user's allowance approval of the GnoXchange realm for all tokens to be used: +// .Approve(std.DerivePkgAddr("gno.land/r/matijamarjanovic/gnoxchange"), ) +// (in case of NFTs, the approval is done on the NFT contract) +// .SetApprovalForAll(std.DerivePkgAddr("gno.land/r/matijamarjanovic/gnoxchange"), true) (for all NFTs in the collection), or: +// .Approve(std.DerivePkgAddr("gno.land/r/matijamarjanovic/gnoxchange"), ) (for specific NFT) +// +// Both token and nft instances can be accessed through the tokenhub realm (GetToken() and GetNFT() functions). +// +// Since tokens can have different decimals, 1 unit of a token with less decimals is the minimum amount of both tokens. +// Meaning if user wants to p2p swap/create a pool with tokenA (4 decimalns) and tokenB (six decimals) +// he would have to use at least 1 tokenA (to be more precise 0.0001 becasue of the decimals) and 100 tokenB (0.000100). +// +// All actions are done optimistically - for instance, if user wants to add liquidiy of tokenA and tokenB, +// after passing all the checks 2 tokens are to be added to the pool. Since this cannot happen at once, +// after adding the first token, if adding of the second token fails, the first token is refunded. +// This is applied to all operations in the system. +package gnoxchange + +import ( + "gno.land/p/demo/avl" + "gno.land/r/leon/hof" +) + +var ( + poolRegistry *PoolRegistry + ticketRegistry *TicketRegistry +) + +func init() { + poolRegistry = &PoolRegistry{ + pools: avl.NewTree(), + } + + ticketRegistry = &TicketRegistry{ + allTickets: avl.NewTree(), + openTickets: avl.NewTree(), + openNFTTickets: avl.NewTree(), + } + + hof.Register() +} diff --git a/examples/gno.land/r/matijamarjanovic/gnoxchange/gnoxchange_test.gno b/examples/gno.land/r/matijamarjanovic/gnoxchange/gnoxchange_test.gno new file mode 100644 index 00000000000..15043eee3bf --- /dev/null +++ b/examples/gno.land/r/matijamarjanovic/gnoxchange/gnoxchange_test.gno @@ -0,0 +1,477 @@ +package gnoxchange + +import ( + "std" + "testing" + + "gno.land/p/demo/grc/grc20" + "gno.land/p/demo/grc/grc721" + "gno.land/p/demo/uassert" + "gno.land/p/demo/urequire" + "gno.land/r/matijamarjanovic/tokenhub" +) + +func TestCreatePool(t *testing.T) { + std.TestSetRealm(std.NewCodeRealm("gno.land/r/test/pools")) + tokenA, ledgerA := grc20.NewToken("Test Token A", "TTA", 6) + tokenB, ledgerB := grc20.NewToken("Test Token B", "TTB", 6) + + tokenhub.RegisterToken(tokenA.Getter(), "test_token_a") + tokenhub.RegisterToken(tokenB.Getter(), "test_token_b") + + ledgerA.Mint(std.CurrentRealm().Address(), 10000) + ledgerB.Mint(std.CurrentRealm().Address(), 10000) + + _, err := CreatePool("gno.land/r/test/pools.test_token_a", "gno.land/r/test/pools.test_token_b", 1000, 1000) + uassert.Error(t, err, "Should fail with identical tokens") + + tellerA := tokenA.CallerTeller() + tellerB := tokenB.CallerTeller() + + err = tellerA.Approve(std.DerivePkgAddr("gno.land/r/matijamarjanovic/gnoxchange"), 10000) + urequire.NoError(t, err, "Should approve token A") + err = tellerB.Approve(std.DerivePkgAddr("gno.land/r/matijamarjanovic/gnoxchange"), 10000) + urequire.NoError(t, err, "Should approve token B") + + poolKey, err := CreatePool( + "gno.land/r/test/pools.test_token_a", + "gno.land/r/test/pools.test_token_b", + 1000, + 1000, + ) + urequire.NoError(t, err, "Should create pool successfully") + uassert.NotEmpty(t, poolKey, "Pool key should not be empty") + + tokenAName, tokenBName, reserveA, reserveB, err := GetPoolInfo(poolKey) + urequire.NoError(t, err, "Should get pool info") + uassert.Equal(t, "gno.land/r/test/pools.test_token_a", tokenAName) + uassert.Equal(t, "gno.land/r/test/pools.test_token_b", tokenBName) + uassert.Equal(t, uint64(1000), reserveA) + uassert.Equal(t, uint64(1000), reserveB) +} + +func TestAddLiquidity(t *testing.T) { + std.TestSetRealm(std.NewCodeRealm("gno.land/r/test/pools")) + tokenA, ledgerA := grc20.NewToken("Test Token A", "TTA", 6) + tokenB, ledgerB := grc20.NewToken("Test Token B", "TTB", 6) + + tokenhub.RegisterToken(tokenA.Getter(), "test_token_a2") + tokenhub.RegisterToken(tokenB.Getter(), "test_token_b2") + + ledgerA.Mint(std.CurrentRealm().Address(), 10000) + ledgerB.Mint(std.CurrentRealm().Address(), 10000) + + tellerA := tokenA.CallerTeller() + tellerB := tokenB.CallerTeller() + + err := tellerA.Approve(std.DerivePkgAddr("gno.land/r/matijamarjanovic/gnoxchange"), 10000) + urequire.NoError(t, err, "Should approve token A") + err = tellerB.Approve(std.DerivePkgAddr("gno.land/r/matijamarjanovic/gnoxchange"), 10000) + urequire.NoError(t, err, "Should approve token B") + + poolKey, err := CreatePool("gno.land/r/test/pools.test_token_a2", "gno.land/r/test/pools.test_token_b2", 1000, 1000) + urequire.NoError(t, err, "Should create pool") + + err = tellerA.Approve(std.DerivePkgAddr("gno.land/r/matijamarjanovic/gnoxchange"), 500) + urequire.NoError(t, err) + err = tellerB.Approve(std.DerivePkgAddr("gno.land/r/matijamarjanovic/gnoxchange"), 1000) + urequire.NoError(t, err) + + err = AddLiquidity(poolKey, 500, 1000) + uassert.Error(t, err, "Should fail with incorrect ratio") + + err = tellerA.Approve(std.DerivePkgAddr("gno.land/r/matijamarjanovic/gnoxchange"), 500) + urequire.NoError(t, err) + err = tellerB.Approve(std.DerivePkgAddr("gno.land/r/matijamarjanovic/gnoxchange"), 500) + urequire.NoError(t, err) + + err = AddLiquidity(poolKey, 500, 500) + urequire.NoError(t, err, "Should add liquidity successfully") + + _, _, reserveA, reserveB, err := GetPoolInfo(poolKey) + urequire.NoError(t, err) + uassert.Equal(t, uint64(1500), reserveA) + uassert.Equal(t, uint64(1500), reserveB) +} + +func TestWithdrawLiquidity(t *testing.T) { + std.TestSetRealm(std.NewCodeRealm("gno.land/r/test/pools")) + tokenA, ledgerA := grc20.NewToken("Test Token A", "TTA", 6) + tokenB, ledgerB := grc20.NewToken("Test Token B", "TTB", 6) + + tokenhub.RegisterToken(tokenA.Getter(), "test_token_a3") + tokenhub.RegisterToken(tokenB.Getter(), "test_token_b3") + + ledgerA.Mint(std.CurrentRealm().Address(), 10000) + ledgerB.Mint(std.CurrentRealm().Address(), 10000) + + tellerA := tokenA.CallerTeller() + tellerB := tokenB.CallerTeller() + + err := tellerA.Approve(std.DerivePkgAddr("gno.land/r/matijamarjanovic/gnoxchange"), 1000) + urequire.NoError(t, err) + err = tellerB.Approve(std.DerivePkgAddr("gno.land/r/matijamarjanovic/gnoxchange"), 1000) + urequire.NoError(t, err) + + poolKey, err := CreatePool("gno.land/r/test/pools.test_token_a3", "gno.land/r/test/pools.test_token_b3", 1000, 1000) + urequire.NoError(t, err, "Should create pool") + + poolInterface, exists := poolRegistry.pools.Get(poolKey) + urequire.True(t, exists, "Pool should exist") + pool := poolInterface.(*Pool) + + initialLPBalance := pool.GetLPBalance(std.CurrentRealm().Address()) + uassert.True(t, initialLPBalance > 0, "Should have LP tokens") + + _, _, err = WithdrawLiquidity(poolKey, initialLPBalance+1) + uassert.Error(t, err, "Should fail withdrawing more than available") + + amountA, amountB, err := WithdrawLiquidity(poolKey, initialLPBalance/2) + urequire.NoError(t, err, "Should withdraw successfully") + uassert.Equal(t, uint64(500), amountA) + uassert.Equal(t, uint64(500), amountB) + + newLPBalance := pool.GetLPBalance(std.CurrentRealm().Address()) + uassert.Equal(t, initialLPBalance/2, newLPBalance) +} + +func TestPoolRegistry(t *testing.T) { + std.TestSetRealm(std.NewCodeRealm("gno.land/r/test/pools")) + tokenA, ledgerA := grc20.NewToken("Test Token A", "TTA", 6) + tokenB, ledgerB := grc20.NewToken("Test Token B", "TTB", 6) + + tokenhub.RegisterToken(tokenA.Getter(), "test_token_a4") + tokenhub.RegisterToken(tokenB.Getter(), "test_token_b4") + + ledgerA.Mint(std.CurrentRealm().Address(), 10000) + ledgerB.Mint(std.CurrentRealm().Address(), 10000) + + tellerA := tokenA.CallerTeller() + tellerB := tokenB.CallerTeller() + + err := tellerA.Approve(std.DerivePkgAddr("gno.land/r/matijamarjanovic/gnoxchange"), 2000) + urequire.NoError(t, err) + err = tellerB.Approve(std.DerivePkgAddr("gno.land/r/matijamarjanovic/gnoxchange"), 2000) + urequire.NoError(t, err) + + poolKey1, err := CreatePool("gno.land/r/test/pools.test_token_a4", "gno.land/r/test/pools.test_token_b4", 1000, 1000) + urequire.NoError(t, err) + + poolNames := GetAllPoolNames() + uassert.True(t, len(poolNames) > 0, "Should have at least one pool") + uassert.True(t, contains(poolNames, poolKey1), "Should contain created pool") + + page := GetPoolsPage("") + uassert.True(t, len(page.Items) > 0, "Page should contain entries") +} + +func contains(slice []string, str string) bool { + for _, v := range slice { + if v == str { + return true + } + } + return false +} + +func TestCreateCoinToTokenTicket(t *testing.T) { + std.TestSetRealm(std.NewCodeRealm("gno.land/r/test/tickets")) + token, ledger := grc20.NewToken("Test Token", "TT", 6) + tokenhub.RegisterToken(token.Getter(), "test_token") + + { + _, err := CreateCoinToTokenTicket("ugnot", "gno.land/r/test/tickets.test_token", 1000, 24) + uassert.Error(t, err, "Should fail without sending coins") + } + + { + std.TestSetOriginSend(std.Coins{{"wrongcoin", 1000}}, nil) + _, err := CreateCoinToTokenTicket("ugnot", "gno.land/r/test/tickets.test_token", 1000, 24) + uassert.Error(t, err, "Should fail with wrong coin denomination") + } + + { + std.TestSetOriginSend(std.Coins{{"ugnot", 1000}, {"other", 500}}, nil) + _, err := CreateCoinToTokenTicket("ugnot", "gno.land/r/test/tickets.test_token", 1000, 24) + uassert.Error(t, err, "Should fail with multiple coins") + } + + { + std.TestSetOriginSend(std.Coins{{"ugnot", 1000}}, nil) + ticketID, err := CreateCoinToTokenTicket("ugnot", "gno.land/r/test/tickets.test_token", 1000, 24) + urequire.NoError(t, err, "Should create ticket successfully") + uassert.NotEmpty(t, ticketID, "Ticket ID should not be empty") + + ticket, err := GetTicket(ticketID) + urequire.NoError(t, err) + uassert.Equal(t, "ugnot", ticket.AssetIn.Denom) + uassert.Equal(t, "gno.land/r/test/tickets.test_token", ticket.AssetOut.Token) + uassert.Equal(t, uint64(1000), ticket.MinAmountOut) + uassert.Equal(t, "open", ticket.Status) + } +} + +func TestCreateTokenToCoinTicket(t *testing.T) { + std.TestSetRealm(std.NewCodeRealm("gno.land/r/test/tickets")) + token, ledger := grc20.NewToken("Test Token", "TT", 6) + tokenhub.RegisterToken(token.Getter(), "test_token") + + ledger.Mint(std.CurrentRealm().Address(), 10000) + + _, err := CreateTokenToCoinTicket("gno.land/r/test/tickets.test_token", "ugnot", 1000, 1000, 24) + uassert.Error(t, err, "Should fail without approval") + + teller := token.CallerTeller() + err = teller.Approve(std.DerivePkgAddr("gno.land/r/matijamarjanovic/gnoxchange"), 1000) + urequire.NoError(t, err) + + ticketID, err := CreateTokenToCoinTicket("gno.land/r/test/tickets.test_token", "ugnot", 1000, 1000, 24) + urequire.NoError(t, err, "Should create ticket successfully") + + ticket, err := GetTicket(ticketID) + urequire.NoError(t, err) + uassert.Equal(t, "gno.land/r/test/tickets.test_token", ticket.AssetIn.Token) + uassert.Equal(t, "ugnot", ticket.AssetOut.Denom) + uassert.Equal(t, uint64(1000), ticket.MinAmountOut) +} + +func TestCreateTokenToTokenTicket(t *testing.T) { + std.TestSetRealm(std.NewCodeRealm("gno.land/r/test/tickets")) + tokenA, ledgerA := grc20.NewToken("Test Token A", "TTA", 6) + tokenB, ledgerB := grc20.NewToken("Test Token B", "TTB", 6) + + tokenhub.RegisterToken(tokenA.Getter(), "test_token_a") + tokenhub.RegisterToken(tokenB.Getter(), "test_token_b") + + ledgerA.Mint(std.CurrentRealm().Address(), 10000) + + tellerA := tokenA.CallerTeller() + err := tellerA.Approve(std.DerivePkgAddr("gno.land/r/matijamarjanovic/gnoxchange"), 1000) + urequire.NoError(t, err) + + ticketID, err := CreateTokenToTokenTicket( + "gno.land/r/test/tickets.test_token_a", + "gno.land/r/test/tickets.test_token_b", + 1000, + 1000, + 24, + ) + urequire.NoError(t, err, "Should create ticket successfully") + + ticket, err := GetTicket(ticketID) + urequire.NoError(t, err) + uassert.Equal(t, "gno.land/r/test/tickets.test_token_a", ticket.AssetIn.Token) + uassert.Equal(t, "gno.land/r/test/tickets.test_token_b", ticket.AssetOut.Token) +} + +func TestFulfillTicket(t *testing.T) { + std.TestSetRealm(std.NewCodeRealm("gno.land/r/test/tickets")) + tokenA, ledgerA := grc20.NewToken("Test Token A", "TTA", 6) + tokenB, ledgerB := grc20.NewToken("Test Token B", "TTB", 6) + + tokenhub.RegisterToken(tokenA.Getter(), "test_token_a") + tokenhub.RegisterToken(tokenB.Getter(), "test_token_b") + + ledgerA.Mint(std.CurrentRealm().Address(), 10000) + ledgerB.Mint(std.CurrentRealm().Address(), 10000) + + tellerA := tokenA.CallerTeller() + err := tellerA.Approve(std.DerivePkgAddr("gno.land/r/matijamarjanovic/gnoxchange"), 1000) + urequire.NoError(t, err) + + ticketID, err := CreateTokenToTokenTicket( + "gno.land/r/test/tickets.test_token_a", + "gno.land/r/test/tickets.test_token_b", + 1000, + 1000, + 24, + ) + urequire.NoError(t, err) + + std.TestSetRealm(std.NewCodeRealm("gno.land/r/test/tickets2")) + ledgerA.Mint(std.CurrentRealm().Address(), 10000) + ledgerB.Mint(std.CurrentRealm().Address(), 10000) + + err = tellerA.Approve(std.DerivePkgAddr("gno.land/r/matijamarjanovic/gnoxchange"), 1000) + err = FulfillTicket(ticketID, 500) + uassert.Error(t, err, "Should fail with insufficient amount") + + tellerB := tokenB.CallerTeller() + err = tellerB.Approve(std.DerivePkgAddr("gno.land/r/matijamarjanovic/gnoxchange"), 1000) + urequire.NoError(t, err) + + err = FulfillTicket(ticketID, 1000) + urequire.NoError(t, err, "Should fulfill ticket successfully") + + ticket, err := GetTicket(ticketID) + urequire.NoError(t, err) + uassert.Equal(t, "fulfilled", ticket.Status) +} + +func TestCancelTicket(t *testing.T) { + std.TestSetRealm(std.NewCodeRealm("gno.land/r/test/tickets")) + token, ledger := grc20.NewToken("Test Token", "TT", 6) + tokenhub.RegisterToken(token.Getter(), "test_token") + + ledger.Mint(std.CurrentRealm().Address(), 10000) + + teller := token.CallerTeller() + err := teller.Approve(std.DerivePkgAddr("gno.land/r/matijamarjanovic/gnoxchange"), 1000) + urequire.NoError(t, err) + + ticketID, err := CreateTokenToCoinTicket("gno.land/r/test/tickets.test_token", "ugnot", 1000, 1000, 24) + urequire.NoError(t, err) + + err = CancelTicket(ticketID) + urequire.NoError(t, err, "Should cancel ticket successfully") + + ticket, err := GetTicket(ticketID) + urequire.NoError(t, err) + uassert.Equal(t, "cancelled", ticket.Status) +} + +func TestCreateNFTToTokenTicket(t *testing.T) { + std.TestSetRealm(std.NewCodeRealm("gno.land/r/test/nfts")) + + token, ledger := grc20.NewToken("Test Token", "TT", 6) + tokenhub.RegisterToken(token.Getter(), "test_token") + nft := grc721.NewBasicNFT("Test NFT", "TNFT") + tokenhub.RegisterNFT(nft.Getter(), "test_nft", "1") + + { + _, err := CreateNFTToTokenTicket("gno.land/r/test/nfts.test_nft.nonexistent.1", "gno.land/r/test/nfts.test_token", 1000, 24) + uassert.Error(t, err, "Should fail with non-existent NFT") + } + + { + otherAddr := std.DerivePkgAddr("some.other.address") + err := nft.Mint(otherAddr, "1") + urequire.NoError(t, err) + + _, err = CreateNFTToTokenTicket( + "gno.land/r/test/nfts.test_nft.1", + "gno.land/r/test/nfts.test_token", + 1000, + 24, + ) + uassert.Error(t, err, "Should fail when caller is not owner") + } + + { + err := nft.Mint(std.CurrentRealm().Address(), "2") + urequire.NoError(t, err) + + _, err = CreateNFTToTokenTicket( + "gno.land/r/test/nfts.test_nft.2", + "gno.land/r/test/nfts.test_token", + 1000, + 24, + ) + uassert.Error(t, err, "Should fail without approval") + } +} + +func TestCreateNFTToCoinTicket(t *testing.T) { + std.TestSetRealm(std.NewCodeRealm("gno.land/r/test/nftsss")) + nft := grc721.NewBasicNFT("Test NFT", "TNFT") + + tokenhub.RegisterNFT(nft.Getter(), "test_nft", "1") + + { + _, err := CreateNFTToCoinTicket("gno.land/r/test/nftsss.test_nft.nonexistent.1", "ugnot", 1000, 24) + uassert.Error(t, err, "Should fail with non-existent NFT") + } + + { + otherAddr := std.DerivePkgAddr("some.other.address") + err := nft.Mint(otherAddr, "1") + urequire.NoError(t, err) + + _, err = CreateNFTToCoinTicket( + "gno.land/r/test/nftsss.test_nft.1", + "ugnot", + 1000, + 24, + ) + uassert.Error(t, err, "Should fail when caller is not owner") + } + + { + err := nft.Mint(std.CurrentRealm().Address(), "2") + tokenhub.RegisterNFT(nft.Getter(), "test_nft", "2") + urequire.NoError(t, err) + + contractAddr := std.DerivePkgAddr("gno.land/r/matijamarjanovic/gnoxchange") + err = nft.Approve(contractAddr, "2") + urequire.NoError(t, err) + + ticketID, err := CreateNFTToCoinTicket( + "gno.land/r/test/nftsss.test_nft.2", + "ugnot", + 1000, + 24, + ) + urequire.NoError(t, err, "Should create ticket successfully") + + ticket, err := GetTicket(ticketID) + urequire.NoError(t, err) + uassert.Equal(t, "gno.land/r/test/nftsss.test_nft.2", ticket.AssetIn.NFTPath) + uassert.Equal(t, "ugnot", ticket.AssetOut.Denom) + uassert.Equal(t, uint64(1000), ticket.MinAmountOut) + + owner, err := nft.OwnerOf("2") + urequire.NoError(t, err) + uassert.Equal(t, contractAddr, owner, "NFT should be transferred to contract") + } +} + +func TestBuyNFT(t *testing.T) { + std.TestSetRealm(std.NewCodeRealm("gno.land/r/test/nfts")) + + token, ledger := grc20.NewToken("Test Token", "TT", 6) + tokenhub.RegisterToken(token.Getter(), "test_token") + nft := grc721.NewBasicNFT("Test NFT", "TNFT") + tokenhub.RegisterNFT(nft.Getter(), "test_nft", "1") + + contractAddr := std.DerivePkgAddr("gno.land/r/matijamarjanovic/gnoxchange") + + err := nft.Mint(std.CurrentRealm().Address(), "1") + tokenhub.RegisterNFT(nft.Getter(), "test_nft", "1") + urequire.NoError(t, err) + + err = nft.Approve(contractAddr, "1") + urequire.NoError(t, err) + + ticketID, err := CreateNFTToTokenTicket( + "gno.land/r/test/nfts.test_nft.1", + "gno.land/r/test/nfts.test_token", + 1000, + 24, + ) + urequire.NoError(t, err) + + std.TestSetRealm(std.NewCodeRealm("gno.land/r/test/nftss")) + + { + err = BuyNFT(ticketID, 500) + uassert.Error(t, err, "Should fail with insufficient payment") + } + + { + ledger.Mint(std.CurrentRealm().Address(), 2000) + teller := token.CallerTeller() + err = teller.Approve(contractAddr, 1000) + urequire.NoError(t, err) + + err = BuyNFT(ticketID, 1000) + urequire.NoError(t, err, "Should buy NFT successfully") + + owner, err := nft.OwnerOf("1") + urequire.NoError(t, err) + uassert.Equal(t, std.CurrentRealm().Address(), owner, "NFT should be transferred to buyer") + + ticket, err := GetTicket(ticketID) + urequire.NoError(t, err) + uassert.Equal(t, "fulfilled", ticket.Status) + } +} diff --git a/examples/gno.land/r/matijamarjanovic/gnoxchange/nftmarket.gno b/examples/gno.land/r/matijamarjanovic/gnoxchange/nftmarket.gno new file mode 100644 index 00000000000..c6af6ddc9df --- /dev/null +++ b/examples/gno.land/r/matijamarjanovic/gnoxchange/nftmarket.gno @@ -0,0 +1,182 @@ +package gnoxchange + +import ( + "errors" + "std" + "strings" + "time" + + "gno.land/p/demo/grc/grc721" + "gno.land/p/demo/ufmt" + "gno.land/r/matijamarjanovic/tokenhub" +) + +// CreateNFTToTokenTicket creates a ticket to swap a GRC721 NFT for GRC20 tokens. +// Requires NFT approval before creating the ticket. +func CreateNFTToTokenTicket( + nftFullPath string, // e.g. "gno.land/r/test.nft.mycollection.1" + tokenKey string, // e.g. "gno.land/r/test.gtoken" + minAmountOut uint64, // minimum amount of tokens to receive + expiryHours int64, +) (string, error) { + caller := std.PreviousRealm().Address() + + parts := strings.Split(nftFullPath, ".") + if len(parts) < 4 { + return "", errors.New("invalid NFT path format: expected path.collection.tokenID") + } + + tokenID := parts[len(parts)-1] + + nft := tokenhub.GetNFT(nftFullPath) + if nft == nil { + return "", errors.New("NFT not found: " + nftFullPath) + } + + owner, err := nft.OwnerOf(grc721.TokenID(tokenID)) + if err != nil { + return "", errors.New("invalid token ID") + } + if owner != caller { + return "", errors.New("caller is not the owner of the NFT") + } + + tokenOut := tokenhub.GetToken(tokenKey) + if tokenOut == nil { + return "", errors.New("token not found: " + tokenKey) + } + + if err := nft.TransferFrom(caller, std.CurrentRealm().Address(), grc721.TokenID(tokenID)); err != nil { + return "", errors.New("failed to transfer NFT: " + err.Error()) + } + + ticketCounter++ + ticketID := ufmt.Sprintf("nfts-%d", ticketCounter) + + ticket := &Ticket{ + ID: ticketID, + Creator: caller, + AssetIn: NewNFTAsset(nftFullPath), + AssetOut: NewTokenAsset(tokenKey), + AmountIn: 1, // non-fungible + MinAmountOut: minAmountOut, + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(time.Duration(expiryHours) * time.Hour), + Status: "open", + } + + addTicket(ticket) + return ticketID, nil +} + +// CreateNFTToCoinTicket creates a ticket to swap a GRC721 NFT for native coins. +func CreateNFTToCoinTicket( + nftFullPath string, // e.g. "gno.land/r/test.nft.mycollection.1" + coinDenom string, // e.g. "ugnot" + minAmountOut uint64, // minimum amount of coins to receive + expiryHours int64, +) (string, error) { + caller := std.PreviousRealm().Address() + + parts := strings.Split(nftFullPath, ".") + if len(parts) < 4 { + return "", errors.New("invalid NFT path format: expected path.collection.tokenID") + } + + tokenID := parts[len(parts)-1] + + nft := tokenhub.GetNFT(nftFullPath) + if nft == nil { + return "", errors.New("NFT not found: " + nftFullPath) + } + + owner, err := nft.OwnerOf(grc721.TokenID(tokenID)) + if err != nil { + return "", errors.New("invalid token ID") + } + if owner != caller { + return "", errors.New("caller is not the owner of the NFT") + } + + if err := nft.TransferFrom(caller, std.CurrentRealm().Address(), grc721.TokenID(tokenID)); err != nil { + return "", errors.New("failed to transfer NFT: " + err.Error()) + } + + ticketCounter++ + ticketID := ufmt.Sprintf("nfts-%d", ticketCounter) + + ticket := &Ticket{ + ID: ticketID, + Creator: caller, + AssetIn: NewNFTAsset(nftFullPath), + AssetOut: NewCoinAsset(coinDenom), + AmountIn: 1, // non-fungible + MinAmountOut: minAmountOut, + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(time.Duration(expiryHours) * time.Hour), + Status: "open", + } + + addTicket(ticket) + return ticketID, nil +} + +// BuyNFT allows a user to fulfill an NFT ticket by paying with tokens or coins. +// It functions on the same principle as the fulfillTicket function. +func BuyNFT(ticketID string, amountOut uint64) error { + caller := std.PreviousRealm().Address() + + ticketInterface, exists := ticketRegistry.openNFTTickets.Get(ticketID) + if !exists { + return errors.New("ticket not found") + } + + ticket := ticketInterface.(*Ticket) + + if time.Now().After(ticket.ExpiresAt) { + updateTicketStatus(ticket, "expired") + return errors.New("ticket has expired") + } + + if amountOut < ticket.MinAmountOut { + return errors.New("insufficient payment amount") + } + + if ticket.AssetIn.Type != AssetTypeNFT { + return errors.New("not an NFT ticket") + } + + nft := tokenhub.GetNFT(ticket.AssetIn.NFTPath) + if nft == nil { + return errors.New("NFT not found: " + ticket.AssetIn.NFTPath) + } + + if ticket.AssetOut.Type == AssetTypeCoin { + sent := std.OriginSend() + if len(sent) != 1 || sent[0].Denom != ticket.AssetOut.Denom || uint64(sent[0].Amount) != amountOut { + return errors.New("sent coins don't match payment parameters") + } + + banker := std.NewBanker(std.BankerTypeRealmSend) + banker.SendCoins(std.CurrentRealm().Address(), ticket.Creator, sent) + } else { + tokenOut := tokenhub.GetToken(ticket.AssetOut.Token) + if tokenOut == nil { + return errors.New("token not found: " + ticket.AssetOut.Token) + } + + tellerOut := tokenOut.RealmTeller() + if err := tellerOut.TransferFrom(caller, ticket.Creator, amountOut); err != nil { + return errors.New("failed to transfer payment tokens: " + err.Error()) + } + } + nftKeyParts := strings.Split(ticket.AssetIn.NFTPath, ".") + tokenID := nftKeyParts[len(nftKeyParts)-1] + + if err := nft.TransferFrom(std.CurrentRealm().Address(), caller, grc721.TokenID(tokenID)); err != nil { + panic(ufmt.Sprintf("CRITICAL: payment processed but NFT transfer failed: %v", err)) + } + + updateTicketStatus(ticket, "fulfilled") + return nil +} diff --git a/examples/gno.land/r/matijamarjanovic/gnoxchange/pools.gno b/examples/gno.land/r/matijamarjanovic/gnoxchange/pools.gno new file mode 100644 index 00000000000..5023d1a7a68 --- /dev/null +++ b/examples/gno.land/r/matijamarjanovic/gnoxchange/pools.gno @@ -0,0 +1,331 @@ +package gnoxchange + +import ( + "errors" + "math" + "std" + + "gno.land/p/demo/avl/pager" + "gno.land/p/demo/grc/grc20" + "gno.land/p/demo/ufmt" + "gno.land/r/matijamarjanovic/tokenhub" +) + +var ( + poolPager *pager.Pager +) + +// CreatePool is the public function that handles validation and security checks before creating a pool. +// Since it is possible for tokens to have different decimals, it is the lowest amount of both tokens +// is the lowest ammount of the one with less decimals. +// If the pool creation is successful, the pool key is returned. +func CreatePool(tokenA, tokenB string, initialAmountA, initialAmountB uint64) (string, error) { + caller := std.PreviousRealm().Address() + + if tokenA == tokenB { + return "", errors.New("identical tokens") + } + + tokenAInstance := tokenhub.GetToken(tokenA) + if tokenAInstance == nil { + return "", errors.New(ufmt.Sprintf("token %s not found", tokenA)) + } + + tokenBInstance := tokenhub.GetToken(tokenB) + if tokenBInstance == nil { + return "", errors.New(ufmt.Sprintf("token %s not found", tokenB)) + } + + decimalsA := tokenAInstance.GetDecimals() + decimalsB := tokenBInstance.GetDecimals() + + minDecimals := decimalsA + if decimalsB < decimalsA { + minDecimals = decimalsB + } + + if decimalsA > minDecimals { + expectedScale := uint64(math.Pow10(int(decimalsA - minDecimals))) + if initialAmountA%expectedScale != 0 { + return "", errors.New(ufmt.Sprintf( + "invalid amount for %s: amount must be in units of %d (token has %d decimals vs %d decimals for the other token)", + tokenA, expectedScale, decimalsA, minDecimals, + )) + } + } + + if decimalsB > minDecimals { + expectedScale := uint64(math.Pow10(int(decimalsB - minDecimals))) + if initialAmountB%expectedScale != 0 { + return "", errors.New(ufmt.Sprintf( + "invalid amount for %s: amount must be in units of %d (token has %d decimals vs %d decimals for the other token)", + tokenB, expectedScale, decimalsB, minDecimals, + )) + } + } + + poolKey := createPoolKey(tokenA, tokenB) + if _, exists := poolRegistry.pools.Get(poolKey); exists { + return "", errors.New("pool already exists") + } + + allowanceA := tokenAInstance.Allowance(caller, std.CurrentRealm().Address()) + if allowanceA < initialAmountA { + return "", errors.New(ufmt.Sprintf("insufficient allowance for token %s: have %d, need %d", tokenA, allowanceA, initialAmountA)) + } + + allowanceB := tokenBInstance.Allowance(caller, std.CurrentRealm().Address()) + if allowanceB < initialAmountB { + return "", errors.New(ufmt.Sprintf("insufficient allowance for token %s: have %d, need %d", tokenB, allowanceB, initialAmountB)) + } + + if err := createPool(caller, tokenA, tokenB, initialAmountA, initialAmountB); err != nil { + return "", err + } + + return poolKey, nil +} + +// AddLiquidity is a public function that allows the caller to add liquidity to the pool for the given pool key. +// Poolkey is constucted by concatenating the two tokens in alphabetical order with a colon between them. +func AddLiquidity(poolKey string, amountA, amountB uint64) error { + pool, exists := poolRegistry.pools.Get(poolKey) + if !exists { + return errors.New("pool not found") + } + + return pool.(*Pool).addLiquidity(std.PreviousRealm().Address(), amountA, amountB) +} + +// WithdrawLiquidity is a public function that allows the caller to withdraw liquidity from the pool. +// Poolkey is constucted by concatenating the two tokens in alphabetical order with a colon between them. +func WithdrawLiquidity(poolKey string, lpAmount uint64) (uint64, uint64, error) { + poolInterface, exists := poolRegistry.pools.Get(poolKey) + if !exists { + return 0, 0, errors.New("pool not found") + } + + pool, ok := poolInterface.(*Pool) + if !ok { + return 0, 0, errors.New("invalid pool type in registry") + } + + if lpAmount == 0 { + return 0, 0, errors.New("cannot withdraw zero liquidity") + } + + caller := std.PreviousRealm().Address() + lpBalance := pool.GetLPBalance(caller) + if lpBalance < lpAmount { + return 0, 0, errors.New("insufficient LP token balance") + } + + return pool.withdrawLiquidity(caller, lpAmount) +} + +// createPoolKey is a helper function that creates a pool key and ensures consistent ordering of tokens +func createPoolKey(tokenA, tokenB string) string { + if tokenA < tokenB { + return tokenA + ":" + tokenB + } + return tokenB + ":" + tokenA +} + +// addLiquidity is a helper function that adds liquidity to the pool. After the liquidity is added, the pool state is updated. +// Liquidy cannot be added to the pool unless the caller himself gives the gnoXchange realm enough allowance to take for the amount of tokens he wants to add. +// If user gives enough allowance for one token and not the other, the first token will be returned to the caller. +// After the liquidity is added, the pool state is updated. +func (p *Pool) addLiquidity(provider std.Address, amountA, amountB uint64) error { + tokenAInstance := tokenhub.GetToken(p.tokenA) + tokenBInstance := tokenhub.GetToken(p.tokenB) + + decimalsA := tokenAInstance.GetDecimals() + decimalsB := tokenBInstance.GetDecimals() + + minDecimals := decimalsA + if decimalsB < decimalsA { + minDecimals = decimalsB + } + + if decimalsA > minDecimals { + expectedScale := uint64(math.Pow10(int(decimalsA - minDecimals))) + if amountA%expectedScale != 0 { + return errors.New(ufmt.Sprintf( + "invalid amount for %s: amount must be in units of %d (token has %d decimals vs %d decimals for the other token)", + p.tokenA, expectedScale, decimalsA, minDecimals, + )) + } + } + + if decimalsB > minDecimals { + expectedScale := uint64(math.Pow10(int(decimalsB - minDecimals))) + if amountB%expectedScale != 0 { + return errors.New(ufmt.Sprintf( + "invalid amount for %s: amount must be in units of %d (token has %d decimals vs %d decimals for the other token)", + p.tokenB, expectedScale, decimalsB, minDecimals, + )) + } + } + + allowanceA := tokenAInstance.Allowance(provider, std.CurrentRealm().Address()) + if allowanceA < amountA { + return errors.New(ufmt.Sprintf("insufficient allowance for token %s: have %d, need %d", p.tokenA, allowanceA, amountA)) + } + + allowanceB := tokenBInstance.Allowance(provider, std.CurrentRealm().Address()) + if allowanceB < amountB { + return errors.New(ufmt.Sprintf("insufficient allowance for token %s: have %d, need %d", p.tokenB, allowanceB, amountB)) + } + + tellerA := tokenAInstance.RealmTeller() + if err := tellerA.TransferFrom( + provider, + std.CurrentRealm().Address(), + amountA, + ); err != nil { + return errors.New(ufmt.Sprintf("failed to transfer token %s: %v", p.tokenA, err)) + } + + tellerB := tokenBInstance.RealmTeller() + if err := tellerB.TransferFrom( + provider, + std.CurrentRealm().Address(), + amountB, + ); err != nil { + // if second transfer fails, we need to refund the first transfer + if refundErr := tellerA.Transfer(provider, amountA); refundErr != nil { + // serious error - couldn't refund + panic(ufmt.Sprintf("CRITICAL: failed to refund token %s after failed transfer of token %s: original error: %v, refund error: %v", + p.tokenA, p.tokenB, err, refundErr)) + } + return errors.New(ufmt.Sprintf("failed to transfer token %s: %v", p.tokenB, err)) + } + + var shares uint64 + if p.totalSupplyLp == 0 { + if amountA == 0 || amountB == 0 { + return errors.New("cannot add zero liquidity") + } + shares = uint64(math.Sqrt(float64(amountA * amountB))) + } else { + if (amountA * p.reserveB) != (amountB * p.reserveA) { + return errors.New(ufmt.Sprintf("incorrect token ratio, should be %d:%d", p.reserveA, p.reserveB)) + } + + sharesA := (amountA * p.totalSupplyLp) / p.reserveA + sharesB := (amountB * p.totalSupplyLp) / p.reserveB + if sharesA < sharesB { + shares = sharesA + } else { + shares = sharesB + } + } + + if shares == 0 { + return errors.New("insufficient liquidity provided") + } + + p.reserveA += amountA + p.reserveB += amountB + p.totalSupplyLp += shares + + if err := p.lpLedger.Mint(provider, shares); err != nil { + return err + } + + return nil +} + +// withdrawLiquidity is a helper function that burns LP tokens and updates the pool state +// if the tokens to be withdrawn fail to transfer back to the caller, the pool state is still updated +// and the caller's LPs are burned. This ensures that the pool state is consistent even if the transfer fails, +// but the caller loses their LPs. +func (p *Pool) withdrawLiquidity(caller std.Address, lpAmount uint64) (uint64, uint64, error) { + if lpAmount == 0 { + return 0, 0, errors.New("cannot withdraw zero liquidity") + } + + amountA := (lpAmount * p.reserveA) / p.totalSupplyLp + amountB := (lpAmount * p.reserveB) / p.totalSupplyLp + + if err := p.lpLedger.Burn(caller, lpAmount); err != nil { + return 0, 0, errors.New(ufmt.Sprintf("failed to burn LP tokens: %v", err)) + } + + p.reserveA = p.reserveA - amountA + p.reserveB = p.reserveB - amountB + p.totalSupplyLp = p.totalSupplyLp - lpAmount + + tokenAInstance := tokenhub.GetToken(p.tokenA) + tokenBInstance := tokenhub.GetToken(p.tokenB) + + tellerA := tokenAInstance.RealmTeller() + if err := tellerA.Transfer(caller, amountA); err != nil { + panic(ufmt.Sprintf("CRITICAL: pool state updated but token A transfer failed: %v", err)) + } + + tellerB := tokenBInstance.RealmTeller() + if err := tellerB.Transfer(caller, amountB); err != nil { + panic(ufmt.Sprintf("CRITICAL: pool state updated but token B transfer failed: %v", err)) + } + + return amountA, amountB, nil +} + +// createPool handles the core pool creation logic. First it takes the tokens from the caller and transfers them to the pool. +// Then it creates the LP tokens and mints them to the caller. It calculates the initial liquidity based on the sqrt of the product of the two tokens. +// The pool is then created and added to the pool registry. +func createPool(creator std.Address, tokenA, tokenB string, initialAmountA, initialAmountB uint64) error { + tokenAInstance := tokenhub.GetToken(tokenA) + tokenBInstance := tokenhub.GetToken(tokenB) + + tellerA := tokenAInstance.RealmTeller() + if err := tellerA.TransferFrom( + creator, + std.CurrentRealm().Address(), + initialAmountA, + ); err != nil { + return errors.New(ufmt.Sprintf("failed to transfer token %s: %v", tokenA, err)) + } + + tellerB := tokenBInstance.RealmTeller() + if err := tellerB.TransferFrom( + creator, + std.CurrentRealm().Address(), + initialAmountB, + ); err != nil { + if refundErr := tellerA.Transfer(creator, initialAmountA); refundErr != nil { + panic(ufmt.Sprintf("CRITICAL: failed to refund token %s after failed transfer of token %s: original error: %v, refund error: %v", + tokenA, tokenB, err, refundErr)) + } + return errors.New(ufmt.Sprintf("failed to transfer token %s: %v", tokenB, err)) + } + + lpName := ufmt.Sprintf("LP %s-%s", tokenAInstance.GetName(), tokenBInstance.GetName()) + lpSymbol := ufmt.Sprintf("LP-%s-%s", tokenAInstance.GetSymbol(), tokenBInstance.GetSymbol()) + lpToken, lpLedger := grc20.NewToken(lpName, lpSymbol, 6) + tokenhub.RegisterToken(lpToken.Getter(), lpSymbol) + + shares := uint64(math.Sqrt(float64(initialAmountA * initialAmountB))) + if shares == 0 { + return errors.New("insufficient initial liquidity") + } + + pool := &Pool{ + tokenA: tokenA, + tokenB: tokenB, + reserveA: initialAmountA, + reserveB: initialAmountB, + lpToken: lpToken, + lpLedger: lpLedger, + totalSupplyLp: shares, + } + + if err := pool.lpLedger.Mint(creator, shares); err != nil { + return errors.New(ufmt.Sprintf("failed to mint LP tokens: %v", err)) + } + + poolRegistry.pools.Set(createPoolKey(tokenA, tokenB), pool) + + return nil +} diff --git a/examples/gno.land/r/matijamarjanovic/gnoxchange/render.gno b/examples/gno.land/r/matijamarjanovic/gnoxchange/render.gno new file mode 100644 index 00000000000..4de7c282909 --- /dev/null +++ b/examples/gno.land/r/matijamarjanovic/gnoxchange/render.gno @@ -0,0 +1,380 @@ +package gnoxchange + +import ( + "math" + "strings" + "time" + + "gno.land/p/demo/ufmt" + "gno.land/p/moul/md" + "gno.land/r/matijamarjanovic/tokenhub" +) + +var statusEmojis = map[string]string{ + "open": "🟢 Open", + "fulfilled": "✅ Fulfilled", + "expired": "⏰ Expired", + "cancelled": "❌ Cancelled", +} + +func Render(path string) string { + basePath := path + if idx := strings.Index(path, "?"); idx != -1 { + basePath = path[:idx] + } + + switch basePath { + case "ticketing": + return renderTickets(path) + case "ticketHistory": + return renderTicketHistory(path) + case "nftmarket": + return renderNFTMarket(path) + case "pools": + return renderPools(path) + default: + return renderHome() + } +} + +func renderHome() string { + var str string + str += md.H1("GnoXchange") + str += md.Paragraph("Welcome to GnoXchange - Decentralized token exchange system on Gno.land") + + str += md.H2("Navigation") + links := []string{ + "[Liquidity Pools](/r/matijamarjanovic/gnoxchange:pools) - View and interact with liquidity pools", // TODO: change to gno.land + "[Ticket System](/r/matijamarjanovic/gnoxchange:ticketing) - Create and manage swap tickets", // TODO: change to gno.land + "[NFT Market](/r/matijamarjanovic/gnoxchange:nftmarket) - Create and manage NFT tickets", // TODO: change to gno.land + } + str += md.BulletList(links) + + str += md.H2("Quick Stats") + stats := []string{ + ufmt.Sprintf("Total Pools: %d", GetAllPoolNamesCount()), + ufmt.Sprintf("Open Tickets: %d", GetOpenTicketsCount()), + ufmt.Sprintf("Total NFTs for sale: %d", GetOpenNFTTicketsCount()), + } + str += md.BulletList(stats) + + str += md.Paragraph("") + str += md.Paragraph("[Ticket History](/r/matijamarjanovic/gnoxchange:ticketHistory) - View all tickets (including expired, fulfilled, and cancelled).") + + str += md.HorizontalRule() + str += md.Paragraph("Start by exploring the available pools or creating a new swap ticket.") + + return str +} + +func renderTickets(path string) string { + var str string + str += md.H2("Ticket System") + str += md.Paragraph("View all open tickets for coin and token swaps.") + + page := GetAllOpenNonNFTTickets(path) + if len(page.Items) == 0 { + str += md.Blockquote("No open tickets available.") + return str + } + + str += md.H2("Open Tickets") + + for _, item := range page.Items { + ticket := item.Value.(*Ticket) + + swapType := "" + var amountInStr, amountOutStr string + + if ticket.AssetIn.Type == AssetTypeCoin { + swapType = ticket.AssetIn.Denom + " → " + ticket.AssetOut.Token + amountInStr = ufmt.Sprintf("%f", float64(ticket.AmountIn)/1000000) + " " + ticket.AssetIn.Denom + + tokenOut := tokenhub.GetToken(ticket.AssetOut.Token) + decimalAdjustOut := math.Pow(10, float64(tokenOut.GetDecimals())) + amountOutStr = ufmt.Sprintf("%f", float64(ticket.MinAmountOut)/decimalAdjustOut) + " " + tokenOut.GetSymbol() + + } else if ticket.AssetOut.Type == AssetTypeCoin { + tokenIn := tokenhub.GetToken(ticket.AssetIn.Token) + swapType = tokenIn.GetSymbol() + " → " + ticket.AssetOut.Denom + + decimalAdjustIn := math.Pow(10, float64(tokenIn.GetDecimals())) + amountInStr = ufmt.Sprintf("%f", float64(ticket.AmountIn)/decimalAdjustIn) + " " + tokenIn.GetSymbol() + amountOutStr = ufmt.Sprintf("%f", float64(ticket.MinAmountOut)/1000000) + " " + ticket.AssetOut.Denom + + } else { + tokenIn := tokenhub.GetToken(ticket.AssetIn.Token) + tokenOut := tokenhub.GetToken(ticket.AssetOut.Token) + swapType = tokenIn.GetSymbol() + " → " + tokenOut.GetSymbol() + + decimalAdjustIn := math.Pow(10, float64(tokenIn.GetDecimals())) + decimalAdjustOut := math.Pow(10, float64(tokenOut.GetDecimals())) + + amountInStr = ufmt.Sprintf("%f", float64(ticket.AmountIn)/decimalAdjustIn) + " " + tokenIn.GetSymbol() + amountOutStr = ufmt.Sprintf("%f", float64(ticket.MinAmountOut)/decimalAdjustOut) + " " + tokenOut.GetSymbol() + } + + str += md.H3("#" + ticket.ID + " (" + swapType + ")") + + details := []string{ + "Publisher Offers: " + amountInStr, + "Publisher Wants (min): " + amountOutStr, + "Created At: " + ticket.CreatedAt.Format(time.RFC3339), + "Expires In: " + formatDuration(ticket.ExpiresAt.Sub(time.Now())), + "Status: " + statusEmojis[ticket.Status], + } + + str += md.BulletList(details) + str += md.HorizontalRule() + } + + if page.TotalPages > 1 { + str += "\n\nPages: " + page.Picker() + } + + return str +} + +func renderNFTMarket(path string) string { + var str string + str += md.H2("NFT Market") + str += md.Paragraph("View all NFT tickets for sale.") + + page := GetOpenNFTTickets(path) + if len(page.Items) == 0 { + str += md.Blockquote("No NFTs available for sale.") + return str + } + + str += md.H2("NFT Tickets") + + for _, item := range page.Items { + ticket := item.Value.(*Ticket) + + nft := tokenhub.GetNFT(ticket.AssetIn.NFTPath) + if nft == nil { + continue + } + + parts := strings.Split(ticket.AssetIn.NFTPath, ".") + collection := parts[len(parts)-2] + tokenID := parts[len(parts)-1] + + var priceStr string + if ticket.AssetOut.Type == AssetTypeCoin { + priceStr = ufmt.Sprintf("%f", float64(ticket.MinAmountOut)/1000000) + " " + ticket.AssetOut.Denom + } else { + tokenOut := tokenhub.GetToken(ticket.AssetOut.Token) + decimalAdjustOut := math.Pow(10, float64(tokenOut.GetDecimals())) + priceStr = ufmt.Sprintf("%f", float64(ticket.MinAmountOut)/decimalAdjustOut) + " " + tokenOut.GetSymbol() + } + + str += md.H3("#" + ticket.ID + " (" + collection + " #" + tokenID + ")") + + details := []string{ + "Collection: " + collection, + "Token ID: " + tokenID, + "Price: " + priceStr, + "Created At: " + ticket.CreatedAt.Format(time.RFC3339), + "Expires In: " + formatDuration(ticket.ExpiresAt.Sub(time.Now())), + "Status: " + statusEmojis[ticket.Status], + } + + str += md.BulletList(details) + str += md.HorizontalRule() + } + + if page.TotalPages > 1 { + str += "\n\nPages: " + page.Picker() + } + + return str +} + +func renderPools(path string) string { + var str string + str += md.H2("Available Pools") + str += md.Paragraph("View and interact with liquidity pools.") + + page := GetPoolsPage(path) + if len(page.Items) == 0 { + str += md.Blockquote("No pools available.") + return str + } + + for _, item := range page.Items { + poolKey := item.Key + tokenA, tokenB, reserveA, reserveB, err := GetPoolInfo(poolKey) + if err != nil { + continue + } + + tokenAInstance := tokenhub.GetToken(tokenA) + tokenBInstance := tokenhub.GetToken(tokenB) + + decimalAdjustA := math.Pow(10, float64(tokenAInstance.GetDecimals())) + decimalAdjustB := math.Pow(10, float64(tokenBInstance.GetDecimals())) + + tokenAName := tokenAInstance.GetName() + tokenBName := tokenBInstance.GetName() + tokenASymbol := tokenAInstance.GetSymbol() + tokenBSymbol := tokenBInstance.GetSymbol() + + str += md.H3(tokenAName + " (" + tokenASymbol + ") <-> " + tokenBName + " (" + tokenBSymbol + ")") + details := []string{ + "First token: " + tokenASymbol + " - **" + ufmt.Sprintf("%f", float64(reserveA)/decimalAdjustA) + "**" + + " (1 " + tokenASymbol + " ≈ " + ufmt.Sprintf("%f", float64(reserveB)/float64(reserveA)) + " " + tokenBSymbol + ")", + "Second token: " + tokenBSymbol + " - **" + ufmt.Sprintf("%f", float64(reserveB)/decimalAdjustB) + "**" + + " (1 " + tokenBSymbol + " ≈ " + ufmt.Sprintf("%f", float64(reserveA)/float64(reserveB)) + " " + tokenASymbol + ")", + } + str += md.BulletList(details) + str += md.HorizontalRule() + } + + if page.TotalPages > 1 { + str += "\n\nPages: " + page.Picker() + } + + return str +} + +func renderTicketHistory(path string) string { + var str string + str += md.H2("Ticket History") + str += md.Paragraph("View all tickets (including expired, fulfilled, and cancelled).") + + page := GetAllTickets(path) + if len(page.Items) == 0 { + str += md.Blockquote("No tickets available.") + return str + } + + str += md.H2("All Tickets") + + for _, item := range page.Items { + ticket := item.Value.(*Ticket) + + if ticket.AssetIn.Type == AssetTypeNFT { + nft := tokenhub.GetNFT(ticket.AssetIn.NFTPath) + if nft == nil { + continue + } + + parts := strings.Split(ticket.AssetIn.NFTPath, ".") + collection := parts[len(parts)-2] + tokenID := parts[len(parts)-1] + + var priceStr string + if ticket.AssetOut.Type == AssetTypeCoin { + priceStr = ufmt.Sprintf("%f", float64(ticket.MinAmountOut)/1000000) + " " + ticket.AssetOut.Denom + } else { + tokenOut := tokenhub.GetToken(ticket.AssetOut.Token) + decimalAdjustOut := math.Pow(10, float64(tokenOut.GetDecimals())) + priceStr = ufmt.Sprintf("%f", float64(ticket.MinAmountOut)/decimalAdjustOut) + " " + tokenOut.GetSymbol() + } + + str += md.H3("#" + ticket.ID + " (NFT: " + collection + " #" + tokenID + ")") + + details := []string{ + "Type: NFT Sale", + "Collection: " + collection, + "Token ID: " + tokenID, + "Price: " + priceStr, + "Created At: " + ticket.CreatedAt.Format(time.RFC3339), + "Expires In: " + formatDuration(ticket.ExpiresAt.Sub(time.Now())), + "Status: " + statusEmojis[ticket.Status], + } + + str += md.BulletList(details) + + } else { + swapType := "" + var amountInStr, amountOutStr string + + if ticket.AssetIn.Type == AssetTypeCoin { + swapType = ticket.AssetIn.Denom + " → " + ticket.AssetOut.Token + amountInStr = ufmt.Sprintf("%f", float64(ticket.AmountIn)/1000000) + " " + ticket.AssetIn.Denom + + tokenOut := tokenhub.GetToken(ticket.AssetOut.Token) + decimalAdjustOut := math.Pow(10, float64(tokenOut.GetDecimals())) + amountOutStr = ufmt.Sprintf("%f", float64(ticket.MinAmountOut)/decimalAdjustOut) + " " + tokenOut.GetSymbol() + + } else if ticket.AssetOut.Type == AssetTypeCoin { + tokenIn := tokenhub.GetToken(ticket.AssetIn.Token) + swapType = tokenIn.GetSymbol() + " → " + ticket.AssetOut.Denom + + decimalAdjustIn := math.Pow(10, float64(tokenIn.GetDecimals())) + amountInStr = ufmt.Sprintf("%f", float64(ticket.AmountIn)/decimalAdjustIn) + " " + tokenIn.GetSymbol() + amountOutStr = ufmt.Sprintf("%f", float64(ticket.MinAmountOut)/1000000) + " " + ticket.AssetOut.Denom + + } else { + tokenIn := tokenhub.GetToken(ticket.AssetIn.Token) + tokenOut := tokenhub.GetToken(ticket.AssetOut.Token) + swapType = tokenIn.GetSymbol() + " → " + tokenOut.GetSymbol() + + decimalAdjustIn := math.Pow(10, float64(tokenIn.GetDecimals())) + decimalAdjustOut := math.Pow(10, float64(tokenOut.GetDecimals())) + + amountInStr = ufmt.Sprintf("%f", float64(ticket.AmountIn)/decimalAdjustIn) + " " + tokenIn.GetSymbol() + amountOutStr = ufmt.Sprintf("%f", float64(ticket.MinAmountOut)/decimalAdjustOut) + " " + tokenOut.GetSymbol() + } + + str += md.H3("#" + ticket.ID + " (" + swapType + ")") + + details := []string{ + "Type: Token Swap", + "Publisher Offers: " + amountInStr, + "Publisher Wants (min): " + amountOutStr, + "Created At: " + ticket.CreatedAt.Format(time.RFC3339), + "Expires In: " + formatDuration(ticket.ExpiresAt.Sub(time.Now())), + "Status: " + statusEmojis[ticket.Status], + } + + str += md.BulletList(details) + } + + str += md.HorizontalRule() + } + + if page.TotalPages > 1 { + str += "\n\nPages: " + page.Picker() + } + + return str +} + +func formatAsset(asset Asset, amount uint64) string { + if asset.Type == AssetTypeCoin { + return ufmt.Sprintf("**%d** %s", amount, asset.Denom) + } + return ufmt.Sprintf("**%d** %s", amount, asset.Token) +} + +func formatDuration(d time.Duration) string { + if d <= 0 { + return "Expired" + } + + hours := int(d.Hours()) + minutes := int(d.Minutes()) % 60 + + if hours > 24 { + days := hours / 24 + hours = hours % 24 + return ufmt.Sprintf("%dd %dh", days, hours) + } + + if hours > 0 { + return ufmt.Sprintf("%dh %dm", hours, minutes) + } + + return ufmt.Sprintf("%dm", minutes) +} + +func getTokenName(tokenPath string) string { + parts := strings.Split(tokenPath, ".") + if len(parts) > 0 { + return parts[len(parts)-1] + } + return tokenPath +} diff --git a/examples/gno.land/r/matijamarjanovic/gnoxchange/swap.gno b/examples/gno.land/r/matijamarjanovic/gnoxchange/swap.gno new file mode 100644 index 00000000000..12e4f4d270e --- /dev/null +++ b/examples/gno.land/r/matijamarjanovic/gnoxchange/swap.gno @@ -0,0 +1,159 @@ +package gnoxchange + +import ( + "errors" + "math" + "std" + + "gno.land/p/demo/ufmt" + "gno.land/r/matijamarjanovic/tokenhub" +) + +const ( + FEE_NUMERATOR = 3 // 0.3% + FEE_DENOMINATOR = 1000 +) + +// Swap executes a token swap in a given pool. Pool keys are made +// by concatenating the token keys with a semicolon (alphabetical order). +func Swap(poolKey string, tokenInKey string, amountIn uint64, minAmountOut uint64) (uint64, error) { + poolInterface, exists := poolRegistry.pools.Get(poolKey) + if !exists { + return 0, errors.New("pool not found") + } + pool := poolInterface.(*Pool) + + if tokenInKey != pool.tokenA && tokenInKey != pool.tokenB { + return 0, errors.New("invalid input token") + } + + tokenIn := tokenhub.GetToken(tokenInKey) + if tokenIn == nil { + return 0, errors.New("input token not found in tokenhub") + } + + var tokenOutKey string + if tokenInKey == pool.tokenA { + tokenOutKey = pool.tokenB + } else { + tokenOutKey = pool.tokenA + } + + tokenOut := tokenhub.GetToken(tokenOutKey) + if tokenOut == nil { + return 0, errors.New("output token not found in tokenhub") + } + + decimalsIn := tokenIn.GetDecimals() + decimalsOut := tokenOut.GetDecimals() + + minDecimals := decimalsIn + if decimalsOut < decimalsIn { + minDecimals = decimalsOut + } + + if decimalsIn > minDecimals { + expectedScale := uint64(math.Pow10(int(decimalsIn - minDecimals))) + if amountIn%expectedScale != 0 { + return 0, errors.New(ufmt.Sprintf( + "invalid input amount for %s: amount must be in units of %d (token has %d decimals vs %d decimals for the other token)", + tokenInKey, expectedScale, decimalsIn, minDecimals, + )) + } + } + + amountOut, err := calculateOutputAmount(pool, tokenInKey, amountIn) + if err != nil { + return 0, err + } + + if amountOut < minAmountOut { + return 0, errors.New(ufmt.Sprintf("insufficient output amount: expected minimum %d, got %d", minAmountOut, amountOut)) + } + + caller := std.PreviousRealm().Address() + + allowance := tokenIn.Allowance(caller, std.CurrentRealm().Address()) + if allowance < amountIn { + return 0, errors.New(ufmt.Sprintf("insufficient allowance for token %s: have %d, need %d", tokenInKey, allowance, amountIn)) + } + + tellerIn := tokenIn.RealmTeller() + if err := tellerIn.TransferFrom( + caller, + std.CurrentRealm().Address(), + amountIn, + ); err != nil { + return 0, errors.New(ufmt.Sprintf("failed to transfer input token: %v", err)) + } + + tellerOut := tokenOut.RealmTeller() + if err := tellerOut.Transfer(caller, amountOut); err != nil { + if refundErr := tellerIn.Transfer(caller, amountIn); refundErr != nil { + panic(ufmt.Sprintf("CRITICAL: failed to refund input token after failed output transfer: input error: %v, refund error: %v", + err, refundErr)) + } + return 0, errors.New(ufmt.Sprintf("failed to transfer output token: %v", err)) + } + + if tokenInKey == pool.tokenA { + pool.reserveA += amountIn + pool.reserveB -= amountOut + } else { + pool.reserveA -= amountOut + pool.reserveB += amountIn + } + + return amountOut, nil +} + +// calculateOutputAmount calculates the output amount for a given input amount +// using the constant product formula (x * y = k) with a 0.3% fee +func calculateOutputAmount(pool *Pool, tokenInKey string, amountIn uint64) (uint64, error) { + var reserveIn, reserveOut uint64 + + if tokenInKey == pool.tokenA { + reserveIn = pool.reserveA + reserveOut = pool.reserveB + } else { + reserveIn = pool.reserveB + reserveOut = pool.reserveA + } + + if reserveIn == 0 || reserveOut == 0 { + return 0, errors.New("insufficient liquidity") + } + + // calculate amount with fee (0.3%) + amountInWithFee := amountIn * (FEE_DENOMINATOR - FEE_NUMERATOR) + numerator := amountInWithFee * reserveOut + denominator := (reserveIn * FEE_DENOMINATOR) + amountInWithFee + + amountOut := numerator / denominator + + if amountOut == 0 { + return 0, errors.New("insufficient output amount") + } + + if amountOut >= reserveOut { + return 0, errors.New("insufficient liquidity") + } + + return amountOut, nil +} + +// GetSwapEstimate returns the estimated output amount for a given input amount +// This is useful for UI to show the expected output before executing the swap +func GetSwapEstimate(poolKey string, tokenInKey string, amountIn uint64) (uint64, error) { + poolInterface, exists := poolRegistry.pools.Get(poolKey) + if !exists { + return 0, errors.New("pool not found") + } + pool := poolInterface.(*Pool) + + if tokenInKey != pool.tokenA && tokenInKey != pool.tokenB { + return 0, errors.New("invalid input token") + } + + return calculateOutputAmount(pool, tokenInKey, amountIn) +} diff --git a/examples/gno.land/r/matijamarjanovic/gnoxchange/tickets.gno b/examples/gno.land/r/matijamarjanovic/gnoxchange/tickets.gno new file mode 100644 index 00000000000..bcac9af6a5b --- /dev/null +++ b/examples/gno.land/r/matijamarjanovic/gnoxchange/tickets.gno @@ -0,0 +1,375 @@ +package gnoxchange + +import ( + "errors" + "math" + "std" + "strings" + "time" + + "gno.land/p/demo/grc/grc721" + "gno.land/p/demo/ufmt" + "gno.land/r/matijamarjanovic/tokenhub" +) + +var ( + ticketCounter uint64 +) + +func init() { + ticketCounter = 0 +} + +// CreateCoinToTokenTicket creates a ticket to swap native coins for GRC20 tokens. +// The coins to be swapped must be sent with the transaction. They will be locked in the realm until +// one of the following happens (this is the case for all tickets): +// - the ticket is fulfilled +// - the ticket is cancelled +// - the ticket expires +func CreateCoinToTokenTicket( + coinDenom string, // e.g. "ugnot" + tokenKey string, // e.g. "gno.land/r/test.gtoken" + minAmountOut uint64, // minimum amount of tokens to receive + expiryHours int64, +) (string, error) { + caller := std.PreviousRealm().Address() + + sent := std.OriginSend() + if len(sent) != 1 || sent[0].Denom != coinDenom { + return "", errors.New("sent coins don't match ticket parameters") + } + amountIn := uint64(sent[0].Amount) + + tokenOut := tokenhub.GetToken(tokenKey) + if tokenOut == nil { + return "", errors.New("token not found: " + tokenKey) + } + + coinDecimals := uint(6) + tokenDecimals := tokenOut.GetDecimals() + + minDecimals := coinDecimals + if tokenDecimals < coinDecimals { + minDecimals = tokenDecimals + } + + if tokenDecimals > minDecimals { + expectedScale := uint64(math.Pow10(int(tokenDecimals - minDecimals))) + if minAmountOut%expectedScale != 0 { + return "", errors.New(ufmt.Sprintf( + "invalid minimum output amount: amount must be in units of %d (token has %d decimals vs %d decimals for native coin)", + expectedScale, tokenDecimals, coinDecimals, + )) + } + } + + ticketCounter++ + ticketID := ufmt.Sprintf("swap-%d", ticketCounter) + + ticket := &Ticket{ + ID: ticketID, + Creator: caller, + AssetIn: NewCoinAsset(coinDenom), + AssetOut: NewTokenAsset(tokenKey), + AmountIn: amountIn, + MinAmountOut: minAmountOut, + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(time.Duration(expiryHours) * time.Hour), + Status: "open", + } + + addTicket(ticket) + return ticketID, nil +} + +// CreateTokenToCoinTicket creates a ticket to swap GRC20 tokens for native coins. +// Requires token approval before creating the ticket. +func CreateTokenToCoinTicket( + tokenKey string, // e.g. "gno.land/r/test.gtoken" + coinDenom string, // e.g. "ugnot" + amountIn uint64, // amount of tokens to swap + minAmountOut uint64, // minimum amount of coins to receive + expiryHours int64, +) (string, error) { + caller := std.PreviousRealm().Address() + + tokenIn := tokenhub.GetToken(tokenKey) + if tokenIn == nil { + return "", errors.New("token not found: " + tokenKey) + } + + coinDecimals := uint(6) + tokenDecimals := tokenIn.GetDecimals() + + minDecimals := coinDecimals + if tokenDecimals < coinDecimals { + minDecimals = tokenDecimals + } + + if tokenDecimals > minDecimals { + expectedScale := uint64(math.Pow10(int(tokenDecimals - minDecimals))) + if amountIn%expectedScale != 0 { + return "", errors.New(ufmt.Sprintf( + "invalid input amount: amount must be in units of %d (token has %d decimals vs %d decimals for native coin)", + expectedScale, tokenDecimals, coinDecimals, + )) + } + } + + tellerIn := tokenIn.RealmTeller() + if err := tellerIn.TransferFrom(caller, std.CurrentRealm().Address(), amountIn); err != nil { + return "", errors.New("failed to transfer tokens: " + err.Error()) + } + + ticketCounter++ + ticketID := ufmt.Sprintf("swap-%d", ticketCounter) + + ticket := &Ticket{ + ID: ticketID, + Creator: caller, + AssetIn: NewTokenAsset(tokenKey), + AssetOut: NewCoinAsset(coinDenom), + AmountIn: amountIn, + MinAmountOut: minAmountOut, + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(time.Duration(expiryHours) * time.Hour), + Status: "open", + } + + addTicket(ticket) + return ticketID, nil +} + +// CreateTokenToTokenTicket creates a ticket to swap one GRC20 token for another. +// Requires token approval before creating the ticket. +func CreateTokenToTokenTicket( + tokenInKey string, // e.g. "gno.land/r/test1.gtokenA" + tokenOutKey string, // e.g. "gno.land/r/test2.gtokenB" + amountIn uint64, // amount of tokens to swap + minAmountOut uint64, // minimum amount of tokens to receive + expiryHours int64, +) (string, error) { + caller := std.PreviousRealm().Address() + + tokenIn := tokenhub.GetToken(tokenInKey) + if tokenIn == nil { + return "", errors.New("token not found: " + tokenInKey) + } + + tokenOut := tokenhub.GetToken(tokenOutKey) + if tokenOut == nil { + return "", errors.New("token not found: " + tokenOutKey) + } + + decimalsIn := tokenIn.GetDecimals() + decimalsOut := tokenOut.GetDecimals() + + minDecimals := decimalsIn + if decimalsOut < decimalsIn { + minDecimals = decimalsOut + } + + if decimalsIn > minDecimals { + expectedScale := uint64(math.Pow10(int(decimalsIn - minDecimals))) + if amountIn%expectedScale != 0 { + return "", errors.New(ufmt.Sprintf( + "invalid input amount for %s: amount must be in units of %d (token has %d decimals vs %d decimals for the other token)", + tokenInKey, expectedScale, decimalsIn, minDecimals, + )) + } + } + + if decimalsOut > minDecimals { + expectedScale := uint64(math.Pow10(int(decimalsOut - minDecimals))) + if minAmountOut%expectedScale != 0 { + return "", errors.New(ufmt.Sprintf( + "invalid minimum output amount for %s: amount must be in units of %d (token has %d decimals vs %d decimals for the other token)", + tokenOutKey, expectedScale, decimalsOut, minDecimals, + )) + } + } + + tellerIn := tokenIn.RealmTeller() + if err := tellerIn.TransferFrom(caller, std.CurrentRealm().Address(), amountIn); err != nil { + return "", errors.New("failed to transfer tokens: " + err.Error()) + } + + ticketCounter++ + ticketID := ufmt.Sprintf("swap-%d", ticketCounter) + + ticket := &Ticket{ + ID: ticketID, + Creator: caller, + AssetIn: NewTokenAsset(tokenInKey), + AssetOut: NewTokenAsset(tokenOutKey), + AmountIn: amountIn, + MinAmountOut: minAmountOut, + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(time.Duration(expiryHours) * time.Hour), + Status: "open", + } + + addTicket(ticket) + return ticketID, nil +} + +// FulfillTicket allows a user to fulfill an open ticket. +// If all checks pass, the ticket is fulfilled and the assets are transferred to both parties. +// +// note: If the ticket is asking for coins, the amountOut must be the exact amount of coins sent. +func FulfillTicket(ticketID string, amountOut uint64) error { + caller := std.PreviousRealm().Address() + + ticketInterface, exists := ticketRegistry.allTickets.Get(ticketID) + if !exists { + return errors.New("ticket not found") + } + + ticket := ticketInterface.(*Ticket) + + if ticket.Status != "open" { + return errors.New("ticket is not open") + } + + if time.Now().After(ticket.ExpiresAt) { + updateTicketStatus(ticket, "expired") + return errors.New("ticket has expired") + } + + if amountOut < ticket.MinAmountOut { + return errors.New("insufficient output amount") + } + + if ticket.AssetOut.Type == AssetTypeCoin { + sent := std.OriginSend() + if len(sent) != 1 || sent[0].Denom != ticket.AssetOut.Denom || uint64(sent[0].Amount) != amountOut { + return errors.New("sent coins don't match fulfillment parameters") + } + + banker := std.NewBanker(std.BankerTypeRealmSend) + banker.SendCoins(std.CurrentRealm().Address(), ticket.Creator, sent) + + if ticket.AssetIn.Type == AssetTypeCoin { + banker.SendCoins( + std.CurrentRealm().Address(), + caller, + std.Coins{{ticket.AssetIn.Denom, int64(ticket.AmountIn)}}, + ) + } else { + tokenIn := tokenhub.GetToken(ticket.AssetIn.Token) + tellerIn := tokenIn.RealmTeller() + if err := tellerIn.Transfer(caller, ticket.AmountIn); err != nil { + return errors.New("failed to transfer input token: " + err.Error()) + } + } + } else { + tokenOut := tokenhub.GetToken(ticket.AssetOut.Token) + if tokenOut == nil { + return errors.New("token not found: " + ticket.AssetOut.Token) + } + + tellerOut := tokenOut.RealmTeller() + if err := tellerOut.TransferFrom(caller, ticket.Creator, amountOut); err != nil { + return errors.New("failed to transfer output token: " + err.Error()) + } + + if ticket.AssetIn.Type == AssetTypeCoin { + banker := std.NewBanker(std.BankerTypeRealmSend) + banker.SendCoins( + std.CurrentRealm().Address(), + caller, + std.Coins{{ticket.AssetIn.Denom, int64(ticket.AmountIn)}}, + ) + } else { + tokenIn := tokenhub.GetToken(ticket.AssetIn.Token) + tellerIn := tokenIn.RealmTeller() + if err := tellerIn.Transfer(caller, ticket.AmountIn); err != nil { + return errors.New("failed to transfer input token: " + err.Error()) + } + } + } + + updateTicketStatus(ticket, "fulfilled") + return nil +} + +// CancelTicket allows the creator to cancel their ticket and withdraw the tokens/coins before the ticket is expired. +func CancelTicket(ticketID string) error { + caller := std.PreviousRealm().Address() + + ticketInterface, exists := ticketRegistry.allTickets.Get(ticketID) + if !exists { + return errors.New("ticket not found") + } + + ticket := ticketInterface.(*Ticket) + + if ticket.Creator != caller { + return errors.New("only ticket creator can cancel") + } + + if ticket.Status != "open" { + return errors.New("ticket is not open") + } + + if ticket.AssetIn.Type == AssetTypeCoin { + banker := std.NewBanker(std.BankerTypeRealmSend) + banker.SendCoins( + std.CurrentRealm().Address(), + ticket.Creator, + std.Coins{{ticket.AssetIn.Denom, int64(ticket.AmountIn)}}, + ) + } else if ticket.AssetIn.Type == AssetTypeNFT { + nft := tokenhub.GetNFT(ticket.AssetIn.NFTPath) + if nft == nil { + return errors.New("NFT not found: " + ticket.AssetIn.NFTPath) + } + + parts := strings.Split(ticket.AssetIn.NFTPath, ".") + tokenID := parts[len(parts)-1] + + if err := nft.TransferFrom(std.CurrentRealm().Address(), ticket.Creator, grc721.TokenID(tokenID)); err != nil { + return errors.New(ufmt.Sprintf("failed to return NFT: %v", err)) + } + } else { + tokenIn := tokenhub.GetToken(ticket.AssetIn.Token) + tellerIn := tokenIn.RealmTeller() + if err := tellerIn.Transfer(ticket.Creator, ticket.AmountIn); err != nil { + return errors.New(ufmt.Sprintf("failed to refund tokens: %v", err)) + } + } + + updateTicketStatus(ticket, "cancelled") + return nil +} + +// Helper function to add a ticket to the appropriate trees +func addTicket(ticket *Ticket) { + ticketID := ticket.ID + + ticketRegistry.allTickets.Set(ticketID, ticket) + + if ticket.Status == "open" && !time.Now().After(ticket.ExpiresAt) { + if ticket.AssetIn.Type == AssetTypeNFT { + ticketRegistry.openNFTTickets.Set(ticketID, ticket) + } else { + ticketRegistry.openTickets.Set(ticketID, ticket) + } + } +} + +// Helper function to update ticket status +func updateTicketStatus(ticket *Ticket, newStatus string) { + oldStatus := ticket.Status + ticket.Status = newStatus + + ticketRegistry.allTickets.Set(ticket.ID, ticket) + + if oldStatus == "open" && newStatus != "open" { + if ticket.AssetIn.Type == AssetTypeNFT { + ticketRegistry.openNFTTickets.Remove(ticket.ID) + } else { + ticketRegistry.openTickets.Remove(ticket.ID) + } + } +} diff --git a/examples/gno.land/r/matijamarjanovic/gnoxchange/types.gno b/examples/gno.land/r/matijamarjanovic/gnoxchange/types.gno new file mode 100644 index 00000000000..e21f397faed --- /dev/null +++ b/examples/gno.land/r/matijamarjanovic/gnoxchange/types.gno @@ -0,0 +1,108 @@ +package gnoxchange + +import ( + "std" + "time" + + "gno.land/p/demo/avl" + "gno.land/p/demo/grc/grc20" +) + +// PoolRegistry is a registry for swap pools. It is a singleton that is initialized in the init function. +// It is private to only this realm and cannot and should not be accessed in other realms. +type PoolRegistry struct { + pools *avl.Tree // key is "tokenA:tokenB", value is *Pool +} + +// Pool is a struct that represents a swap pool. It contains the two tokens in the pool, +// the reserves of each token, the liquidity provider token, and the total supply of the liquidity provider token. +// None of the pool's fields are to be accessed externally, only through the PoolRegistry in the gnoXchange realm. +type Pool struct { + tokenA string // tokenhub tokenKey + tokenB string // tokenhub tokenKey + reserveA uint64 + reserveB uint64 + lpToken *grc20.Token + lpLedger *grc20.PrivateLedger + totalSupplyLp uint64 +} + +// TokenInfo represents the public information about a token +type TokenInfo struct { + Path string + Name string + Symbol string + Decimals uint +} + +// PoolInfo represents the public information about a pool +type PoolInfo struct { + PoolKey string + TokenAInfo TokenInfo + TokenBInfo TokenInfo + ReserveA uint64 + ReserveB uint64 + TotalSupplyLP uint64 +} + +// Ticket is a struct that represents a swap ticket. +// It holds information about the available swaps +type Ticket struct { + ID string + Creator std.Address + AssetIn Asset + AssetOut Asset + AmountIn uint64 + MinAmountOut uint64 + CreatedAt time.Time + ExpiresAt time.Time + Status string +} + +// TicketRegisty is consisted of 3 trees only for better gas efficiency. +// Most of the time, user would only want to see open tickets or nfts currently for sale. +// There is no need to iterate over all tickets everry time. +type TicketRegistry struct { + allTickets *avl.Tree // all tickets (including NFTs) + openTickets *avl.Tree // open non-NFT tickets + openNFTTickets *avl.Tree // open NFT tickets +} + +// Types of assets +type AssetType uint8 + +const ( + AssetTypeCoin AssetType = iota // Native coins (like ugnot) + AssetTypeToken // GRC20 tokens + AssetTypeNFT // GRC721 NFTs +) + +// Asset is a struct shared between coins, tokens and NFTs +type Asset struct { + Type AssetType + Denom string // for coins (e.g. "ugnot") + Token string // for GRC20 tokens (e.g. "gno.land/r/test.testtokena") + NFTPath string // for NFTs (e.g. "gno.land/r/test.mycollection.1") +} + +// Helper functions for creating assets +func NewCoinAsset(denom string) Asset { + return Asset{ + Type: AssetTypeCoin, + Denom: denom, + } +} + +func NewTokenAsset(token string) Asset { + return Asset{ + Type: AssetTypeToken, + Token: token, + } +} + +func NewNFTAsset(nftFullPath string) Asset { + return Asset{ + Type: AssetTypeNFT, + NFTPath: nftFullPath, + } +} diff --git a/examples/gno.land/r/matijamarjanovic/test/gno.mod b/examples/gno.land/r/matijamarjanovic/test/gno.mod new file mode 100644 index 00000000000..aa2814cdb1e --- /dev/null +++ b/examples/gno.land/r/matijamarjanovic/test/gno.mod @@ -0,0 +1 @@ +module gno.land/r/matijamarjanovic/test diff --git a/examples/gno.land/r/matijamarjanovic/test/test.gno b/examples/gno.land/r/matijamarjanovic/test/test.gno new file mode 100644 index 00000000000..07fefc5e13e --- /dev/null +++ b/examples/gno.land/r/matijamarjanovic/test/test.gno @@ -0,0 +1,131 @@ +package test + +import ( + "std" + + "gno.land/p/demo/grc/grc1155" + "gno.land/p/demo/grc/grc20" + "gno.land/p/demo/grc/grc721" + "gno.land/p/demo/ufmt" + "gno.land/r/matijamarjanovic/tokenhub" +) + +var ( + nftA = grc721.NewBasicNFT("Collection A", "NFTA") + nftB = grc721.NewBasicNFT("Collection B", "NFTB") + nftC = grc721.NewBasicNFT("Collection C", "NFTC") + + tokenA, tokenAAdmin = grc20.NewToken("Token A", "TOKA", 6) + tokenB, tokenBAdmin = grc20.NewToken("Token B", "TOKB", 6) + tokenC, tokenCAdmin = grc20.NewToken("Token C", "TOKC", 6) + + mtGameItems = grc1155.NewBasicGRC1155Token("https://game.example.com/items/{id}.json") + mtArtworks = grc1155.NewBasicGRC1155Token("https://art.example.com/pieces/{id}.json") + mtCollectibles = grc1155.NewBasicGRC1155Token("https://collect.example.com/cards/{id}.json") +) + +func init() { + testAddr := std.Address("g1ej0qca5ptsw9kfr64ey8jvfy9eacga6mpj2z0y") + MintTestNFTs(testAddr) + MintTestTokens(testAddr) + MintTestMultiTokens(testAddr) + + tokenhub.RegisterNFT(func() grc721.IGRC721 { return nftA }, "nft-a", "NFTA_1") + tokenhub.RegisterNFT(func() grc721.IGRC721 { return nftB }, "nft-b", "NFTB_1") + tokenhub.RegisterNFT(func() grc721.IGRC721 { return nftC }, "nft-c", "NFTC_1") + tokenhub.RegisterNFT(func() grc721.IGRC721 { return nftA }, "nft-a", "NFTA_2") + tokenhub.RegisterNFT(func() grc721.IGRC721 { return nftB }, "nft-b", "NFTB_2") + tokenhub.RegisterNFT(func() grc721.IGRC721 { return nftC }, "nft-c", "NFTC_2") + + tokenhub.RegisterToken(tokenA.Getter(), "token-a") + tokenhub.RegisterToken(tokenB.Getter(), "token-b") + tokenhub.RegisterToken(tokenC.Getter(), "token-c") + + tokenhub.RegisterMultiToken(mtGameItems.Getter(), "sword") + tokenhub.RegisterMultiToken(mtGameItems.Getter(), "potion") + tokenhub.RegisterMultiToken(mtArtworks.Getter(), "artwork_1") + tokenhub.RegisterMultiToken(mtCollectibles.Getter(), "rare_card") +} + +func MintTestNFTs(to std.Address) error { + if !to.IsValid() { + return grc721.ErrInvalidAddress + } + + for i := 1; i <= 2; i++ { + tokenIDA := grc721.TokenID(ufmt.Sprintf("NFTA_%d", i)) + if err := nftA.Mint(to, tokenIDA); err != nil { + return err + } + + tokenIDB := grc721.TokenID(ufmt.Sprintf("NFTB_%d", i)) + if err := nftB.Mint(to, tokenIDB); err != nil { + return err + } + + tokenIDC := grc721.TokenID(ufmt.Sprintf("NFTC_%d", i)) + if err := nftC.Mint(to, tokenIDC); err != nil { + return err + } + } + + return nil +} + +func MintTestTokens(to std.Address) error { + if !to.IsValid() { + return grc20.ErrInvalidAddress + } + + if err := tokenAAdmin.Mint(to, 1000_000000); err != nil { + return err + } + if err := tokenBAdmin.Mint(to, 1000_000000); err != nil { + return err + } + if err := tokenCAdmin.Mint(to, 1000_000000); err != nil { + return err + } + + if err := tokenAAdmin.Mint(std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5"), 1000_000000); err != nil { + return err + } + if err := tokenBAdmin.Mint(std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5"), 1000_000000); err != nil { + return err + } + if err := tokenCAdmin.Mint(std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5"), 1000_000000); err != nil { + return err + } + + if err := tokenAAdmin.Mint(std.Address("g1kjsl2ungmc95mgluq96w8dqlep8d4n8cxdfthk"), 1000_000000); err != nil { + return err + } + if err := tokenBAdmin.Mint(std.Address("g1kjsl2ungmc95mgluq96w8dqlep8d4n8cxdfthk"), 1000_000000); err != nil { + return err + } + if err := tokenCAdmin.Mint(std.Address("g1kjsl2ungmc95mgluq96w8dqlep8d4n8cxdfthk"), 1000_000000); err != nil { + return err + } + return nil +} + +func MintTestMultiTokens(to std.Address) error { + if !to.IsValid() { + return grc1155.ErrInvalidAddress + } + + if err := mtGameItems.SafeMint(to, "sword", 10); err != nil { + return err + } + if err := mtGameItems.SafeMint(to, "potion", 50); err != nil { + return err + } + if err := mtArtworks.SafeMint(to, "artwork_1", 1); err != nil { + return err + } + if err := mtCollectibles.SafeMint(to, "rare_card", 1); err != nil { + return err + } + + return nil +} diff --git a/examples/gno.land/r/matijamarjanovic/tokenhub/errors.gno b/examples/gno.land/r/matijamarjanovic/tokenhub/errors.gno new file mode 100644 index 00000000000..f4030a06cf7 --- /dev/null +++ b/examples/gno.land/r/matijamarjanovic/tokenhub/errors.gno @@ -0,0 +1,15 @@ +package tokenhub + +import ( + "errors" +) + +var ( + ErrNFTAlreadyRegistered = errors.New("NFT already registered") + ErrNFTNotFound = errors.New("NFT not found") + ErrMTAlreadyRegistered = errors.New("multi-token already registered") + ErrMTNotFound = errors.New("multi-token not found") + ErrMTInfoNotFound = errors.New("multi-token info not found") + ErrNFTtokIDNotExists = errors.New("NFT token ID does not exists") + ErrNFTNotMetadata = errors.New("NFT must implement IGRC721CollectionMetadata") +) diff --git a/examples/gno.land/r/matijamarjanovic/tokenhub/getters.gno b/examples/gno.land/r/matijamarjanovic/tokenhub/getters.gno new file mode 100644 index 00000000000..94f78de9802 --- /dev/null +++ b/examples/gno.land/r/matijamarjanovic/tokenhub/getters.gno @@ -0,0 +1,216 @@ +package tokenhub + +import ( + "std" + "strings" + + "gno.land/p/demo/grc/grc1155" + "gno.land/p/demo/grc/grc20" + "gno.land/p/demo/grc/grc721" + "gno.land/p/demo/ufmt" + "gno.land/r/demo/grc20reg" + "gno.land/r/demo/users" +) + +// GetUserTokenBalances returns a string of all the grc20 tokens the user owns +func GetUserTokenBalances(userNameOrAddress string) string { + return getTokenBalances(userNameOrAddress, false) +} + +// GetUserTokenBalancesNonZero returns a string of all the grc20 tokens the user owns, but only the ones that have a balance greater than 0 +func GetUserTokenBalancesNonZero(userNameOrAddress string) string { + return getTokenBalances(userNameOrAddress, true) +} + +// GetUserNFTBalances returns a string of all the NFTs the user owns +func GetUserNFTBalances(userNameOrAddress string) string { + return getNFTBalances(userNameOrAddress) +} + +// GetUserMultiTokenBalances returns a string of all the multi-tokens the user owns +func GetUserMultiTokenBalances(userNameOrAddress string) string { + return getMultiTokenBalances(userNameOrAddress, false) +} + +// GetUserMultiTokenBalancesNonZero returns a string of all the multi-tokens the user owns, but only the ones that have a balance greater than 0 +func GetUserMultiTokenBalancesNonZero(userNameOrAddress string) string { + return getMultiTokenBalances(userNameOrAddress, true) +} + +// GetToken returns a token instance for a given key +func GetToken(key string) *grc20.Token { + getter := grc20reg.Get(key) + return getter() +} + +// MustGetToken returns a token instance for a given key, panics if the token is not found +func MustGetToken(key string) *grc20.Token { + getter := grc20reg.MustGet(key) + if getter == nil { + panic("unknown token: " + key) + } + return getter() +} + +// GetNFT returns an NFT instance for a given key +func GetNFT(key string) grc721.IGRC721 { + nftGetter, ok := registeredNFTs.Get(key) + if !ok { + return nil + } + return (nftGetter.(grc721.NFTGetter))() +} + +// MustGetNFT returns an NFT instance for a given key, panics if the NFT is not found +func MustGetNFT(key string) grc721.IGRC721 { + nftGetter := GetNFT(key) + if nftGetter == nil { + panic("unknown NFT: " + key) + } + return nftGetter +} + +// GetMultiToken returns a multi-token instance for a given key +func GetMultiToken(key string) grc1155.IGRC1155 { + info, ok := registeredMTs.Get(key) + if !ok { + return nil + } + mt := info.(GRC1155TokenInfo).Collection + return mt() +} + +// MustGetMultiToken returns a multi-token instance for a given key, panics if the multi-token is not found +func MustGetMultiToken(key string) grc1155.IGRC1155 { + info := GetMultiToken(key) + if info == nil { + panic("unknown multi-token: " + key) + } + return info +} + +// GetAllNFTs returns a string of all the NFTs registered +func GetAllNFTs() string { + var out string + registeredNFTs.Iterate("", "", func(key string, value interface{}) bool { + out += ufmt.Sprintf("NFT:%s,", key) + return false + }) + return out +} + +// GetAllTokens returns a string of all the tokens registered +func GetAllTokens() string { + var out string + grc20reg.GetRegistry().Iterate("", "", func(key string, value interface{}) bool { + out += "Token:" + key + "," + return false + }) + return out +} + +// GetAllTokenWithDetails returns a string of all the tokens registered with their details +func GetAllTokenWithDetails() string { + var out string + grc20reg.GetRegistry().Iterate("", "", func(key string, value interface{}) bool { + tokenGetter := value.(grc20.TokenGetter) + token := tokenGetter() + out += ufmt.Sprintf("Token:%s,Name:%s,Symbol:%s,Decimals:%d;", key, token.GetName(), token.GetSymbol(), token.GetDecimals()) + return false + }) + return out +} + +// GetAllMultiTokens returns a string of all the multi-tokens registered +func GetAllMultiTokens() string { + var out string + registeredMTs.Iterate("", "", func(key string, value interface{}) bool { + out += "MultiToken:" + key + "," + return false + }) + return out +} + +// GetAllRegistered returns a string of all the registered tokens, NFTs and multi-tokens +func GetAllRegistered() string { + return GetAllNFTs() + GetAllTokens() + GetAllMultiTokens() +} + +// getNFTBalances returns a string of all the NFTs the user owns +func getNFTBalances(input string) string { + addr := getAddressForUsername(input) + if !addr.IsValid() { + panic("invalid address or username: " + input) + } + var out string + + registeredNFTs.Iterate("", "", func(key string, value interface{}) bool { + nftGetter := value.(grc721.NFTGetter) + nft := nftGetter() + key_parts := strings.Split(key, ".") + owner, err := nft.OwnerOf(grc721.TokenID(key_parts[len(key_parts)-1])) + if err == nil && addr == owner { // show only the nfts owner owns + out += "NFT:" + key + "," + } + return false + }) + + return out +} + +// getTokenBalances returns a string of all the tokens the user owns +func getTokenBalances(input string, nonZero bool) string { + addr := getAddressForUsername(input) + if !addr.IsValid() { + panic("invalid address or username: " + input) + } + var out string + grc20reg.GetRegistry().Iterate("", "", func(key string, value interface{}) bool { + tokenGetter := value.(grc20.TokenGetter) + token := tokenGetter() + balance := token.BalanceOf(addr) + if !nonZero || balance > 0 { + out += ufmt.Sprintf("Token:%s:%d,", key, balance) + } + return false + }) + + return out +} + +// getMultiTokenBalances returns a string of all the multi-tokens the user owns +func getMultiTokenBalances(input string, nonZero bool) string { + addr := getAddressForUsername(input) + if !addr.IsValid() { + panic("invalid address or username: " + input) + } + var out string + + registeredMTs.Iterate("", "", func(key string, value interface{}) bool { + info := value.(GRC1155TokenInfo) + mt := info.Collection() + balance, err := mt.BalanceOf(addr, grc1155.TokenID(info.TokenID)) + if err == nil { + if !nonZero || balance > 0 { + out += ufmt.Sprintf("MultiToken:%s:%d,", key, balance) + } + } + return false + }) + + return out +} + +// getAddressForUsername returns an address for a given username or address +func getAddressForUsername(addrOrName string) std.Address { + addr := std.Address(addrOrName) + if addr.IsValid() { + return addr + } + + if user := users.GetUserByName(addrOrName); user != nil { + return user.Address + } + + return "" +} diff --git a/examples/gno.land/r/matijamarjanovic/tokenhub/gno.mod b/examples/gno.land/r/matijamarjanovic/tokenhub/gno.mod new file mode 100644 index 00000000000..19deb1b830d --- /dev/null +++ b/examples/gno.land/r/matijamarjanovic/tokenhub/gno.mod @@ -0,0 +1 @@ +module gno.land/r/matijamarjanovic/tokenhub diff --git a/examples/gno.land/r/matijamarjanovic/tokenhub/render.gno b/examples/gno.land/r/matijamarjanovic/tokenhub/render.gno new file mode 100644 index 00000000000..a416602df65 --- /dev/null +++ b/examples/gno.land/r/matijamarjanovic/tokenhub/render.gno @@ -0,0 +1,170 @@ +package tokenhub + +import ( + "strings" + + "gno.land/p/demo/avl/pager" + "gno.land/p/demo/grc/grc20" + "gno.land/p/demo/grc/grc721" + "gno.land/p/demo/ufmt" + "gno.land/p/moul/md" + "gno.land/r/demo/grc20reg" +) + +const ( + token = "token" // grc20 + nft = "nft" // grc721 + mt = "mt" // grc1155 +) + +func Render(path string) string { + var out string + + switch { + case path == "": + out = renderHome() + + case strings.HasPrefix(path, token): + out = renderToken(path) + + case strings.HasPrefix(path, nft): + out = renderNFT(path) + + case strings.HasPrefix(path, mt): + out = renderMT(path) + + default: + out = md.H1("404 Not Found") + out += md.Paragraph("The requested page does not exist.") + out += "[Back to home](/r/matijamarjanovic/tokenhub)" + } + + return out +} + +func renderHome() string { + out := md.H1("Token Hub") + out += md.Paragraph("A central registry for GRC721 NFTs, GRC20 tokens, and GRC1155 multi-tokens on gno.land") + + links := []string{ + "[GRC20 Tokens](/r/matijamarjanovic/tokenhub:tokens)", + "[GRC721 NFTs](/r/matijamarjanovic/tokenhub:nfts)", + "[GRC1155 Multi-Tokens](/r/matijamarjanovic/tokenhub:mts)", + } + out += md.BulletList(links) + + return out +} + +func renderToken(path string) string { + out := md.H1("GRC20 Tokens") + var tokenItems []string + + tokenPager := pager.NewPager(grc20reg.GetRegistry(), pageSize, false) + + page := tokenPager.MustGetPageByPath(path) + + for _, item := range page.Items { + tokenGetter := item.Value.(grc20.TokenGetter) + token := tokenGetter() + tokenItems = append(tokenItems, ufmt.Sprintf("%s (%s) [%s]", + md.Bold(token.GetName()), + md.InlineCode(token.GetSymbol()), + md.InlineCode(item.Key))) + } + + if len(tokenItems) > 0 { + out += md.BulletList(tokenItems) + out += "\n" + + if picker := page.Picker(); picker != "" { + out += md.HorizontalRule() + out += md.Paragraph(picker) + } else { + out += md.HorizontalRule() + } + } else { + out += md.Italic("No tokens registered yet") + out += "\n" + } + out += renderFooter() + return out +} + +func renderNFT(path string) string { + out := md.H1("GRC721 NFTs") + + var nftItems []string + nftPager := pager.NewPager(registeredNFTs, pageSize, false) + page := nftPager.MustGetPageByPath(path) + + for _, item := range page.Items { + nftGetter := item.Value.(grc721.NFTGetter) + nft := nftGetter() + metadata, ok := nft.(grc721.IGRC721CollectionMetadata) + if !ok { + continue + } + + nftItems = append(nftItems, ufmt.Sprintf("%s (%s) [%s]", + md.Bold(metadata.Name()), + md.InlineCode(metadata.Symbol()), + md.InlineCode(item.Key))) + } + + if len(nftItems) > 0 { + out += md.BulletList(nftItems) + out += "\n" + + if picker := page.Picker(); picker != "" { + out += md.HorizontalRule() + out += md.Paragraph(picker) + } else { + out += md.HorizontalRule() + } + } else { + out += md.Italic("No NFTs registered yet") + out += "\n" + } + out += renderFooter() + return out +} + +func renderMT(path string) string { + out := md.H1("GRC1155 Multi-Tokens") + var mtItems []string + + mtPager := pager.NewPager(registeredMTs, pageSize, false) + + page := mtPager.MustGetPageByPath(path) + + for _, item := range page.Items { + info := item.Value.(GRC1155TokenInfo) + mtItems = append(mtItems, ufmt.Sprintf("%s: %s [%s]", + md.Bold("TokenID"), + md.InlineCode(info.TokenID), + md.InlineCode(item.Key))) + } + + if len(mtItems) > 0 { + out += md.BulletList(mtItems) + out += "\n" + + if picker := page.Picker(); picker != "" { + out += md.HorizontalRule() + out += md.Paragraph(picker) + } else { + out += md.HorizontalRule() + } + } else { + out += md.Italic("No multi-tokens registered yet") + out += "\n" + } + out += renderFooter() + return out +} +func renderFooter() string { + out := "\n" + out += md.Link("Back to home", "http://localhost:8888/r/matijamarjanovic/tokenhub") + return out +} diff --git a/examples/gno.land/r/matijamarjanovic/tokenhub/tokenhub.gno b/examples/gno.land/r/matijamarjanovic/tokenhub/tokenhub.gno new file mode 100644 index 00000000000..a2a7ba769fe --- /dev/null +++ b/examples/gno.land/r/matijamarjanovic/tokenhub/tokenhub.gno @@ -0,0 +1,86 @@ +package tokenhub + +import ( + "std" + + "gno.land/p/demo/avl" + "gno.land/p/demo/fqname" + "gno.land/p/demo/grc/grc1155" + "gno.land/p/demo/grc/grc20" + "gno.land/p/demo/grc/grc721" + "gno.land/r/demo/grc20reg" + "gno.land/r/leon/hof" +) + +type GRC1155TokenInfo struct { + Collection grc1155.MultiTokenGetter + TokenID string +} + +var ( + registeredTokens = avl.NewTree() // rlmPath[.slug] -> grc20.TokenGetter + registeredNFTs = avl.NewTree() // rlmPath[.slug] -> grc721.NFTGetter + registeredMTs = avl.NewTree() // rlmPath[.slug] -> GRC1155TokenInfo +) + +const pageSize = 10 + +func init() { + hof.Register() +} + +// RegisterToken is a function that uses gno.land/r/demo/grc20reg to register a token +// It uses the slug to construct a key and then registers the token in the registry +// The logic is the same as in grc20reg, but it's done here so the key path is callers pkgpath and not of this realm +// After doing so, the token hub realm uses grc20reg's registry as a read-only avl.Tree +func RegisterToken(tokenGetter grc20.TokenGetter, slug string) { + rlmPath := std.PreviousRealm().PkgPath() + key := fqname.Construct(rlmPath, slug) + grc20reg.RegisterWithTokenhub(tokenGetter, key) +} + +// RegisterNFT is a function that registers an NFT in an avl.Tree +func RegisterNFT(nftGetter grc721.NFTGetter, collection string, tokenId string) error { + nft := nftGetter() + _, ok := nft.(grc721.IGRC721CollectionMetadata) + if !ok { + return ErrNFTNotMetadata + } + + nftOwner, err := nft.OwnerOf(grc721.TokenID(tokenId)) + + if err != nil { + return err + } + if !nftOwner.IsValid() { + return ErrNFTtokIDNotExists + } + + rlmPath := std.PreviousRealm().PkgPath() + key := rlmPath + "." + collection + "." + tokenId + + if registeredNFTs.Has(key) { + return ErrNFTAlreadyRegistered + } + + registeredNFTs.Set(key, nftGetter) + return nil +} + +// RegisterMultiToken is a function that registers a multi-token in an avl.Tree +// The avl.Tree value is a struct defined in this realm. It contains not only the getter (like other token types) but also the tokenID +func RegisterMultiToken(mtGetter grc1155.MultiTokenGetter, tokenID string) error { + rlmPath := std.PreviousRealm().PkgPath() + + key := rlmPath + "." + tokenID + + if registeredMTs.Has(key) { + return ErrMTAlreadyRegistered + } + + registeredMTs.Set(key, GRC1155TokenInfo{ + Collection: mtGetter, + TokenID: tokenID, + }) + return nil +} diff --git a/examples/gno.land/r/matijamarjanovic/tokenhub/tokenhub_test.gno b/examples/gno.land/r/matijamarjanovic/tokenhub/tokenhub_test.gno new file mode 100644 index 00000000000..43bf1011329 --- /dev/null +++ b/examples/gno.land/r/matijamarjanovic/tokenhub/tokenhub_test.gno @@ -0,0 +1,194 @@ +package tokenhub + +import ( + "std" + "strings" + "testing" + + "gno.land/p/demo/grc/grc1155" + "gno.land/p/demo/grc/grc20" + "gno.land/p/demo/grc/grc721" + "gno.land/p/demo/uassert" + "gno.land/p/demo/urequire" +) + +func TestTokenRegistration(t *testing.T) { + std.TestSetRealm(std.NewCodeRealm("gno.land/r/matijamarjanovic/home")) + + token, _ := grc20.NewToken("Test Token", "TEST", 6) + RegisterToken(token.Getter(), "test_token") + + retrievedToken := GetToken("gno.land/r/matijamarjanovic/home.test_token") + urequire.True(t, retrievedToken != nil, "Should retrieve registered token") + + uassert.Equal(t, "Test Token", retrievedToken.GetName(), "Token name should match") + uassert.Equal(t, "TEST", retrievedToken.GetSymbol(), "Token symbol should match") +} + +func TestNFTRegistration(t *testing.T) { + std.TestSetRealm(std.NewCodeRealm("gno.land/r/matijamarjanovic/home")) + + nft := grc721.NewBasicNFT("Test NFT", "TNFT") + nft.Mint(std.CurrentRealm().Address(), grc721.TokenID("1")) + err := RegisterNFT(nft.Getter(), "test_nft", "1") + urequire.NoError(t, err, "Should register NFT without error") + + retrievedNFT := GetNFT("gno.land/r/matijamarjanovic/home.test_nft.1") + urequire.True(t, retrievedNFT != nil, "Should retrieve registered NFT") + + metadata, ok := retrievedNFT.(grc721.IGRC721CollectionMetadata) + urequire.True(t, ok, "NFT should implement IGRC721CollectionMetadata") + uassert.Equal(t, "Test NFT", metadata.Name(), "NFT name should match") + uassert.Equal(t, "TNFT", metadata.Symbol(), "NFT symbol should match") +} + +func TestGRC1155Registration(t *testing.T) { + std.TestSetRealm(std.NewCodeRealm("gno.land/r/matijamarjanovic/home")) + + mt := grc1155.NewBasicGRC1155Token("test-uri") + err := RegisterMultiToken(mt.Getter(), "1") + urequire.NoError(t, err, "Should register multi-token without error") + + multiToken := GetMultiToken("gno.land/r/matijamarjanovic/home.1") + urequire.True(t, multiToken != nil, "Should retrieve multi-token") + _, ok := multiToken.(grc1155.IGRC1155) + urequire.True(t, ok, "Retrieved multi-token should implement IGRC1155") + + err = RegisterMultiToken(mt.Getter(), "1") + uassert.True(t, err != nil, "Should not allow duplicate registration") +} + +func TestBalanceRetrieval(t *testing.T) { + std.TestSetRealm(std.NewCodeRealm("gno.land/r/matijamarjanovic/home")) + + token, ledger := grc20.NewToken("Test Token", "TEST", 6) + RegisterToken(token.Getter(), "test_tokenn") + ledger.Mint(std.CurrentRealm().Address(), 1000) + + mt := grc1155.NewBasicGRC1155Token("test-uri") + RegisterMultiToken(mt.Getter(), "11") + mt.SafeMint(std.CurrentRealm().Address(), grc1155.TokenID("11"), 5) + + balances := GetUserTokenBalances(std.CurrentRealm().Address().String()) + uassert.True(t, strings.Contains(balances, "Token:gno.land/r/matijamarjanovic/home.test_tokenn:1000"), "Should show correct GRC20 balance") + + balances = GetUserNFTBalances(std.CurrentRealm().Address().String()) + uassert.True(t, strings.Contains(balances, "NFT:gno.land/r/matijamarjanovic/home.test_nft.1"), "Should show correct NFT balance") //already minted in test register + + balances = GetUserMultiTokenBalances(std.CurrentRealm().Address().String()) + uassert.True(t, strings.Contains(balances, "MultiToken:gno.land/r/matijamarjanovic/home.11:5"), "Should show multi-token balance") + + nonZeroBalances := GetUserTokenBalancesNonZero(std.CurrentRealm().Address().String()) + uassert.True(t, strings.Contains(nonZeroBalances, "Token:gno.land/r/matijamarjanovic/home.test_tokenn:1000"), "Should show non-zero GRC20 balance") +} + +func TestErrorCases(t *testing.T) { + std.TestSetRealm(std.NewCodeRealm("gno.land/r/matijamarjanovic/home")) + + nft := grc721.NewBasicNFT("Test NFT", "TNFT") + err := RegisterNFT(nft.Getter(), "test_nft", "1") + uassert.True(t, err != nil, "Should not allow duplicate registration") + + err = RegisterNFT(nft.Getter(), "test_nft", "1") + uassert.True(t, err != nil, "Should not allow duplicate registration") + + mt := grc1155.NewBasicGRC1155Token("test-uri") + err = RegisterMultiToken(mt.Getter(), "1") + uassert.True(t, err != nil, "Should not allow duplicate registration") + + err = RegisterMultiToken(mt.Getter(), "1") + uassert.True(t, err != nil, "Should not allow duplicate registration") +} + +func TestTokenListingFunctions(t *testing.T) { + std.TestSetRealm(std.NewCodeRealm("gno.land/r/matijamarjanovic/home")) + + grc20Token, _ := grc20.NewToken("Test Token", "TEST", 6) + RegisterToken(grc20Token.Getter(), "listing_token") + + nftToken := grc721.NewBasicNFT("Listing NFT", "LNFT") + nftToken.Mint(std.Address("g1ej0qca5ptsw9kfr64ey8jvfy9eacga6mpj2z0y"), grc721.TokenID("1")) + RegisterNFT(nftToken.Getter(), "listing_nft", "1") + + multiToken := grc1155.NewBasicGRC1155Token("test-uri") + RegisterMultiToken(multiToken.Getter(), "listing_mt") + + nftList := GetAllNFTs() + uassert.True(t, strings.Contains(nftList, "NFT:gno.land/r/matijamarjanovic/home.listing_nft.1"), + "GetAllNFTs should list registered NFT") + + grc20List := GetAllTokens() + uassert.True(t, strings.Contains(grc20List, "Token:gno.land/r/matijamarjanovic/home.listing_token"), + "GetAllGRC20Tokens should list registered token") + + grc1155List := GetAllMultiTokens() + uassert.True(t, strings.Contains(grc1155List, "MultiToken:gno.land/r/matijamarjanovic/home.listing_mt"), + "GetAllMultiTokens should list registered multi-token") + + completeList := GetAllRegistered() + uassert.True(t, strings.Contains(completeList, "NFT:gno.land/r/matijamarjanovic/home.listing_nft.1"), + "GetAllTokens should list NFTs") + uassert.True(t, strings.Contains(completeList, "Token:gno.land/r/matijamarjanovic/home.listing_token"), + "GetAllTokens should list GRC20 tokens") + uassert.True(t, strings.Contains(completeList, "MultiToken:gno.land/r/matijamarjanovic/home.listing_mt"), + "GetAllTokens should list multi-tokens") +} + +func TestMustGetFunctions(t *testing.T) { + std.TestSetRealm(std.NewCodeRealm("gno.land/r/matijamarjanovic/home")) + + token, _ := grc20.NewToken("Must Token", "MUST", 6) + RegisterToken(token.Getter(), "must_token") + + retrievedToken := MustGetToken("gno.land/r/matijamarjanovic/home.must_token") + uassert.Equal(t, "Must Token", retrievedToken.GetName(), "Token name should match") + + defer func() { + r := recover() + uassert.True(t, r != nil, "MustGetToken should panic for non-existent token") + uassert.True(t, strings.Contains(r.(string), "unknown token"), "Panic message should mention unknown token") + }() + MustGetToken("non_existent_token") + + nft := grc721.NewBasicNFT("Must NFT", "MNFT") + nft.Mint(std.CurrentRealm().Address(), grc721.TokenID("1")) + RegisterNFT(nft.Getter(), "must_nft", "1") + + retrievedNFT := MustGetNFT("gno.land/r/matijamarjanovic/home.must_nft.1") + metadata, ok := retrievedNFT.(grc721.IGRC721CollectionMetadata) + urequire.True(t, ok, "NFT should implement IGRC721CollectionMetadata") + uassert.Equal(t, "Must NFT", metadata.Name(), "NFT name should match") + + defer func() { + r := recover() + uassert.True(t, r != nil, "MustGetNFT should panic for non-existent NFT") + uassert.True(t, strings.Contains(r.(string), "unknown NFT"), "Panic message should mention unknown NFT") + }() + MustGetNFT("non_existent_nft") + + mt := grc1155.NewBasicGRC1155Token("must-uri") + RegisterMultiToken(mt.Getter(), "must_mt") + + retrievedMT := MustGetMultiToken("gno.land/r/matijamarjanovic/home.must_mt") + _, ok = retrievedMT.(grc1155.IGRC1155) + urequire.True(t, ok, "Retrieved multi-token should implement IGRC1155") + + defer func() { + r := recover() + uassert.True(t, r != nil, "MustGetMultiToken should panic for non-existent multi-token") + uassert.True(t, strings.Contains(r.(string), "unknown multi-token"), "Panic message should mention unknown multi-token") + }() + MustGetMultiToken("non_existent_mt") +} + +func TestGetAddressForUsername(t *testing.T) { + std.TestSetRealm(std.NewCodeRealm("gno.land/r/matijamarjanovic/home")) + + validAddr := "g1ej0qca5ptsw9kfr64ey8jvfy9eacga6mpj2z0y" + addr := getAddressForUsername(validAddr) + uassert.Equal(t, validAddr, addr.String(), "Should return same address for valid address input") + + invalidInput := "invalid_input" + addr = getAddressForUsername(invalidInput) + uassert.Equal(t, "", addr.String(), "Should return empty address for invalid input") +}