Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

views.gno

14.97 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/g1a9g8nu3m0xgx58hh36fph49e2xkqm9axlxuf5z/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}