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

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}