render.gno
9.12 Kb · 302 lines
1package otc
2
3import (
4 "strconv"
5 "strings"
6 "time"
7
8 "gno.land/p/nt/avl/v0"
9 "gno.land/r/nt/grc20reg/v0"
10)
11
12// Reads do not refuse coins: the envelope belongs to the whole message, so a
13// guard here would break any realm that reads an offer inside a paid call of
14// its own. Only writes are guarded.
15//
16// Amounts in JSON are strings: int64 does not fit a JavaScript number.
17
18func RealmAddress() address { return realmAddr }
19
20// GetOffer returns one offer as JSON, public or private.
21func GetOffer(id uint64) string { return offerJSON(mustOffer(id)) }
22
23// ListOpen returns open public offers, newest first (S9).
24func ListOpen(offset, limit int) string {
25 return listJSON(&openPublic, offset, limit)
26}
27
28// ListByMaker returns every offer of one maker, private and closed ones
29// included, newest first.
30func ListByMaker(addr string, offset, limit int) string {
31 v := byMaker.Get(addr)
32 if v == nil {
33 return `{"total":0,"offers":[]}`
34 }
35 return listJSON(v.(*avl.Tree), offset, limit)
36}
37
38// GetConfig returns the parameters and the whitelist as JSON.
39func GetConfig() string {
40 var b strings.Builder
41 b.WriteString(`{"realm":` + jstr(realmAddr.String()))
42 b.WriteString(`,"admin":` + jstr(admin.String()))
43 b.WriteString(`,"fee_recipient":` + jstr(feeRecipient.String()))
44 b.WriteString(`,"fee_bps":` + num(feeBps))
45 b.WriteString(`,"fee_cap_bps":` + num(feeCapBps))
46 b.WriteString(`,"gnomic_key":` + jstr(gnomicKey))
47 b.WriteString(`,"gnomic_fee_enabled":` + strconv.FormatBool(gnomicFeeOn))
48 b.WriteString(`,"gnomic_fee_bps":` + num(gnomicFee))
49 b.WriteString(`,"paused":` + strconv.FormatBool(paused))
50 b.WriteString(`,"max_page_size":` + strconv.Itoa(maxPageSize))
51 b.WriteString(`,"tokens":[`)
52 i := 0
53 tokens.Iterate("", "", func(key string, v any) bool { // bounded by maxTokens
54 if i > 0 {
55 b.WriteString(",")
56 }
57 i++
58 sym, dec := tokenInfo(key)
59 b.WriteString(`{"key":` + jstr(key) + `,"symbol":` + jstr(sym) +
60 `,"decimals":` + strconv.Itoa(dec) + `,"enabled":` + strconv.FormatBool(v.(bool)) + `}`)
61 return false
62 })
63 b.WriteString("]}")
64 return b.String()
65}
66
67// GetStats returns the running totals as JSON (F7).
68func GetStats() string {
69 return `{"total_fees_ugnot":` + jstr(num(totalFees)) +
70 `,"total_volume_ugnot":` + jstr(num(totalVolume)) +
71 `,"filled":` + num(filledCount) +
72 `,"offers":` + strconv.FormatUint(lastID, 10) +
73 `,"open_public":` + strconv.Itoa(openPublic.Size()) + `}`
74}
75
76// ---------------------------------------------------------------------------
77// Render
78// ---------------------------------------------------------------------------
79
80// Render serves "" (stats and the first page), "page/N" and "offer/ID".
81func Render(path string) string {
82 switch {
83 case path == "":
84 return renderHome(1)
85 case strings.HasPrefix(path, "page/"):
86 n, err := strconv.Atoi(path[len("page/"):])
87 if err != nil || n < 1 {
88 return "# Not found\n"
89 }
90 return renderHome(n)
91 case strings.HasPrefix(path, "offer/"):
92 id, err := strconv.ParseUint(path[len("offer/"):], 10, 64)
93 if err != nil || !offers.Has(padID(id)) {
94 return "# Not found\n"
95 }
96 return renderOffer(mustOffer(id))
97 }
98 return "# Not found\n"
99}
100
101func renderHome(page int) string {
102 var b strings.Builder
103 b.WriteString("# Gnomic OTC\n\n")
104 b.WriteString("Escrowed GRC20 ⇄ GNOT offers, filled in full and atomically.\n\n")
105 if paused {
106 b.WriteString("**Paused:** no new offers or fills. Makers can still cancel.\n\n")
107 }
108 b.WriteString("| Volume | Fees | Filled | Open (public) | Fee |\n|---|---|---|---|---|\n")
109 b.WriteString("| " + units(totalVolume, 6) + " GNOT | " + units(totalFees, 6) + " GNOT | " +
110 num(filledCount) + " | " + strconv.Itoa(openPublic.Size()) + " | " + bps(feeBps))
111 if gnomicFeeOn {
112 b.WriteString(" (GNOMIC " + bps(gnomicFee) + ")")
113 }
114 b.WriteString(" |\n\n")
115
116 b.WriteString("## Open offers\n\n")
117 size := openPublic.Size()
118 // Compared as page numbers, not as an offset: (page-1)*pageSize overflows
119 // for a large enough page.
120 if page > 1 && page-1 > (size-1)/defaultPageSize {
121 return "# Not found\n"
122 }
123 if size == 0 {
124 b.WriteString("No open offers.\n")
125 return b.String()
126 }
127 offset := (page - 1) * defaultPageSize
128 b.WriteString("| ID | Side | Token | Amount | GNOT | Maker | Expires |\n|---|---|---|---|---|---|---|\n")
129 openPublic.ReverseIterateByOffset(offset, defaultPageSize, func(key string, _ any) bool {
130 o := offers.Get(key).(*Offer)
131 sym, dec := tokenInfo(o.TokenKey)
132 b.WriteString("| [" + num64(o.ID) + "](" + link("offer/"+num64(o.ID)) + ") | " + o.Side.String() +
133 " | " + sym + " | " + units(o.TokenAmt, dec) + " | " + units(o.GnotAmt, 6) +
134 " | " + short(o.Maker) + " | " + expiry(o) + " |\n")
135 return false
136 })
137 if page > 1 {
138 b.WriteString("\n[← newer](" + link("page/"+strconv.Itoa(page-1)) + ")")
139 }
140 if offset+defaultPageSize < size {
141 b.WriteString("\n[older →](" + link("page/"+strconv.Itoa(page+1)) + ")")
142 }
143 b.WriteString("\n")
144 return b.String()
145}
146
147func renderOffer(o *Offer) string {
148 sym, dec := tokenInfo(o.TokenKey)
149 fee := feeOf(o.GnotAmt, o.FeeBps)
150 var b strings.Builder
151 b.WriteString("# Offer " + num64(o.ID) + "\n\n")
152 b.WriteString("| | |\n|---|---|\n")
153 if o.Status == Open && expired(o) {
154 b.WriteString("| Status | Open, expired: it can no longer be filled, only cancelled by its maker |\n")
155 } else {
156 b.WriteString("| Status | " + o.Status.String() + " |\n")
157 }
158 b.WriteString("| Side | " + o.Side.String() + " |\n")
159 b.WriteString("| Token | " + sym + " (`" + o.TokenKey + "`) |\n")
160 b.WriteString("| Token amount | " + units(o.TokenAmt, dec) + " " + sym + " |\n")
161 b.WriteString("| GNOT amount | " + units(o.GnotAmt, 6) + " GNOT |\n")
162 b.WriteString("| Fee | " + bps(o.FeeBps) + " = " + units(fee, 6) + " GNOT, paid by whoever receives the GNOT |\n")
163 b.WriteString("| Maker | `" + o.Maker.String() + "` |\n")
164 if o.Taker == "" {
165 b.WriteString("| Taker | anyone |\n")
166 } else {
167 b.WriteString("| Taker | `" + o.Taker.String() + "` (not listed) |\n")
168 }
169 b.WriteString("| Created | " + when(o.CreatedAt) + " |\n")
170 b.WriteString("| Expires | " + expiry(o) + " |\n")
171 b.WriteString("\n[← all offers](" + link("") + ")\n")
172 return b.String()
173}
174
175// ---------------------------------------------------------------------------
176// Helpers
177// ---------------------------------------------------------------------------
178
179func listJSON(t *avl.Tree, offset, limit int) string {
180 if offset < 0 {
181 offset = 0
182 }
183 if limit <= 0 {
184 limit = defaultPageSize
185 }
186 if limit > maxPageSize {
187 limit = maxPageSize
188 }
189 var b strings.Builder
190 b.WriteString(`{"total":` + strconv.Itoa(t.Size()) + `,"offers":[`)
191 i := 0
192 t.ReverseIterateByOffset(offset, limit, func(key string, _ any) bool {
193 if i > 0 {
194 b.WriteString(",")
195 }
196 i++
197 b.WriteString(offerJSON(offers.Get(key).(*Offer)))
198 return false
199 })
200 b.WriteString("]}")
201 return b.String()
202}
203
204func offerJSON(o *Offer) string {
205 sym, dec := tokenInfo(o.TokenKey)
206 return `{"id":` + jstr(num64(o.ID)) +
207 `,"maker":` + jstr(o.Maker.String()) +
208 `,"taker":` + jstr(o.Taker.String()) +
209 `,"side":` + jstr(o.Side.String()) +
210 `,"token_key":` + jstr(o.TokenKey) +
211 `,"token_symbol":` + jstr(sym) +
212 `,"token_decimals":` + strconv.Itoa(dec) +
213 `,"token_amt":` + jstr(num(o.TokenAmt)) +
214 `,"gnot_amt":` + jstr(num(o.GnotAmt)) +
215 `,"fee_bps":` + num(o.FeeBps) +
216 `,"fee_ugnot":` + jstr(num(feeOf(o.GnotAmt, o.FeeBps))) +
217 `,"expires_at":` + num(o.ExpiresAt) +
218 `,"expired":` + strconv.FormatBool(expired(o)) +
219 `,"created_at":` + num(o.CreatedAt) +
220 `,"status":` + jstr(o.Status.String()) + `}`
221}
222
223func tokenInfo(key string) (string, int) {
224 t := grc20reg.Get(key)
225 if t == nil {
226 return key, 0
227 }
228 return t.GetSymbol(), t.GetDecimals()
229}
230
231// jstr quotes a string for JSON.
232func jstr(s string) string {
233 var b strings.Builder
234 b.WriteByte('"')
235 for _, r := range s {
236 switch {
237 case r == '"' || r == '\\':
238 b.WriteByte('\\')
239 b.WriteRune(r)
240 case r < 0x20:
241 h := strconv.FormatInt(int64(r), 16)
242 b.WriteString(`\u` + strings.Repeat("0", 4-len(h)) + h)
243 default:
244 b.WriteRune(r)
245 }
246 }
247 b.WriteByte('"')
248 return b.String()
249}
250
251func num(v int64) string { return strconv.FormatInt(v, 10) }
252func num64(v uint64) string { return strconv.FormatUint(v, 10) }
253
254// units formats base units with dec decimals, trailing zeros dropped.
255func units(v int64, dec int) string {
256 s := strconv.FormatInt(v, 10)
257 if dec <= 0 {
258 return s
259 }
260 if len(s) <= dec {
261 s = strings.Repeat("0", dec-len(s)+1) + s
262 }
263 whole, frac := s[:len(s)-dec], strings.TrimRight(s[len(s)-dec:], "0")
264 if frac == "" {
265 return whole
266 }
267 return whole + "." + frac
268}
269
270func bps(v int64) string { return units(v, 2) + "%" }
271
272func short(a address) string {
273 s := a.String()
274 if len(s) <= 12 {
275 return s
276 }
277 return s[:8] + "…" + s[len(s)-4:]
278}
279
280// link is an absolute gnoweb link to one of this realm's Render paths.
281// gnoweb rejects a bare ":path" destination and prints it as plain text.
282func link(path string) string {
283 if path == "" {
284 return webPath
285 }
286 return webPath + ":" + path
287}
288
289// expiry is the Expires cell: the date, marked once it has passed.
290func expiry(o *Offer) string {
291 if expired(o) {
292 return "expired " + when(o.ExpiresAt)
293 }
294 return when(o.ExpiresAt)
295}
296
297func when(unix int64) string {
298 if unix == 0 {
299 return "never"
300 }
301 return time.Unix(unix, 0).UTC().Format("2006-01-02 15:04 UTC")
302}