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

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}