Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

gsdash.gno

7.57 Kb · 231 lines
  1// Package gsdash renders one address's whole GnoSwap footprint on chain, with
  2// no indexer and no off-chain query: balances, liquidity positions, what each
  3// one is worth right now, and what it has earned but not collected.
  4//
  5// It reads GnoSwap through the unversioned proxy paths (r/gnoswap/position and
  6// friends) rather than their /vN implementations, so a GnoSwap implementation
  7// upgrade does not reach this realm.
  8package gsdash
  9
 10import (
 11	"chain"
 12	"strconv"
 13	"strings"
 14
 15	"gno.land/p/moul/kit/ui/v0"
 16	"gno.land/p/nt/ufmt/v0"
 17
 18	"gno.land/r/gnoswap/gnft"
 19	"gno.land/r/gnoswap/gns"
 20	"gno.land/r/gnoswap/gov/xgns"
 21	"gno.land/r/gnoswap/position"
 22	"gno.land/r/gnoswap/staker"
 23)
 24
 25// scanWindow is how many stored position records one page walks.
 26//
 27// There is no address-to-positions index anywhere in GnoSwap: gnft is a plain
 28// grc721 with no enumerable extension, so the only way to find an address's
 29// positions is to walk the records and ask each one who owns it. GnoSwap's own
 30// position realm renders a keyed lookup and says out loud that it "does not
 31// enumerate positions", which is the same ceiling talking. Queries get
 32// 3,000,000,000 gas (maxGasQuery, gno.land/pkg/sdk/vm/keeper.go), so the walk
 33// is paged rather than unbounded, and the page footer links the next one.
 34const scanWindow = 250
 35
 36// stakerPath is the realm that custodies a staked position's NFT. Staking
 37// transfers the token to it, so for anything staked the grc721 owner is this
 38// address and not a person: see resolveOwner.
 39const stakerPath = "gno.land/r/gnoswap/staker"
 40
 41// Render serves three routes: the index, an address page, and the next page of
 42// an address whose records did not fit in one scan window.
 43//
 44//	""                  the index
 45//	"g1..."             that address, scanning from record 0
 46//	"g1.../from/500"    the same, scanning from record 500
 47func Render(path string) string {
 48	if path == "" {
 49		return renderIndex()
 50	}
 51
 52	addrPart, offset, ok := parsePath(path)
 53	if !ok {
 54		return ui.Empty("Not a route. Try `" + shortSelf + ":g1...`.")
 55	}
 56	addr := address(addrPart)
 57	if !addr.IsValid() {
 58		// Escaped rather than echoed: the path is whatever the reader typed.
 59		return ui.Empty("Not a valid address: " + ui.Inline(addrPart))
 60	}
 61	return renderAddress(addr, offset)
 62}
 63
 64// shortSelf is this realm's own render prefix, used in the links it prints.
 65const shortSelf = "/r/moul/x/gsdash/v0"
 66
 67func renderIndex() string {
 68	s := "# GnoSwap dashboard\n\n"
 69	s += "Everything one address has on GnoSwap, read live from the chain: GNS and xGNS " +
 70		"balances, every liquidity position with what it holds right now, the fees it has " +
 71		"accrued, and the emission rewards waiting to be collected.\n\n"
 72	s += ufmt.Sprintf("Open an address: `%s:g1...`\n\n", shortSelf)
 73
 74	t := ui.NewTable("What", "Now")
 75	t.Row("Stored position records", ufmt.Sprintf("%d", position.GetPositions().Size()))
 76	t.Row("Position NFTs in circulation", ufmt.Sprintf("%d", gnft.TotalSupply()))
 77	t.Row("Records walked per page", ufmt.Sprintf("%d", scanWindow))
 78	s += t.String()
 79
 80	s += "\nRecords include burned positions, so the count is not a count of live liquidity.\n"
 81	return s
 82}
 83
 84func renderAddress(addr address, offset int) string {
 85	s := ufmt.Sprintf("# %s\n\n", ui.AddrFull(addr))
 86
 87	nfts, err := gnft.BalanceOf(addr)
 88	if err != nil {
 89		nfts = 0
 90	}
 91	t := ui.NewTable("GNS", "xGNS", "Position NFTs held")
 92	t.Row(
 93		ufmt.Sprintf("%d", gns.BalanceOf(addr)),
 94		ufmt.Sprintf("%d", xgns.BalanceOf(addr)),
 95		ufmt.Sprintf("%d", nfts),
 96	)
 97	s += t.String() + "\n"
 98
 99	s += ufmt.Sprintf("## Positions\n\nRecords %d to %d of %d.\n\n",
100		offset, offset+scanWindow, position.GetPositions().Size())
101	s += renderPositions(addr, offset)
102
103	if next := offset + scanWindow; next < position.GetPositions().Size() {
104		s += ufmt.Sprintf("\n[Next %d records](%s:%s/from/%d)\n", scanWindow, shortSelf, addr.String(), next)
105	}
106	return s
107}
108
109// renderPositions walks one window of the position records and prints the rows
110// belonging to addr.
111//
112// The walk is deliberately cheap per record and expensive per hit: identifying
113// the owner is one or two tree lookups, while the price math behind IsInRange,
114// the token balances and the unclaimed fees costs far more. Only a row that is
115// actually printed pays for it.
116func renderPositions(addr address, offset int) string {
117	t := ui.NewTable("ID", "Pool", "Ticks", "Liquidity", "In range", "Holds", "Uncollected fees", "Staked", "Reward")
118
119	position.GetPositions().IterateByOffset(offset, scanWindow, func(key string, _ any) bool {
120		id, err := strconv.ParseUint(key, 10, 64)
121		if err != nil {
122			return false
123		}
124		owner, staked, ok := resolveOwner(id)
125		if !ok || owner != addr {
126			return false
127		}
128		t.Row(row(id, staked)...)
129		return false
130	})
131
132	return t.OrEmpty("No positions in this window.")
133}
134
135// row is the expensive half: everything here reads pool price state or recomputes
136// a fee, so it runs for a matched position and never for a scanned one.
137func row(id uint64, staked bool) []string {
138	poolKey, _ := position.GetPositionPoolKey(id)
139	lower, upper, _ := position.GetPositionTicks(id)
140	liquidity, _ := position.GetPositionLiquidity(id)
141	inRange, _ := position.IsInRange(id)
142	amount0, amount1, _ := position.GetPositionTokenBalances(id)
143	fee0, fee1, _ := position.GetUnclaimedFee(id)
144
145	reward := "-"
146	if staked {
147		if r, err := staker.CollectableEmissionReward(id); err == nil {
148			reward = ufmt.Sprintf("%d", r)
149		}
150	}
151
152	return []string{
153		ufmt.Sprintf("%d", id),
154		ui.Cell(shortPool(poolKey)),
155		ufmt.Sprintf("%d to %d", lower, upper),
156		ui.Cell(liquidity),
157		yesNo(inRange),
158		ufmt.Sprintf("%d / %d", amount0, amount1),
159		ui.Cell(fee0) + " / " + ui.Cell(fee1),
160		yesNo(staked),
161		reward,
162	}
163}
164
165// resolveOwner answers who a position really belongs to, and whether it is staked.
166//
167// Staking transfers the position NFT to the staker realm, so for every staked
168// position the grc721 owner is that realm's address and not the person's. A
169// dashboard that trusts the NFT owner alone shows an empty page to everyone who
170// staked, which is most of them. The deposit record is what still names the
171// person.
172func resolveOwner(id uint64) (owner address, staked bool, ok bool) {
173	held, err := position.GetPositionOwner(id)
174	if err != nil {
175		return "", false, false
176	}
177	if held != chain.PackageAddress(stakerPath) {
178		return held, false, true
179	}
180	deposited, err := staker.GetDepositOwner(id)
181	if err != nil {
182		// Custodied by the staker with no deposit behind it: in flight, or a
183		// record this realm does not understand. Better to say nothing than to
184		// attribute it to the staker realm as if it were a user.
185		return "", true, false
186	}
187	return deposited, true, true
188}
189
190// parsePath splits an address route from its optional scan offset.
191func parsePath(path string) (addr string, offset int, ok bool) {
192	addr, rest, found := strings.Cut(path, "/")
193	if !found {
194		return addr, 0, true
195	}
196	after, found := strings.CutPrefix(rest, "from/")
197	if !found {
198		return "", 0, false
199	}
200	n, err := strconv.Atoi(after)
201	if err != nil || n < 0 {
202		return "", 0, false
203	}
204	return addr, n, true
205}
206
207// shortPool turns a pool key ("<token0 path>.<symbol>:<token1 path>.<symbol>:<fee>")
208// into something that fits a table cell.
209func shortPool(poolKey string) string {
210	parts := strings.Split(poolKey, ":")
211	if len(parts) != 3 {
212		return poolKey
213	}
214	return symbolOf(parts[0]) + "/" + symbolOf(parts[1]) + " " + parts[2]
215}
216
217// symbolOf takes the symbol off a GnoSwap token key, which is the grc20 registry
218// form "<pkgpath>.<Symbol>".
219func symbolOf(tokenKey string) string {
220	if i := strings.LastIndex(tokenKey, "."); i >= 0 {
221		return tokenKey[i+1:]
222	}
223	return tokenKey
224}
225
226func yesNo(b bool) string {
227	if b {
228		return "yes"
229	}
230	return "no"
231}