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