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

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}