Transaction

3AFCF13BC9D581…DD3B740F4E52

Block 408,995 · index 0 · indexed

Summary

Hash
3AFCF13BC9D5812440503F4FA22A2DA9840EB0ED08D8BB6ABFD3DD3B740F4E52
Block
408,995
Size
16052 bytes
Gas used
21,695,029 / 52,859,000
Fee
158577ugnot
Status
success

Messages

#1AddPackagegno.land/r/moul/x/gsdash/v09 arguments
Attached funds
7000000ugnot

Arguments · 9

  1. #1gsdash
  2. #2README.md
  3. #3# `gno.land/r/moul/x/gsdash/v0` **One address's whole GnoSwap footprint, read on chain.** GNS and xGNS balances, every liquidity position with what it holds at the current price, the swap fees it has accrued, and the emission rewards waiting to be collected. No indexer, no API, no off-chain query: the page is a realm reading other realms. ``` /r/moul/x/gsdash/v0 the index /r/moul/x/gsdash/v0:g1manfred... that address /r/moul/x/gsdash/v0:g1manfred.../from/250 the next window of records ``` ## What it reads, and why through those paths GnoSwap splits every module in two: an unversioned proxy ([`r/gnoswap/position`](https://gno.land/r/gnoswap/position)) and the implementation behind it (`r/gnoswap/position/v1`), swapped at runtime by a version manager. This realm names only the proxies, so a GnoSwap implementation upgrade does not reach it. | Section | Read from | |---|---| | GNS, xGNS, position NFTs held | `r/gnoswap/gns`, `r/gnoswap/gov/xgns`, `r/gnoswap/gnft` | | position records, ticks, liquidity, balances, unclaimed fees | `r/gnoswap/position` | | who really owns a staked position, and its pending emission | `r/gnoswap/staker` | ## Two things that are not obvious **A staked position's NFT owner is not its owner.** Staking transfers the position NFT to the staker realm, so `position.GetPositionOwner` answers with that realm's address for everything staked. The person is named by the deposit record instead, `staker.GetDepositOwner`. A dashboard that trusts the NFT owner alone shows an empty page to everyone who staked, which is most of them. **There is no address-to-positions index in GnoSwap.** `gnft` is a plain GRC721 with no enumerable extension, so finding an address's positions means walking the stored records and asking each one who owns it. Queries run under a fixed gas ceiling, so the walk is paged: one page covers 250 records, and the footer links the next. GnoSwap's own position page takes the same view from the other side, rendering a keyed lookup and saying it "does not enumerate positions". The walk is cheap per record and expensive per hit on purpose. Identifying an owner is one tree lookup, or two for a staked position; the price math behind the in-range flag, the token balances and the unclaimed fees costs far more, and runs only for a row that is actually printed. Record keys are decimal strings, so a window is a slice of the tree in lexicographic order and not a range of IDs. It covers every record either way, which is what the scan needs; do not read an offset as an ID. ## Building it GnoSwap lives on gnoland-1 and in no monorepo `examples/`, so its packages are vendored here from the chain: ``` go -C tools tool gnocontracts vendor -from-chain https://rpc.gno.land ``` <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:** ![gno.land/r/moul/x/gsdash/v0 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/r/moul/x/gsdash/v0/deps.png) > 🧪 **Highly experimental — potentially vibe-coded.** Not audited; may break, change, or be removed at any time. Do not use with anything of value. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
  4. #4gnomod.toml
  5. #5module = "gno.land/r/moul/x/gsdash/v0" gno = "0.9" private = true
  6. #6gsdash.gno
  7. #7// Package gsdash renders one address's whole GnoSwap footprint on chain, with // no indexer and no off-chain query: balances, liquidity positions, what each // one is worth right now, and what it has earned but not collected. // // It reads GnoSwap through the unversioned proxy paths (r/gnoswap/position and // friends) rather than their /vN implementations, so a GnoSwap implementation // upgrade does not reach this realm. package gsdash import ( "chain" "strconv" "strings" "gno.land/p/moul/kit/ui/v0" "gno.land/p/nt/ufmt/v0" "gno.land/r/gnoswap/gnft" "gno.land/r/gnoswap/gns" "gno.land/r/gnoswap/gov/xgns" "gno.land/r/gnoswap/position" "gno.land/r/gnoswap/staker" ) // scanWindow is how many stored position records one page walks. // // There is no address-to-positions index anywhere in GnoSwap: gnft is a plain // grc721 with no enumerable extension, so the only way to find an address's // positions is to walk the records and ask each one who owns it. GnoSwap's own // position realm renders a keyed lookup and says out loud that it "does not // enumerate positions", which is the same ceiling talking. Queries get // 3,000,000,000 gas (maxGasQuery, gno.land/pkg/sdk/vm/keeper.go), so the walk // is paged rather than unbounded, and the page footer links the next one. const scanWindow = 250 // stakerPath is the realm that custodies a staked position's NFT. Staking // transfers the token to it, so for anything staked the grc721 owner is this // address and not a person: see resolveOwner. const stakerPath = "gno.land/r/gnoswap/staker" // Render serves three routes: the index, an address page, and the next page of // an address whose records did not fit in one scan window. // // "" the index // "g1..." that address, scanning from record 0 // "g1.../from/500" the same, scanning from record 500 func Render(path string) string { if path == "" { return renderIndex() } addrPart, offset, ok := parsePath(path) if !ok { return ui.Empty("Not a route. Try `" + shortSelf + ":g1...`.") } addr := address(addrPart) if !addr.IsValid() { // Escaped rather than echoed: the path is whatever the reader typed. return ui.Empty("Not a valid address: " + ui.Inline(addrPart)) } return renderAddress(addr, offset) } // shortSelf is this realm's own render prefix, used in the links it prints. const shortSelf = "/r/moul/x/gsdash/v0" func renderIndex() string { s := "# GnoSwap dashboard\n\n" s += "Everything one address has on GnoSwap, read live from the chain: GNS and xGNS " + "balances, every liquidity position with what it holds right now, the fees it has " + "accrued, and the emission rewards waiting to be collected.\n\n" s += ufmt.Sprintf("Open an address: `%s:g1...`\n\n", shortSelf) t := ui.NewTable("What", "Now") t.Row("Stored position records", ufmt.Sprintf("%d", position.GetPositions().Size())) t.Row("Position NFTs in circulation", ufmt.Sprintf("%d", gnft.TotalSupply())) t.Row("Records walked per page", ufmt.Sprintf("%d", scanWindow)) s += t.String() s += "\nRecords include burned positions, so the count is not a count of live liquidity.\n" return s } func renderAddress(addr address, offset int) string { s := ufmt.Sprintf("# %s\n\n", ui.AddrFull(addr)) nfts, err := gnft.BalanceOf(addr) if err != nil { nfts = 0 } t := ui.NewTable("GNS", "xGNS", "Position NFTs held") t.Row( ufmt.Sprintf("%d", gns.BalanceOf(addr)), ufmt.Sprintf("%d", xgns.BalanceOf(addr)), ufmt.Sprintf("%d", nfts), ) s += t.String() + "\n" s += ufmt.Sprintf("## Positions\n\nRecords %d to %d of %d.\n\n", offset, offset+scanWindow, position.GetPositions().Size()) s += renderPositions(addr, offset) if next := offset + scanWindow; next < position.GetPositions().Size() { s += ufmt.Sprintf("\n[Next %d records](%s:%s/from/%d)\n", scanWindow, shortSelf, addr.String(), next) } return s } // renderPositions walks one window of the position records and prints the rows // belonging to addr. // // The walk is deliberately cheap per record and expensive per hit: identifying // the owner is one or two tree lookups, while the price math behind IsInRange, // the token balances and the unclaimed fees costs far more. Only a row that is // actually printed pays for it. func renderPositions(addr address, offset int) string { t := ui.NewTable("ID", "Pool", "Ticks", "Liquidity", "In range", "Holds", "Uncollected fees", "Staked", "Reward") position.GetPositions().IterateByOffset(offset, scanWindow, func(key string, _ any) bool { id, err := strconv.ParseUint(key, 10, 64) if err != nil { return false } owner, staked, ok := resolveOwner(id) if !ok || owner != addr { return false } t.Row(row(id, staked)...) return false }) return t.OrEmpty("No positions in this window.") } // row is the expensive half: everything here reads pool price state or recomputes // a fee, so it runs for a matched position and never for a scanned one. func row(id uint64, staked bool) []string { poolKey, _ := position.GetPositionPoolKey(id) lower, upper, _ := position.GetPositionTicks(id) liquidity, _ := position.GetPositionLiquidity(id) inRange, _ := position.IsInRange(id) amount0, amount1, _ := position.GetPositionTokenBalances(id) fee0, fee1, _ := position.GetUnclaimedFee(id) reward := "-" if staked { if r, err := staker.CollectableEmissionReward(id); err == nil { reward = ufmt.Sprintf("%d", r) } } return []string{ ufmt.Sprintf("%d", id), ui.Cell(shortPool(poolKey)), ufmt.Sprintf("%d to %d", lower, upper), ui.Cell(liquidity), yesNo(inRange), ufmt.Sprintf("%d / %d", amount0, amount1), ui.Cell(fee0) + " / " + ui.Cell(fee1), yesNo(staked), reward, } } // resolveOwner answers who a position really belongs to, and whether it is staked. // // Staking transfers the position NFT to the staker realm, so for every staked // position the grc721 owner is that realm's address and not the person's. A // dashboard that trusts the NFT owner alone shows an empty page to everyone who // staked, which is most of them. The deposit record is what still names the // person. func resolveOwner(id uint64) (owner address, staked bool, ok bool) { held, err := position.GetPositionOwner(id) if err != nil { return "", false, false } if held != chain.PackageAddress(stakerPath) { return held, false, true } deposited, err := staker.GetDepositOwner(id) if err != nil { // Custodied by the staker with no deposit behind it: in flight, or a // record this realm does not understand. Better to say nothing than to // attribute it to the staker realm as if it were a user. return "", true, false } return deposited, true, true } // parsePath splits an address route from its optional scan offset. func parsePath(path string) (addr string, offset int, ok bool) { addr, rest, found := strings.Cut(path, "/") if !found { return addr, 0, true } after, found := strings.CutPrefix(rest, "from/") if !found { return "", 0, false } n, err := strconv.Atoi(after) if err != nil || n < 0 { return "", 0, false } return addr, n, true } // shortPool turns a pool key ("<token0 path>.<symbol>:<token1 path>.<symbol>:<fee>") // into something that fits a table cell. func shortPool(poolKey string) string { parts := strings.Split(poolKey, ":") if len(parts) != 3 { return poolKey } return symbolOf(parts[0]) + "/" + symbolOf(parts[1]) + " " + parts[2] } // symbolOf takes the symbol off a GnoSwap token key, which is the grc20 registry // form "<pkgpath>.<Symbol>". func symbolOf(tokenKey string) string { if i := strings.LastIndex(tokenKey, "."); i >= 0 { return tokenKey[i+1:] } return tokenKey } func yesNo(b bool) string { if b { return "yes" } return "no" }
  8. #8gsdash_test.gno
  9. #9package gsdash import ( "strings" "testing" "gno.land/p/nt/uassert/v0" // GnoSwap's unversioned paths are proxies: the facade holds no logic and // panics with "implementation is not initialized" until some version has // registered itself. On chain that happened long ago; in a test VM nothing // has, so these blank imports run each implementation's init, which calls // RegisterInitializer, and the version manager activates the first one it // sees. Importing them here rather than from the realm keeps them out of // what gets deployed: the realm only ever names the proxy. _ "gno.land/r/gnoswap/pool/v1" _ "gno.land/r/gnoswap/position/v1" _ "gno.land/r/gnoswap/staker/v1" ) // someone is a well-formed address with nothing on GnoSwap, which is what every // address looks like in a fresh test VM. const someone = "g1manfred47kzduec920z88wfr64ylksmdcedlf5" func TestRenderIndexReadsTheProxies(t *testing.T) { out := Render("") uassert.True(t, len(out) > 0, "index renders") uassert.True(t, strings.Contains(out, "# GnoSwap dashboard"), "index has its heading") // Reaching this line at all is the point: Size() and TotalSupply() are // cross-realm reads through two different GnoSwap proxies, so an index that // renders proves both resolved to a live implementation. uassert.True(t, strings.Contains(out, "Stored position records"), "index read the position proxy") uassert.True(t, strings.Contains(out, "Position NFTs in circulation"), "index read the gnft realm") } func TestRenderAddressWithNothing(t *testing.T) { out := Render(someone) uassert.True(t, strings.Contains(out, someone), "the page names the address in full") uassert.True(t, strings.Contains(out, "No positions in this window."), "an empty window says so") } func TestRenderRejectsAMalformedAddress(t *testing.T) { out := Render("not-an-address") uassert.True(t, strings.Contains(out, "Not a valid address"), "a bad address is refused") uassert.False(t, strings.Contains(out, "## Positions"), "and no scan is run for it") } func TestRenderEscapesWhatTheReaderTyped(t *testing.T) { // The path is attacker-controlled: whatever is after the colon in the URL. // It must come back inert, never as a live link. out := Render("[click](https://evil.example)") uassert.False(t, strings.Contains(out, "](https://evil.example)"), "no live link survives") } func TestParsePath(t *testing.T) { tests := []struct { name string path string wantAddr string wantOffset int wantOK bool }{ {"bare address", someone, someone, 0, true}, {"with offset", someone + "/from/250", someone, 250, true}, {"zero offset", someone + "/from/0", someone, 0, true}, {"unknown segment", someone + "/nope", "", 0, false}, {"offset not a number", someone + "/from/abc", "", 0, false}, {"negative offset", someone + "/from/-1", "", 0, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { addr, offset, ok := parsePath(tt.path) uassert.Equal(t, tt.wantOK, ok) if !tt.wantOK { return } uassert.Equal(t, tt.wantAddr, addr) uassert.Equal(t, tt.wantOffset, offset) }) } } func TestShortPool(t *testing.T) { tests := []struct { name string key string want string }{ { "a real pool key", "gno.land/r/gnoland/wugnot.wugnot:gno.land/r/gnoswap/gns.GNS:3000", "wugnot/GNS 3000", }, {"not a pool key", "whatever", "whatever"}, {"no symbol on a side", "a:b:100", "a/b 100"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { uassert.Equal(t, tt.want, shortPool(tt.key)) }) } } // ExampleRender pins the index as a fresh chain renders it: no positions, no // NFTs, and the two proxy reads resolving to zero rather than panicking. func ExampleRender() { println(Render("")) // Output: // # GnoSwap dashboard // // Everything one address has on GnoSwap, read live from the chain: GNS and xGNS balances, every liquidity position with what it holds right now, the fees it has accrued, and the emission rewards waiting to be collected. // // Open an address: `/r/moul/x/gsdash/v0:g1...` // // | What | Now | // | --- | --- | // | Stored position records | 0 | // | Position NFTs in circulation | 0 | // | Records walked per page | 250 | // // Records include burned positions, so the count is not a count of live liquidity. }

Result log

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

← Back to block 408,995