Search Apps Documentation Source Content File Folder Download Copy Actions Download 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}