views.gno
14.94 Kb · 430 lines
1package escrow_v4
2
3// Read-only views: NF-2 solvency getters, counters, governance reads, JSON
4// views for frontends and Render. Nothing here changes state. (All of this
5// sits in one file, like the state-changing code in escrow.gno, because every
6// extra file costs AddPackage storage.)
7
8import (
9 "chain/runtime"
10 "crypto/sha256"
11 "encoding/hex"
12 "strconv"
13 "strings"
14
15 "gno.land/p/nt/ufmt/v0"
16 cfg "gno.land/r/samcrew/memba_market_config"
17)
18
19// ── NF-2 solvency getters (MAINNET_READINESS.md §3) ─────────────────────────
20//
21// Invariant the solvency monitor reconciles: realm balance >=
22// TotalLiabilities(). totalLiable rises by the milestone amount in
23// FundMilestone and falls by it at every point funds leave the realm.
24
25// TotalLiabilities returns the ugnot the realm owes across funded, completed
26// and disputed milestones.
27func TotalLiabilities() int64 { return totalLiable }
28
29// RealmAddress is the NF-2 canonical name for the realm's own address.
30func RealmAddress() string { return realmAddress().String() }
31
32// ── Slot and archive counters ─────────────────────────────────────────────
33
34// GetActiveCount returns the number of open contracts (not completed or
35// cancelled).
36func GetActiveCount() int { return activeCount }
37
38// GetClientActiveCount returns the open contracts created by client, bounded
39// by MaxActivePerClient.
40func GetClientActiveCount(client address) int { return clientActive(client) }
41
42// GetLiveCount returns the contracts still stored: open ones plus settled ones
43// their client has not archived.
44func GetLiveCount() int { return contracts.Size() }
45
46// GetArchivedCount returns the contracts deleted by ArchiveContract.
47func GetArchivedCount() int { return archivedCount }
48
49// GetCreatedCount returns every contract ever created; ids run from 0 to
50// GetCreatedCount()-1 and are never reused.
51func GetCreatedCount() int { return nextID }
52
53// Value-only reads for the memba_dao escrow adapter, unchanged from escrow_v3:
54// no Contract pointer or persistent milestone slice escapes. An archived
55// contract reads as absent (Exists false).
56
57type GovernanceMilestone struct {
58 ID int
59 Amount int64
60 Status string
61 FundedAt, CompletedAt, DisputedAt int64
62 PreDisputeStatus string
63}
64
65type GovernanceContract struct {
66 Exists bool
67 ID, ContentHash, Status string
68 Client, Freelancer address
69 Count int
70 Milestones [20]GovernanceMilestone
71}
72
73func GetGovernanceContract(id string) GovernanceContract {
74 v, ok := contracts.Get(id)
75 if !ok {
76 return GovernanceContract{}
77 }
78 c := v.(*Contract)
79 if len(c.Milestones) > MaxMilestones {
80 panic("invalid escrow milestone count")
81 }
82 s := GovernanceContract{Exists: true, ID: c.ID, Status: string(c.Status), Client: c.Client, Freelancer: c.Freelancer, Count: len(c.Milestones), ContentHash: contentHash(c)}
83 for i, m := range c.Milestones {
84 s.Milestones[i] = GovernanceMilestone{ID: m.ID, Amount: m.Amount, Status: string(m.Status), FundedAt: m.FundedAt, CompletedAt: m.CompletedAt, DisputedAt: m.DisputedAt, PreDisputeStatus: string(m.PreDisputeStatus)}
85 }
86 return s
87}
88
89// contentHash digests the immutable metadata of a contract (parties, texts,
90// creation height, milestone titles and amounts), not its state.
91func contentHash(c *Contract) string {
92 metadata := "escrow-metadata/v1" + escrowMetadataField(c.ID) + escrowMetadataField(string(c.Client)) + escrowMetadataField(string(c.Freelancer)) + escrowMetadataField(c.Title) + escrowMetadataField(c.Description) + escrowMetadataField(strconv.FormatInt(c.CreatedAt, 10)) + escrowMetadataField(strconv.Itoa(len(c.Milestones)))
93 for _, m := range c.Milestones {
94 metadata += escrowMetadataField(strconv.Itoa(m.ID)) + escrowMetadataField(m.Title) + escrowMetadataField(strconv.FormatInt(m.Amount, 10))
95 }
96 digest := sha256.Sum256([]byte(metadata))
97 return hex.EncodeToString(digest[:])
98}
99
100type GovernanceFeeTerms struct {
101 RawBPS, EffectiveBPS int64
102 RawTreasury, FallbackTreasury, EffectiveTreasury address
103}
104
105func GetGovernanceFeeTerms() GovernanceFeeTerms {
106 bps, treasury := resolveFee()
107 return GovernanceFeeTerms{RawBPS: int64(cfg.GetFeeBPS(laneService)), EffectiveBPS: bps, RawTreasury: cfg.GetTreasury(), FallbackTreasury: feeRecipient, EffectiveTreasury: treasury}
108}
109
110func escrowMetadataField(value string) string { return strconv.Itoa(len(value)) + ":" + value }
111
112// Structured read-only views for frontends (additive to the frozen API; they
113// change no state and no existing behaviour). Every integer is a decimal
114// string, absent values are null, and an unknown or archived id reads as
115// {"exists":false,...}. Heights such as refundAt are pause-adjusted deadlines,
116// assuming no further pause. Contract listings walk at most limit+1 entries
117// of a per-party index, newest first.
118
119const maxPageSize = 50
120
121// indexPrefix keys the client index inside clientSlots (the per-client slot
122// counters are keyed by the bare address, which never starts with "c/"): one
123// tree instead of two keeps the package smaller.
124const indexPrefix = "c/"
125
126// GetContractJSON returns one contract as JSON.
127func GetContractJSON(id string) string {
128 v, ok := contracts.Get(id)
129 if !ok {
130 return `{"exists":false,"id":` + jstr(id) + `}`
131 }
132 c := v.(*Contract)
133 var total, escrowed, released, refunded, fundedAt, refundAt, resolveAt int64
134 pending := true
135 var ms strings.Builder
136 for i, m := range c.Milestones {
137 total += m.Amount
138 mRefund, mResolve := int64(0), int64(0)
139 switch m.Status {
140 case MsFunded:
141 mRefund = deadline(m.FundedAt, m.fundedPauseMark, AutoRefundBlks)
142 refundAt = minHeight(refundAt, mRefund)
143 escrowed += m.Amount
144 case MsDisputed:
145 mResolve = deadline(m.DisputedAt, m.disputedPauseMark, AutoResolveBlks)
146 resolveAt = minHeight(resolveAt, mResolve)
147 escrowed += m.Amount
148 case MsCompleted:
149 escrowed += m.Amount
150 case MsReleased:
151 released += m.Amount
152 case MsRefunded:
153 refunded += m.Amount
154 }
155 if m.Status != MsPending {
156 pending = false
157 }
158 fundedAt = minHeight(fundedAt, m.FundedAt)
159 if i > 0 {
160 ms.WriteString(",")
161 }
162 ms.WriteString(`{"index":` + jint(int64(i)) + `,"title":` + jstr(m.Title) +
163 `,"amountUgnot":` + jint(m.Amount) + `,"status":` + jstr(string(m.Status)) +
164 `,"fundedAtHeight":` + jheight(m.FundedAt) + `,"completedAtHeight":` + jheight(m.CompletedAt) +
165 `,"disputedAtHeight":` + jheight(m.DisputedAt) + `,"refundAt":` + jheight(mRefund) +
166 `,"resolveAt":` + jheight(mResolve) + `}`)
167 }
168 expireAt := int64(0)
169 if pending && c.Status == StatusActive {
170 expireAt = deadline(c.CreatedAt, c.createdPauseMark, UnfundedExpiryBlks)
171 }
172 return `{"exists":true,"id":` + jstr(c.ID) + `,"client":` + jstr(c.Client.String()) +
173 `,"freelancer":` + jstr(c.Freelancer.String()) + `,"title":` + jstr(c.Title) +
174 `,"description":` + jstr(c.Description) + `,"status":` + jstr(string(c.Status)) +
175 `,"createdAtHeight":` + jint(c.CreatedAt) + `,"fundedAtHeight":` + jheight(fundedAt) +
176 `,"refundAt":` + jheight(refundAt) + `,"expireAt":` + jheight(expireAt) + `,"resolveAt":` + jheight(resolveAt) +
177 `,"milestones":[` + ms.String() + `],"totals":{"amountUgnot":` + jint(total) +
178 `,"escrowedUgnot":` + jint(escrowed) + `,"releasedUgnot":` + jint(released) +
179 `,"refundedUgnot":` + jint(refunded) + `}}`
180}
181
182// GetClientContractsJSON pages the live contracts a client created, newest
183// first: {"items":[{"id","status","createdAtHeight"}...],"next":cursor|null}.
184// before is "" for the first page or the previous page's next cursor; limit
185// is clamped to 1..50.
186func GetClientContractsJSON(client address, before string, limit int) string {
187 return contractsPage(client, before, limit)
188}
189
190// GetPauseStateJSON returns PauseState() as JSON.
191func GetPauseStateJSON() string {
192 p := PauseState()
193 return `{"paused":` + strconv.FormatBool(p.Paused) + `,"pausedAt":` + jheight(p.PausedAt) +
194 `,"exitsReopenAt":` + jheight(p.ExitsReopenAt) + `,"exitsOpen":` + strconv.FormatBool(p.ExitsOpen) +
195 `,"cooldownUntil":` + jint(p.CooldownUntil) + `,"pausedBlocks":` + jint(p.PausedBlocks) + `}`
196}
197
198func contractsPage(who address, before string, limit int) string {
199 if limit < 1 {
200 limit = 1
201 } else if limit > maxPageSize {
202 limit = maxPageSize
203 }
204 prefix := indexPrefix + who.String() + "/"
205 end := prefix + "~" // after every digit
206 if before != "" {
207 n, err := strconv.Atoi(before)
208 if err != nil || n < 0 || strconv.Itoa(n) != before {
209 panic("invalid cursor: " + before)
210 }
211 end = indexKey(who, n)
212 }
213 var sb strings.Builder
214 count, next, last := 0, "null", ""
215 clientSlots.ReverseIterate(prefix, end, func(key string, _ any) bool {
216 n, _ := strconv.Atoi(key[len(prefix):])
217 id := strconv.Itoa(n)
218 if id == before {
219 return false // end is inclusive; the cursor itself was on the previous page
220 }
221 if count == limit {
222 next = jstr(last)
223 return true
224 }
225 c := getContract(id)
226 if count > 0 {
227 sb.WriteString(",")
228 }
229 sb.WriteString(`{"id":` + jstr(id) + `,"status":` + jstr(string(c.Status)) +
230 `,"createdAtHeight":` + jint(c.CreatedAt) + `}`)
231 count++
232 last = id
233 return false
234 })
235 return `{"items":[` + sb.String() + `],"next":` + next + `}`
236}
237
238// indexKey orders a party's contracts by id: 10-digit zero padding.
239func indexKey(who address, id int) string {
240 s := strconv.Itoa(id)
241 return indexPrefix + who.String() + "/" + strings.Repeat("0", 10-len(s)) + s
242}
243
244// deadline is the height at which a clock started at since (pause mark taken
245// then) reaches span unpaused blocks, assuming no pause after the current one.
246func deadline(since, mark, span int64) int64 {
247 now := runtime.ChainHeight()
248 if inBlockingWindow(now) {
249 now = pausedAt + MaxPauseBlks
250 }
251 return since + span + pausedBlocksAt(now) - mark
252}
253
254func minHeight(a, b int64) int64 {
255 if a == 0 || (b != 0 && b < a) {
256 return b
257 }
258 return a
259}
260
261func jint(n int64) string { return `"` + strconv.FormatInt(n, 10) + `"` }
262
263// jheight encodes 0 (not set) as null.
264func jheight(n int64) string {
265 if n == 0 {
266 return "null"
267 }
268 return jint(n)
269}
270
271// jstr quotes s as a JSON string. Stored text is valid UTF-8 without control
272// characters; ids passed by callers are escaped the same way.
273func jstr(s string) string {
274 var b strings.Builder
275 b.WriteByte('"')
276 for _, r := range s {
277 switch {
278 case r == '"' || r == '\\':
279 b.WriteByte('\\')
280 b.WriteRune(r)
281 case r < 0x20:
282 b.WriteString(`\u00`)
283 b.WriteByte("0123456789abcdef"[r>>4])
284 b.WriteByte("0123456789abcdef"[r&15])
285 default:
286 b.WriteRune(r)
287 }
288 }
289 b.WriteByte('"')
290 return b.String()
291}
292
293// Render() contract:
294// Home — Render("") or Render("page/N"):
295// # Escrow Contracts
296// | ID | Title | Client | Freelancer | Status | Total |
297// (RenderPageSize live contracts per page, in tree order)
298// Detail — Render("contract/ID"):
299// # Title
300// **Client:** g1...
301// **Freelancer:** g1...
302// **Status:** active
303// ## Milestones
304// - **Milestone Title** — 1000000 ugnot [funded]
305// Stats — Render("stats"): counters only, no iteration.
306// Archived contracts are gone from every view.
307
308// RenderPageSize is the number of contracts listed per home page.
309const RenderPageSize = 20
310
311func Render(path string) string {
312 switch {
313 case path == "":
314 return renderHome(1)
315 case strings.HasPrefix(path, "page/"):
316 page, err := strconv.Atoi(strings.TrimPrefix(path, "page/"))
317 if err != nil || page < 1 {
318 return "# 404\nNot found: " + path
319 }
320 return renderHome(page)
321 case strings.HasPrefix(path, "contract/"):
322 return renderContract(strings.TrimPrefix(path, "contract/"))
323 case path == "stats":
324 return renderStats()
325 }
326 return "# 404\nNot found: " + path
327}
328
329func renderHome(page int) string {
330 var sb strings.Builder
331 sb.WriteString("# Escrow Contracts\n\n")
332 sb.WriteString("Milestone-based escrow for freelance services on Gno.\n\n")
333
334 live := contracts.Size()
335 if live == 0 {
336 sb.WriteString("*No contracts yet.*\n")
337 return sb.String()
338 }
339 offset := (page - 1) * RenderPageSize
340 if offset >= live {
341 return "# 404\nNo page " + strconv.Itoa(page)
342 }
343
344 sb.WriteString("| ID | Title | Client | Freelancer | Status | Total |\n")
345 sb.WriteString("| --- | --- | --- | --- | --- | --- |\n")
346 contracts.IterateByOffset(offset, RenderPageSize, func(_ string, value any) bool {
347 c := value.(*Contract)
348 sb.WriteString(ufmt.Sprintf("| %s | [%s](:contract/%s) | %s | %s | %s | %d ugnot |\n",
349 c.ID, c.Title, c.ID, truncAddr(c.Client), truncAddr(c.Freelancer),
350 string(c.Status), contractTotal(c)))
351 return false
352 })
353 if offset+RenderPageSize < live {
354 sb.WriteString(ufmt.Sprintf("\n[Next page](:page/%d)\n", page+1))
355 }
356 return sb.String()
357}
358
359func renderContract(id string) string {
360 val, exists := contracts.Get(id)
361 if !exists {
362 return "# 404\nContract not found: " + id
363 }
364 c := val.(*Contract)
365
366 var sb strings.Builder
367 sb.WriteString("# " + c.Title + "\n\n")
368 if len(c.Description) > 0 {
369 sb.WriteString(c.Description + "\n\n")
370 }
371 sb.WriteString("**ID:** " + c.ID + "\n")
372 sb.WriteString("**Client:** " + c.Client.String() + "\n")
373 sb.WriteString("**Freelancer:** " + c.Freelancer.String() + "\n")
374 sb.WriteString("**Status:** " + string(c.Status) + "\n")
375 sb.WriteString("**Created:** block " + strconv.FormatInt(c.CreatedAt, 10) + "\n\n")
376 sb.WriteString("**Total Value:** " + strconv.FormatInt(contractTotal(c), 10) + " ugnot\n\n")
377
378 sb.WriteString("## Milestones\n\n")
379 for _, ms := range c.Milestones {
380 sb.WriteString(ufmt.Sprintf("- **%s** — %d ugnot [%s]", ms.Title, ms.Amount, string(ms.Status)))
381 if ms.FundedAt > 0 {
382 sb.WriteString(ufmt.Sprintf(" (funded block %d)", ms.FundedAt))
383 }
384 if ms.CompletedAt > 0 {
385 sb.WriteString(ufmt.Sprintf(" (completed block %d)", ms.CompletedAt))
386 }
387 if ms.DisputedAt > 0 {
388 sb.WriteString(ufmt.Sprintf(" (disputed block %d)", ms.DisputedAt))
389 }
390 sb.WriteString("\n")
391 }
392 return sb.String()
393}
394
395func renderStats() string {
396 var sb strings.Builder
397 sb.WriteString("# Escrow Stats\n\n")
398 sb.WriteString(ufmt.Sprintf("**Created:** %d\n", nextID))
399 sb.WriteString(ufmt.Sprintf("**Open:** %d (at most %d per client)\n", activeCount, MaxActivePerClient))
400 sb.WriteString(ufmt.Sprintf("**Settled, not archived:** %d\n", contracts.Size()-activeCount))
401 sb.WriteString(ufmt.Sprintf("**Archived:** %d\n", archivedCount))
402 sb.WriteString(ufmt.Sprintf("**Liabilities:** %d ugnot\n", totalLiable))
403 p := PauseState()
404 switch {
405 case !p.Paused:
406 sb.WriteString("**Paused:** no\n")
407 case !p.ExitsOpen:
408 sb.WriteString(ufmt.Sprintf("**Paused:** yes, since block %d; exits reopen at block %d\n", p.PausedAt, p.ExitsReopenAt))
409 default:
410 sb.WriteString(ufmt.Sprintf("**Paused:** new contracts and funding only, since block %d\n", p.PausedAt))
411 }
412 return sb.String()
413}
414
415// contractTotal cannot overflow: CreateContract refuses totals above int64.
416func contractTotal(c *Contract) int64 {
417 total := int64(0)
418 for _, ms := range c.Milestones {
419 total += ms.Amount
420 }
421 return total
422}
423
424func truncAddr(addr address) string {
425 s := addr.String()
426 if len(s) > 13 {
427 return s[:10] + "..."
428 }
429 return s
430}