diff --git a/examples/gno.land/r/gnoland/coins/coins.gno b/examples/gno.land/r/gnoland/coins/coins.gno index d7cffd71dab..02eb568b47c 100644 --- a/examples/gno.land/r/gnoland/coins/coins.gno +++ b/examples/gno.land/r/gnoland/coins/coins.gno @@ -14,17 +14,23 @@ // interface and doesn't maintain any state of its own. This pattern allows for // simple, stateless information retrieval directly through the blockchain's // rendering capabilities. -// -// Example usage: -// -// /r/gnoland/coins:ugnot - shows the total supply of ugnot -// /r/gnoland/coins:ugnot/g1... - shows the ugnot balance of a specific address package coins import ( + "net/url" "std" + "strconv" + "strings" "gno.land/p/demo/mux" + "gno.land/p/demo/ufmt" + "gno.land/p/leon/coinsort" + "gno.land/p/leon/ctg" + "gno.land/p/moul/md" + "gno.land/p/moul/mdtable" + "gno.land/p/moul/realmpath" + + "gno.land/r/sys/users" ) var router *mux.Router @@ -32,26 +38,28 @@ var router *mux.Router func init() { router = mux.NewRouter() - // homepage router.HandleFunc("", func(res *mux.ResponseWriter, req *mux.Request) { res.Write(renderHomepage()) }) - // coin info - router.HandleFunc("{denom}", func(res *mux.ResponseWriter, req *mux.Request) { - // denom := req.GetVar("denom") + router.HandleFunc("balances/{address}", func(res *mux.ResponseWriter, req *mux.Request) { + res.Write(renderAllBalances(req.RawPath, req.GetVar("address"))) + }) + + router.HandleFunc("convert/{address}", func(res *mux.ResponseWriter, req *mux.Request) { + res.Write(renderConvertedAddress(req.GetVar("address"))) + }) + + // Coin info + router.HandleFunc("supply/{denom}", func(res *mux.ResponseWriter, req *mux.Request) { // banker := std.NewBanker(std.BankerTypeReadonly) // res.Write(renderAddressBalance(banker, denom, denom)) - res.Write("Total supply feature is coming soon. Please check back later!") + res.Write("The total supply feature is coming soon.") }) - // address balance - router.HandleFunc("{denom}/{address}", func(res *mux.ResponseWriter, req *mux.Request) { - denom := req.GetVar("denom") - addr := req.GetVar("address") - banker := std.NewBanker(std.BankerTypeReadonly) - res.Write(renderAddressBalance(banker, denom, addr)) - }) + router.NotFoundHandler = func(res *mux.ResponseWriter, req *mux.Request) { + res.Write("# 404\n\nThat page was not found. Would you like to [**go home**?](/r/gnoland/coins)") + } } func Render(path string) string { @@ -59,30 +67,165 @@ func Render(path string) string { } func renderHomepage() string { - return `# gno.land Coins Explorer + return strings.Replace(`# Gno.land Coins Explorer + +This is a simple, readonly realm that allows users to browse native coin balances. +Here are a few examples on how to use it: + +- ~/r/gnoland/coins:balances/
~ - show full list of coin balances of an address + - [Example](/r/gnoland/coins:balances/g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5) +- ~/r/gnoland/coins:balances/
?coin=ugnot~ - shows the balance of an address for a specific coin + - [Example](/r/gnoland/coins:balances/g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5?coin=ugnot) +- ~/r/gnoland/coins:convert/~ - convert Cosmos address to Gno address + - [Example](/r/gnoland/coins:convert/cosmos1jg8mtutu9khhfwc4nxmuhcpftf0pajdh6svrgs) +- ~/r/gnoland/coins:supply/~ - shows the total supply of denom + - Coming soon! +`, "~", "`", -1) +} + +func renderConvertedAddress(addr string) string { + out := "# Address converter\n\n" + + gnoAddress, err := ctg.ConvertCosmosToGno(addr) + if err != nil { + out += err.Error() + return out + } + + user, _ := users.ResolveAny(gnoAddress.String()) + name := "`" + gnoAddress.String() + "`" + if user != nil { + name = user.RenderLink("") + } + + out += ufmt.Sprintf("`%s` on Cosmos matches %s on gno.land.\n\n", addr, name) + out += "[View `ugnot` balance for this address](/r/gnoland/coins:balances/" + gnoAddress.String() + "?coin=ugnot)\n\n" + out += "[View full balance list for this address](/r/gnoland/coins:balances/" + gnoAddress.String() + ")" + return out +} + +func renderSingleCoinBalance(banker std.Banker, denom string, addr string) string { + out := "# Single coin balance\n\n" + if !std.Address(addr).IsValid() { + out += "Invalid address." + return out + } + + user, _ := users.ResolveAny(addr) + name := "`" + addr + "`" + if user != nil { + name = user.RenderLink("") + } + + out += ufmt.Sprintf("%s has `%d%s` at block #%d\n\n", + name, banker.GetCoins(std.Address(addr)).AmountOf(denom), denom, std.ChainHeight()) + + out += "[View full balance list for this address](/r/gnoland/coins:balances/" + addr + ")" + + return out +} + +func renderAllBalances(rawpath, input string) string { + out := "# Balances\n\n" -## Usage + if strings.HasPrefix(input, "cosmos") { + addr, err := ctg.ConvertCosmosToGno(input) + if err != nil { + out += "Tried converting a Cosmos address to a Gno address but failed. Please double-scheck your input." + return out + } + out += ufmt.Sprintf("> [!NOTE]\n> Automatically converted `%s` to its Gno equivalent.\n\n", input) + input = addr.String() + } else { + if !std.Address(input).IsValid() { + out += "Invalid address." + return out + } + } -- /r/gnoland/coins: - shows the total supply of denom (coming soon) -- /r/gnoland/coins:/
- shows the denom balance of a specific address + user, _ := users.ResolveAny(input) + name := "`" + input + "`" + if user != nil { + name = user.RenderLink("") + } -Examples: + banker := std.NewBanker(std.BankerTypeReadonly) + out += ufmt.Sprintf("This page shows full coin balances of %s at block #%d\n\n", + name, std.ChainHeight()) + + req := realmpath.Parse(rawpath) + + coin := req.Query.Get("coin") + if coin != "" { + return renderSingleCoinBalance(banker, coin, input) + } -- /r/gnoland/coins:ugnot - shows the total supply of ` + "`ugnot`" + ` (coming soon) -- /r/gnoland/coins:ugnot/g1... - shows the ` + "`ugnot`" + ` balance of a specific address + balances := banker.GetCoins(std.Address(input)) -` + // Determine sorting + if getSortField(req) == "balance" { + coinsort.SortByBalance(balances) + } + + // Create table + denomColumn := renderSortLink(req, "denom", "Denomination") + balanceColumn := renderSortLink(req, "balance", "Balance") + table := mdtable.Table{ + Headers: []string{denomColumn, balanceColumn}, + } + + if isSortReversed(req) { + for _, b := range balances { + table.Append([]string{b.Denom, strconv.Itoa(int(b.Amount))}) + } + } else { + for i := len(balances) - 1; i >= 0; i-- { + table.Append([]string{balances[i].Denom, strconv.Itoa(int(balances[i].Amount))}) + } + } + + out += table.String() + "\n\n" + return out +} + +// Helper functions for sorting and pagination +func getSortField(req *realmpath.Request) string { + field := req.Query.Get("sort") + switch field { + case "denom", "balance": // XXX: add Coins.SortBy{denom,bal} methods + return field + } + return "denom" +} + +func isSortReversed(req *realmpath.Request) bool { + return req.Query.Get("order") != "asc" } -func renderAddressBalance(banker std.Banker, denom string, addr string) string { - address := std.Address(addr) - coins := banker.GetCoins(address) +func renderSortLink(req *realmpath.Request, field, label string) string { + currentField := getSortField(req) + currentOrder := req.Query.Get("order") + + newOrder := "desc" + if field == currentField && currentOrder != "asc" { + newOrder = "asc" + } + + query := make(url.Values) + for k, vs := range req.Query { + query[k] = append([]string(nil), vs...) + } + + query.Set("sort", field) + query.Set("order", newOrder) - for _, coin := range coins { - if coin.Denom == denom { - return "Balance: " + coin.String() + if field == currentField { + if currentOrder == "asc" { + label += " ↑" + } else { + label += " ↓" } } - return "Balance: 0 " + denom + return md.Link(label, "?"+query.Encode()) } diff --git a/examples/gno.land/r/gnoland/coins/coins_test.gno b/examples/gno.land/r/gnoland/coins/coins_test.gno index f44115d41db..7bb83f18c2d 100644 --- a/examples/gno.land/r/gnoland/coins/coins_test.gno +++ b/examples/gno.land/r/gnoland/coins/coins_test.gno @@ -7,29 +7,35 @@ import ( "gno.land/p/demo/testutils" "gno.land/p/demo/ufmt" + "gno.land/p/leon/ctg" ) func TestBalanceChecker(t *testing.T) { - denom := "testtoken" + denom1 := "testtoken1" + denom2 := "testtoken2" addr1 := testutils.TestAddress("user1") addr2 := testutils.TestAddress("user2") coinsRealm := std.NewCodeRealm("gno.land/r/gnoland/coins") testing.SetRealm(coinsRealm) - testing.IssueCoins(addr1, std.Coins{{denom, 1000000}}) - testing.IssueCoins(addr2, std.Coins{{denom, 500000}}) + testing.IssueCoins(addr1, std.NewCoins(std.NewCoin(denom1, 1000000))) + testing.IssueCoins(addr2, std.NewCoins(std.NewCoin(denom1, 501))) + + testing.IssueCoins(addr2, std.NewCoins(std.NewCoin(denom2, 12345))) + + gnoAddr, _ := ctg.ConvertCosmosToGno("cosmos1s2v4tdskccx2p3yyvzem4mw5nn5fprwcku77hr") tests := []struct { name string path string - expected string + contains string wantPanic bool }{ { name: "homepage", path: "", - expected: "# gno.land Coins Explorer", + contains: "# Gno.land Coins Explorer", }, // TODO: not supported yet // { @@ -37,20 +43,37 @@ func TestBalanceChecker(t *testing.T) { // path: denom, // expected: "Balance: 1500000testtoken", // }, + + { + name: "addr1's coin balance", + path: ufmt.Sprintf("balances/%s?coin=%s", addr1.String(), denom1), + contains: ufmt.Sprintf("`%s` has `%d%s`", addr1.String(), 1000000, denom1), + }, + { + name: "addr2's full balances", + path: ufmt.Sprintf("balances/%s", addr2.String()), + contains: ufmt.Sprintf("This page shows full coin balances of `%s` at block", addr2.String()), + }, + { + name: "addr2's full balances", + path: ufmt.Sprintf("balances/%s", addr2.String()), + contains: `| testtoken1 | 501 | +| testtoken2 | 12345 |`, + }, { - name: "addr1's balance", - path: ufmt.Sprintf("%s/%s", denom, addr1.String()), - expected: "Balance: 1000000testtoken", + name: "addr2's coin balance", + path: ufmt.Sprintf("balances/%s?coin=%s", addr2.String(), denom1), + contains: ufmt.Sprintf("`%s` has `%d%s`", addr2.String(), 501, denom1), }, { - name: "addr2's balance", - path: ufmt.Sprintf("%s/%s", denom, addr2.String()), - expected: "Balance: 500000testtoken", + name: "cosmos addr conversion", + path: "convert/cosmos1s2v4tdskccx2p3yyvzem4mw5nn5fprwcku77hr", + contains: ufmt.Sprintf("`cosmos1s2v4tdskccx2p3yyvzem4mw5nn5fprwcku77hr` on Cosmos matches `%s`", gnoAddr), }, { name: "invalid path", - path: ufmt.Sprintf("%s/invalid/extra", denom), - expected: "404", + path: "invalid", + contains: "404", wantPanic: false, }, } @@ -67,8 +90,8 @@ func TestBalanceChecker(t *testing.T) { result := Render(tt.path) if !tt.wantPanic { - if !strings.Contains(result, tt.expected) { - t.Errorf("expected %s to contain %s", result, tt.expected) + if !strings.Contains(result, tt.contains) { + t.Errorf("expected %s to contain %s", result, tt.contains) } } })