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}