render.gno
13.12 Kb · 375 lines
1package transfer
2
3import (
4 "chain"
5 "strconv"
6 "strings"
7 "time"
8
9 "gno.land/p/aib/ibc/lightclient"
10 "gno.land/p/aib/jsonpage"
11 "gno.land/p/moul/txlink"
12 "gno.land/p/nt/bptree/v0"
13 "gno.land/p/nt/mux/v0"
14 "gno.land/p/nt/ufmt/v0"
15 "gno.land/p/onbloc/json"
16 "gno.land/r/aib/ibc/core"
17)
18
19// transferRealmPath is the package path of this realm; used to compute
20// per-voucher GRC20 registry keys. Hardcoded because we cannot read it from
21// runtime.CurrentRealm() in v2 without the unsafe package, and Render needs
22// it from a non-crossing render handler.
23const transferRealmPath = "gno.land/r/aib/ibc/apps/transfer"
24
25// Render path router. Escrow is tracked per (client, denom); the render
26// endpoints aggregate across all clients for display.
27func Render(path string) string {
28 router := mux.NewRouter()
29 router.HandleFunc("", renderHome)
30 router.HandleFunc("denoms", renderDenoms)
31 router.HandleFunc("denoms/ibc/{hash}", renderDenom)
32 router.HandleFunc("total_escrow/{denom}", renderTotalEscrowForDenom)
33 router.HandleFunc("vouchers", renderVouchers)
34 router.HandleFunc("voucher/ibc/{hash}", renderVoucher)
35 router.HandleFunc("voucher/ibc/{hash}/balance/{addr}", renderVoucherBalance)
36 return router.Render(path)
37}
38
39func renderHome(w *mux.ResponseWriter, r *mux.Request) {
40 var out strings.Builder
41 out.WriteString("# IBC transfer\n\n")
42 out.WriteString("ICS-20 style transfer state and voucher token queries.\n\n")
43 out.WriteString(renderTransferLinks())
44 out.WriteString(ufmt.Sprintf("## Vouchers (%d)\n\n", denoms.Size()))
45 if denoms.Size() > 0 {
46 out.WriteString("| Denom | Base | Path | Supply | GRC20 | Actions |\n")
47 out.WriteString("|-------|------|------|--------|-------|---------|\n")
48 for _, d := range denomsByClientOrder() {
49 ibcDenom := d.IBCDenom()
50 supply := ""
51 grc20link := ""
52 if inst := getVoucher(ibcDenom); inst != nil {
53 supply = ufmt.Sprintf("[%d](/r/aib/ibc/apps/transfer:voucher/%s)", inst.token.TotalSupply(), ibcDenom)
54 key := grc20regKey(ibcDenom)
55 // Shorten the hash suffix: "gno.land/r/.../transfer.CAEF9C..." → keep prefix + first 4 … last 4 of hash
56 dotIdx := strings.LastIndex(key, ".")
57 shortKey := key
58 if dotIdx != -1 {
59 hash := key[dotIdx+1:]
60 if len(hash) > 10 {
61 shortKey = key[:dotIdx+1] + hash[:4] + "…" + hash[len(hash)-4:]
62 }
63 }
64 const grc20regPath = "gno.land/r/demo/defi/grc20reg"
65 grc20link = ufmt.Sprintf("[%s](%s:%s)", shortKey, stripDomain(grc20regPath), key)
66 }
67 // Actions: IBC "send back" (via the voucher's source client) plus
68 // local GRC20 send/approve. All pre-fill the voucher denom; the user
69 // only fills the counterparty and amount (and timeout for the IBC
70 // send) in their wallet.
71 sourceClient := d.Trace[0].ClientId
72 actions := ufmt.Sprintf(
73 "[send back](%s) via `%s` — [send](%s) — [approve](%s)",
74 newTransferLink(sourceClient, "", ibcDenom).URL(),
75 sourceClient,
76 newVoucherSendLink(ibcDenom).URL(),
77 newVoucherApproveLink(ibcDenom).URL(),
78 )
79 out.WriteString(ufmt.Sprintf(
80 "| [`%s`](/r/aib/ibc/apps/transfer:denoms/%s) | %s | %s | %s | %s | %s |\n",
81 shortDenom(ibcDenom), ibcDenom, d.Base, d.Path(), supply, grc20link, actions,
82 ))
83 }
84 out.WriteString("\n")
85 } else {
86 out.WriteString("No vouchers yet.\n\n")
87 }
88 escrowsPerDenom := totalEscrowsPerDenom()
89 out.WriteString(ufmt.Sprintf("## Escrow (%d)\n\n", len(escrowsPerDenom)))
90 if len(escrowsPerDenom) > 0 {
91 out.WriteString("| Denom | Amount |\n")
92 out.WriteString("|-------|--------|\n")
93 // Iterate in the stable order totalEscrow exposes (by client), emitting
94 // each denom the first time it is seen, so the table matches the
95 // underlying storage order rather than map iteration order.
96 seen := map[string]bool{}
97 totalEscrow.IterateByOffset(0, totalEscrow.Size(), func(_ string, v any) bool {
98 t := v.(*bptree.BPTree)
99 t.IterateByOffset(0, t.Size(), func(denom string, _ any) bool {
100 if seen[denom] {
101 return false
102 }
103 seen[denom] = true
104 out.WriteString(ufmt.Sprintf(
105 "| [`%s`](/r/aib/ibc/apps/transfer:total_escrow/%s) | %d |\n",
106 denom, denom, escrowsPerDenom[denom],
107 ))
108 return false
109 })
110 return false
111 })
112 out.WriteString("\n")
113 } else {
114 out.WriteString("No escrow yet.\n\n")
115 }
116 out.WriteString("## JSON endpoints\n\n")
117 out.WriteString("- [`denoms`](/r/aib/ibc/apps/transfer:denoms): list known IBC denoms (`?page`, `?limit`)\n")
118 out.WriteString("- `denoms/ibc/{hash}`: get metadata for an IBC denom\n")
119 out.WriteString("- `total_escrow/{denom}`: get total escrow tracked for a base denom\n")
120 out.WriteString("- [`vouchers`](/r/aib/ibc/apps/transfer:vouchers): list voucher tokens (`?page`, `?limit`)\n")
121 out.WriteString("- `voucher/ibc/{hash}`: get voucher token metadata\n")
122 out.WriteString("- `voucher/ibc/{hash}/balance/{addr}`: get a voucher balance for an address\n\n")
123 w.Write(out.String())
124}
125
126func renderDenoms(w *mux.ResponseWriter, r *mux.Request) {
127 renderNode(w, jsonpage.Render(denoms, r, nil))
128}
129
130func renderDenom(w *mux.ResponseWriter, r *mux.Request) {
131 denom := "ibc/" + r.GetVar("hash")
132 d := denoms.Get(denom)
133 if d == nil {
134 renderNode(w, nodeError(ufmt.Sprintf("denom %s not found", denom)))
135 return
136 }
137 renderNode(w, d.(Denom).RenderJSON())
138}
139
140func renderTotalEscrowForDenom(w *mux.ResponseWriter, r *mux.Request) {
141 denom := r.GetVar("denom")
142 // List the per-client amounts so the client/escrow binding is visible, not
143 // just the aggregate. The total is kept at the top level for quick checks.
144 clients := []*json.Node{}
145 var total int64
146 totalEscrow.IterateByOffset(0, totalEscrow.Size(), func(clientID string, v any) bool {
147 t := v.(*bptree.BPTree)
148 if x := t.Get(denom); x != nil {
149 amt := x.(chain.Coin).Amount
150 total += amt
151 clients = append(clients, json.ObjectNode("", map[string]*json.Node{
152 "client": json.StringNode("", clientID),
153 "amount": json.NumberNode("", float64(amt)),
154 }))
155 }
156 return false
157 })
158 renderNode(w, json.ObjectNode("", map[string]*json.Node{
159 "denom": json.StringNode("", denom),
160 "amount": json.NumberNode("", float64(total)),
161 "clients": json.ArrayNode("", clients),
162 }))
163}
164
165// totalEscrowsPerDenom returns one entry per denom that has any escrow
166// across all clients, with the summed amount.
167func totalEscrowsPerDenom() map[string]int64 {
168 out := map[string]int64{}
169 totalEscrow.IterateByOffset(0, totalEscrow.Size(), func(_ string, v any) bool {
170 t := v.(*bptree.BPTree)
171 t.IterateByOffset(0, t.Size(), func(denom string, cv any) bool {
172 out[denom] += cv.(chain.Coin).Amount
173 return false
174 })
175 return false
176 })
177 return out
178}
179
180func renderVouchers(w *mux.ResponseWriter, r *mux.Request) {
181 renderNode(w, jsonpage.Render(voucherTokens, r, func(key string, v any) *json.Node {
182 inst := v.(*voucher)
183 return json.ObjectNode("", map[string]*json.Node{
184 "denom": json.StringNode("", key),
185 "grc20reg_key": json.StringNode("", grc20regKey(key)),
186 "name": json.StringNode("", inst.token.GetName()),
187 "symbol": json.StringNode("", inst.token.GetSymbol()),
188 "decimals": json.NumberNode("", float64(inst.token.GetDecimals())),
189 "total_supply": json.NumberNode("", float64(inst.token.TotalSupply())),
190 })
191 }))
192}
193
194func renderVoucher(w *mux.ResponseWriter, r *mux.Request) {
195 ibcDenom := "ibc/" + r.GetVar("hash")
196 inst := getVoucher(ibcDenom)
197 if inst == nil {
198 renderNode(w, nodeError("voucher token %s not found", ibcDenom))
199 return
200 }
201 renderNode(w, json.ObjectNode("", map[string]*json.Node{
202 "denom": json.StringNode("", ibcDenom),
203 "grc20reg_key": json.StringNode("", grc20regKey(ibcDenom)),
204 "name": json.StringNode("", inst.token.GetName()),
205 "symbol": json.StringNode("", inst.token.GetSymbol()),
206 "decimals": json.NumberNode("", float64(inst.token.GetDecimals())),
207 "total_supply": json.NumberNode("", float64(inst.token.TotalSupply())),
208 }))
209}
210
211func renderVoucherBalance(w *mux.ResponseWriter, r *mux.Request) {
212 ibcDenom := "ibc/" + r.GetVar("hash")
213 addr := r.GetVar("addr")
214 inst := getVoucher(ibcDenom)
215 if inst == nil {
216 renderNode(w, nodeError("voucher token %s not found", ibcDenom))
217 return
218 }
219 balance := inst.token.BalanceOf(address(addr))
220 renderNode(w, json.ObjectNode("", map[string]*json.Node{
221 "denom": json.StringNode("", ibcDenom),
222 "address": json.StringNode("", addr),
223 "balance": json.NumberNode("", float64(balance)),
224 }))
225}
226
227// denomsByClientOrder returns the stored voucher denoms ordered to match the
228// "Pick the client" listing (core.ClientIDs() order): each voucher is grouped
229// under its source client (Trace[0].ClientId), following the same client order
230// as the client list, so the vouchers table lines up with it. Within a client,
231// the underlying b+tree order is preserved. Denoms whose source client is not
232// (or no longer) registered are appended last, in b+tree order, so nothing is
233// dropped. Every stored denom is a minted voucher with a non-empty trace, so
234// Trace[0] is always safe to read.
235func denomsByClientOrder() []Denom {
236 byClient := make(map[string][]Denom)
237 all := make([]Denom, 0, denoms.Size())
238 denoms.IterateByOffset(0, denoms.Size(), func(_ string, v any) bool {
239 d := v.(Denom)
240 all = append(all, d)
241 client := d.Trace[0].ClientId
242 byClient[client] = append(byClient[client], d)
243 return false
244 })
245
246 out := make([]Denom, 0, len(all))
247 emitted := make(map[string]bool) // source clients already emitted
248 for _, client := range core.ClientIDs() {
249 if ds, ok := byClient[client]; ok {
250 out = append(out, ds...)
251 emitted[client] = true
252 }
253 }
254 for _, d := range all {
255 if !emitted[d.Trace[0].ClientId] {
256 out = append(out, d)
257 }
258 }
259 return out
260}
261
262// renderTransferLinks returns the "## Transfer" section: a set of txlinks
263// that wallets can turn into MsgCalls to Transfer. Each link leaves at least
264// one argument empty so the user fills it in their wallet.
265func renderTransferLinks() string {
266 var out strings.Builder
267 out.WriteString("## Transfer\n\n")
268
269 clientIDs := core.ClientIDs()
270 if len(clientIDs) == 0 {
271 out.WriteString("No IBC client registered yet — create one in [`/r/aib/ibc/core`](/r/aib/ibc/core) before transferring.\n\n")
272 return out.String()
273 }
274
275 out.WriteString("Trigger an IBC transfer from your wallet. Pick the client of the destination chain:\n\n")
276 for _, clientID := range clientIDs {
277 status := core.ClientStatus(clientID)
278 height := core.ClientLatestHeight(clientID)
279 if status == lightclient.Active {
280 out.WriteString(ufmt.Sprintf(
281 "- [send via `%s`](%s) — %s, trusted height `%s` ([client details](/r/aib/ibc/core:clients/%s))\n",
282 clientID, newTransferLink(clientID, "", "").URL(), status, height, clientID,
283 ))
284 } else {
285 // Non-active client: no send link, since packets can't be sent.
286 out.WriteString(ufmt.Sprintf(
287 "- `%s` — %s, trusted height `%s` ([client details](/r/aib/ibc/core:clients/%s))\n",
288 clientID, status, height, clientID,
289 ))
290 }
291 }
292
293 out.WriteString("\n")
294 return out.String()
295}
296
297// newVoucherSendLink builds a txlink to VoucherSend with the ibc denom
298// pre-filled. The user fills `to` and `amount` in their wallet.
299func newVoucherSendLink(ibcDenom string) *txlink.TxBuilder {
300 return txlink.NewLink("VoucherSend").AddArgs(
301 "ibcDenom", ibcDenom,
302 "to", "",
303 "amount", "",
304 )
305}
306
307// newVoucherApproveLink builds a txlink to VoucherApprove with the ibc denom
308// pre-filled. The user fills `spender` and `amount` in their wallet.
309func newVoucherApproveLink(ibcDenom string) *txlink.TxBuilder {
310 return txlink.NewLink("VoucherApprove").AddArgs(
311 "ibcDenom", ibcDenom,
312 "spender", "",
313 "amount", "",
314 )
315}
316
317// newTransferLink builds a txlink to the Transfer function with the given
318// pre-filled arguments. Empty values are left wallet-settable.
319// timeoutTimestamp defaults to one hour from now (in unix seconds), matching
320// what the e2e tests use; the user can still override it in their wallet.
321func newTransferLink(clientID, receiver, denom string) *txlink.TxBuilder {
322 timeoutTimestamp := strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10)
323 return txlink.NewLink("Transfer").AddArgs(
324 "clientID", clientID,
325 "receiver", receiver,
326 "denom", denom,
327 "amount", "",
328 "timeoutTimestamp", timeoutTimestamp,
329 "memo", "",
330 )
331}
332
333// stripDomain removes the domain from a gno pkg path for use in URLs.
334// "gno.land/r/demo/foo" → "/r/demo/foo"
335func stripDomain(path string) string {
336 i := strings.Index(path, "/")
337 if i != -1 {
338 return path[i:]
339 }
340 return path
341}
342
343// shortDenom shortens an IBC denom hash for display.
344// "ibc/CAEF9CABC…9D0F" (ibc/ + first 4 + … + last 4)
345func shortDenom(ibcDenom string) string {
346 const prefix = "ibc/"
347 if !strings.HasPrefix(ibcDenom, prefix) {
348 return ibcDenom
349 }
350 hash := ibcDenom[len(prefix):]
351 if len(hash) > 10 {
352 return prefix + hash[:4] + "…" + hash[len(hash)-4:]
353 }
354 return ibcDenom
355}
356
357func renderNode(w *mux.ResponseWriter, n *json.Node) {
358 bz, err := json.Marshal(n)
359 if err != nil {
360 panic(err)
361 }
362 w.Write(string(bz))
363}
364
365// grc20regKey returns the grc20reg key for a voucher ibc denom.
366// e.g. "ibc/CAEF9C..." → "gno.land/r/aib/ibc/apps/transfer.CAEF9C..."
367func grc20regKey(ibcDenom string) string {
368 return transferRealmPath + "." + ibcDenom[len("ibc/"):]
369}
370
371func nodeError(msg string, args ...any) *json.Node {
372 return json.ObjectNode("", map[string]*json.Node{
373 "error": json.StringNode("", ufmt.Sprintf(msg, args...)),
374 })
375}