Search Apps Documentation Source Content File Folder Download Copy Actions Download

escrow.gno

25.03 Kb · 830 lines
  1package escrow_v2
  2
  3// Milestone-based Escrow — On-chain freelance service contracts for Memba.
  4//
  5// Flow: CreateContract → FundMilestone → CompleteMilestone → ReleaseFunds
  6// Disputes: RaiseDispute → admin resolves (or auto-resolves after timeout)
  7// Timeouts: ClaimRefund (auto-refund if milestone not completed after N blocks)
  8//           ClaimDisputeTimeout (auto-release to freelancer if admin doesn't act)
  9//
 10// Security:
 11//   - STATE-BEFORE-SEND: All state updates happen before SendCoins calls
 12//   - FeeRecipient validated in init()
 13//   - Self-hire prevention: freelancer != client
 14//   - Input validation: title/description length, milestone amounts > 0
 15//   - Auto-refund: prevents permanent fund locking
 16//   - Dispute timeout: prevents permanent dispute locking
 17//
 18// Render() contract:
 19//   Home — Render(""):
 20//     # Escrow Contracts
 21//     | ID | Title | Client | Freelancer | Status | Total |
 22//
 23//   Detail — Render("contract/ID"):
 24//     # Title
 25//     **Client:** g1...
 26//     **Freelancer:** g1...
 27//     **Status:** active
 28//     ## Milestones
 29//     - **Milestone Title** — 1000000 ugnot [funded]
 30
 31import (
 32	"chain"
 33	"chain/banker"
 34	"chain/runtime"
 35	"chain/runtime/unsafe"
 36	"strconv"
 37	"strings"
 38
 39	"gno.land/p/nt/avl/v0"
 40	"gno.land/p/nt/ufmt/v0"
 41)
 42
 43// ── Constants ────────────────────────────────────────────────
 44
 45const (
 46	AdminAddress    = "g1x7k4628w93a7wzdhqc06atzx0v50rnshweuxu0" // samcrew-core-test1 multisig
 47	FeeRecipient    = "g1x7k4628w93a7wzdhqc06atzx0v50rnshweuxu0" // Samourai Coop multisig
 48	PlatformFeePct  = 2  // 2%
 49	CancelFeePct    = 5  // 5% cancellation fee
 50	AutoRefundBlks  = int64(864000)  // ~30 days at 3s/block
 51	AutoResolveBlks = int64(806400)  // ~28 days at 3s/block
 52	MaxTitleLen        = 200
 53	MaxDescLen         = 5000
 54	MaxMilestones      = 20
 55	MaxContracts       = 500
 56	MinMilestoneAmount = int64(1000) // 0.001 GNOT — prevents fee evasion via truncation
 57)
 58
 59// ── Types ────────────────────────────────────────────────────
 60
 61type ContractStatus string
 62
 63const (
 64	StatusActive    ContractStatus = "active"
 65	StatusCompleted ContractStatus = "completed"
 66	StatusDisputed  ContractStatus = "disputed"
 67	StatusCancelled ContractStatus = "cancelled"
 68)
 69
 70type MilestoneStatus string
 71
 72const (
 73	MsPending   MilestoneStatus = "pending"
 74	MsFunded    MilestoneStatus = "funded"
 75	MsCompleted MilestoneStatus = "completed"
 76	MsReleased  MilestoneStatus = "released"
 77	MsDisputed  MilestoneStatus = "disputed"
 78	MsRefunded  MilestoneStatus = "refunded"
 79)
 80
 81type Contract struct {
 82	ID          string
 83	Client      address
 84	Freelancer  address
 85	Title       string
 86	Description string
 87	Status      ContractStatus
 88	CreatedAt   int64 // block height
 89	Milestones  []Milestone
 90}
 91
 92type Milestone struct {
 93	ID               int
 94	Title            string
 95	Amount           int64 // ugnot
 96	Status           MilestoneStatus
 97	FundedAt         int64           // block height (0 if not funded)
 98	CompletedAt      int64           // block height (0 if not completed)
 99	DisputedAt       int64           // block height (0 if not disputed)
100	PreDisputeStatus MilestoneStatus // status before dispute (MsFunded or MsCompleted)
101}
102
103// ── State ────────────────────────────────────────────────────
104
105var (
106	contracts *avl.Tree // id -> *Contract
107	nextID    int
108	paused    bool
109)
110
111func init() {
112	contracts = avl.NewTree()
113
114	// Validate FeeRecipient at init — prevents fund-trapping panics later
115	if len(FeeRecipient) == 0 {
116		panic("FeeRecipient cannot be empty")
117	}
118}
119
120// ── Emergency Pause ────────────────────────────────────────
121
122func assertNotPaused() {
123	if paused {
124		panic("realm is paused — emergency maintenance")
125	}
126}
127
128// Pause halts all write operations. Admin only.
129func Pause(cur realm) {
130	caller := unsafe.PreviousRealm().Address()
131	if caller != address(AdminAddress) {
132		panic("only admin can pause")
133	}
134	paused = true
135}
136
137// Unpause resumes normal operations. Admin only.
138func Unpause(cur realm) {
139	caller := unsafe.PreviousRealm().Address()
140	if caller != address(AdminAddress) {
141		panic("only admin can unpause")
142	}
143	paused = false
144}
145
146// IsPaused returns the current pause state.
147func IsPaused() bool {
148	return paused
149}
150
151// ── Contract Lifecycle ──────────────────────────────────────
152
153// CreateContract creates a new escrow contract with milestones.
154// Milestones format: "title1:amount1,title2:amount2"
155func CreateContract(cur realm, freelancer address, title, description, milestones string) string {
156	assertNotPaused()
157	caller := unsafe.PreviousRealm().Address()
158
159	// Validations
160	if freelancer == caller {
161		panic("cannot hire yourself")
162	}
163	if len(title) == 0 || len(title) > MaxTitleLen {
164		panic(ufmt.Sprintf("title must be 1-%d characters", MaxTitleLen))
165	}
166	if len(description) > MaxDescLen {
167		panic(ufmt.Sprintf("description must be under %d characters", MaxDescLen))
168	}
169	if contracts.Size() >= MaxContracts {
170		panic("contract limit reached")
171	}
172
173	ms := parseMilestones(milestones)
174	if len(ms) == 0 {
175		panic("at least one milestone required")
176	}
177	if len(ms) > MaxMilestones {
178		panic(ufmt.Sprintf("maximum %d milestones allowed", MaxMilestones))
179	}
180
181	id := strconv.Itoa(nextID)
182	nextID++
183
184	c := &Contract{
185		ID:          id,
186		Client:      caller,
187		Freelancer:  freelancer,
188		Title:       sanitizeMilestoneTitle(title),
189		Description: sanitizeMilestoneTitle(description),
190		Status:      StatusActive,
191		CreatedAt:   runtime.ChainHeight(),
192		Milestones:  ms,
193	}
194	contracts.Set(id, c)
195
196	chain.Emit("ContractCreated",
197		"id", id,
198		"client", caller.String(),
199		"freelancer", freelancer.String(),
200		"milestones", strconv.Itoa(len(ms)),
201	)
202	return id
203}
204
205// FundMilestone deposits funds for a specific milestone. Client only.
206func FundMilestone(cur realm, contractId string, milestoneIdx int) {
207	assertNotPaused()
208	caller := unsafe.PreviousRealm().Address()
209	c := getContract(contractId)
210
211	if c.Client != caller {
212		panic("only client can fund")
213	}
214	if c.Status != StatusActive {
215		panic("contract not active")
216	}
217	if milestoneIdx < 0 || milestoneIdx >= len(c.Milestones) {
218		panic("invalid milestone index")
219	}
220
221	ms := &c.Milestones[milestoneIdx]
222	if ms.Status != MsPending {
223		panic("milestone already funded or processed")
224	}
225
226	// Verify coins sent (accumulate to handle multi-entry defensively)
227	sent := unsafe.OriginSend()
228	sentAmount := int64(0)
229	for _, coin := range sent {
230		if coin.Denom == "ugnot" {
231			sentAmount += coin.Amount
232		}
233	}
234	if sentAmount != ms.Amount {
235		panic(ufmt.Sprintf("must send exactly %d ugnot (sent %d)", ms.Amount, sentAmount))
236	}
237
238	ms.Status = MsFunded
239	ms.FundedAt = runtime.ChainHeight()
240	contracts.Set(contractId, c)
241
242	chain.Emit("MilestoneFunded",
243		"contractId", contractId,
244		"milestone", strconv.Itoa(milestoneIdx),
245		"amount", strconv.FormatInt(ms.Amount, 10),
246	)
247}
248
249// CompleteMilestone marks a milestone as completed. Freelancer only.
250func CompleteMilestone(cur realm, contractId string, milestoneIdx int) {
251	assertNotPaused()
252	caller := unsafe.PreviousRealm().Address()
253	c := getContract(contractId)
254
255	if c.Freelancer != caller {
256		panic("only freelancer can mark complete")
257	}
258	// Only allow completion when contract is Active. Disputed contracts are frozen
259	// until ResolveDispute/ClaimDisputeTimeout returns the contract to Active.
260	if c.Status != StatusActive {
261		panic("contract is " + string(c.Status) + " — cannot complete milestones during dispute")
262	}
263	if milestoneIdx < 0 || milestoneIdx >= len(c.Milestones) {
264		panic("invalid milestone index")
265	}
266
267	ms := &c.Milestones[milestoneIdx]
268	if ms.Status != MsFunded {
269		panic("milestone not funded")
270	}
271
272	ms.Status = MsCompleted
273	ms.CompletedAt = runtime.ChainHeight()
274	contracts.Set(contractId, c)
275
276	chain.Emit("MilestoneCompleted",
277		"contractId", contractId,
278		"milestone", strconv.Itoa(milestoneIdx),
279		"freelancer", caller.String(),
280	)
281}
282
283// ReleaseFunds releases funds to freelancer after client approves. Client or Admin.
284func ReleaseFunds(cur realm, contractId string, milestoneIdx int) {
285	assertNotPaused()
286	caller := unsafe.PreviousRealm().Address()
287	c := getContract(contractId)
288
289	if c.Client != caller && caller != address(AdminAddress) {
290		panic("only client or admin can release")
291	}
292	// Only allow release when contract is Active. Disputed contracts are frozen
293	// until ResolveDispute/ClaimDisputeTimeout returns the contract to Active.
294	if c.Status != StatusActive {
295		panic("contract is " + string(c.Status) + " — cannot release funds during dispute")
296	}
297	if milestoneIdx < 0 || milestoneIdx >= len(c.Milestones) {
298		panic("invalid milestone index")
299	}
300
301	ms := &c.Milestones[milestoneIdx]
302	if ms.Status != MsCompleted {
303		panic("milestone not completed")
304	}
305
306	// Calculate fees
307	platformAmount := (ms.Amount * int64(PlatformFeePct)) / 100
308	freelancerAmount := ms.Amount - platformAmount
309
310	// STATE-BEFORE-SEND: update all state before any coin transfers
311	ms.Status = MsReleased
312	if allMilestonesReleased(c) {
313		c.Status = StatusCompleted
314	}
315	contracts.Set(contractId, c)
316
317	// Transfer funds
318	bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
319	realmAddr := unsafe.CurrentRealm().Address()
320
321	bnk.SendCoins(realmAddr, c.Freelancer, chain.Coins{chain.NewCoin("ugnot", freelancerAmount)})
322	if platformAmount > 0 {
323		bnk.SendCoins(realmAddr, address(FeeRecipient), chain.Coins{chain.NewCoin("ugnot", platformAmount)})
324	}
325
326	chain.Emit("FundsReleased",
327		"contractId", contractId,
328		"milestone", strconv.Itoa(milestoneIdx),
329		"freelancer", c.Freelancer.String(),
330		"amount", strconv.FormatInt(freelancerAmount, 10),
331		"fee", strconv.FormatInt(platformAmount, 10),
332	)
333}
334
335// ── Disputes ────────────────────────────────────────────────
336
337// RaiseDispute escalates a milestone to admin arbitration. Client or Freelancer.
338func RaiseDispute(cur realm, contractId string, milestoneIdx int) {
339	assertNotPaused()
340	caller := unsafe.PreviousRealm().Address()
341	c := getContract(contractId)
342
343	if c.Client != caller && c.Freelancer != caller {
344		panic("only client or freelancer can dispute")
345	}
346	if c.Status == StatusCancelled || c.Status == StatusCompleted {
347		panic("contract is " + string(c.Status))
348	}
349	if milestoneIdx < 0 || milestoneIdx >= len(c.Milestones) {
350		panic("invalid milestone index")
351	}
352
353	ms := &c.Milestones[milestoneIdx]
354	if ms.Status != MsFunded && ms.Status != MsCompleted {
355		panic("can only dispute funded or completed milestones")
356	}
357
358	// Capture pre-dispute status so ClaimDisputeTimeout can resolve fairly:
359	// if work was delivered (MsCompleted), pay freelancer; if not (MsFunded), refund client.
360	ms.PreDisputeStatus = ms.Status
361	ms.Status = MsDisputed
362	ms.DisputedAt = runtime.ChainHeight()
363	c.Status = StatusDisputed
364	contracts.Set(contractId, c)
365
366	chain.Emit("DisputeRaised",
367		"contractId", contractId,
368		"milestone", strconv.Itoa(milestoneIdx),
369		"raisedBy", caller.String(),
370	)
371}
372
373// ResolveDispute resolves a dispute. Admin only.
374// refundClient=true refunds to client, false pays freelancer.
375func ResolveDispute(cur realm, contractId string, milestoneIdx int, refundClient bool) {
376	caller := unsafe.PreviousRealm().Address()
377	if caller != address(AdminAddress) {
378		panic("only admin can resolve disputes")
379	}
380
381	c := getContract(contractId)
382	if milestoneIdx < 0 || milestoneIdx >= len(c.Milestones) {
383		panic("invalid milestone index")
384	}
385
386	ms := &c.Milestones[milestoneIdx]
387	if ms.Status != MsDisputed {
388		panic("milestone not in dispute")
389	}
390
391	// STATE-BEFORE-SEND: update state before transfers
392	if refundClient {
393		ms.Status = MsRefunded
394	} else {
395		ms.Status = MsReleased
396	}
397	// Reset contract status if no other milestones are disputed
398	c.Status = StatusActive
399	for _, m := range c.Milestones {
400		if m.Status == MsDisputed {
401			c.Status = StatusDisputed
402			break
403		}
404	}
405	if allMilestonesReleased(c) {
406		c.Status = StatusCompleted
407	}
408	contracts.Set(contractId, c)
409
410	bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
411	realmAddr := unsafe.CurrentRealm().Address()
412
413	if refundClient {
414		bnk.SendCoins(realmAddr, c.Client, chain.Coins{chain.NewCoin("ugnot", ms.Amount)})
415	} else {
416		platformAmount := (ms.Amount * int64(PlatformFeePct)) / 100
417		freelancerAmount := ms.Amount - platformAmount
418		bnk.SendCoins(realmAddr, c.Freelancer, chain.Coins{chain.NewCoin("ugnot", freelancerAmount)})
419		if platformAmount > 0 {
420			bnk.SendCoins(realmAddr, address(FeeRecipient), chain.Coins{chain.NewCoin("ugnot", platformAmount)})
421		}
422	}
423
424	resolution := "released-to-freelancer"
425	if refundClient {
426		resolution = "refunded-to-client"
427	}
428	chain.Emit("DisputeResolved",
429		"contractId", contractId,
430		"milestone", strconv.Itoa(milestoneIdx),
431		"resolution", resolution,
432	)
433}
434
435// ── Cancellation ────────────────────────────────────────────
436
437// CancelContract cancels an active contract. Client only.
438// Funded milestones are refunded minus cancellation fee.
439func CancelContract(cur realm, contractId string) {
440	assertNotPaused()
441	caller := unsafe.PreviousRealm().Address()
442	c := getContract(contractId)
443
444	if c.Client != caller {
445		panic("only client can cancel")
446	}
447	if c.Status != StatusActive {
448		panic("contract not active")
449	}
450
451	// STATE-BEFORE-SEND: update all state before transfers.
452	// Track which milestones are NEWLY transitioned so we only pay those,
453	// preventing double-refund of milestones already resolved via ResolveDispute.
454	c.Status = StatusCancelled
455	var newlyRefunded []int
456	var newlyReleased []int
457	for i := range c.Milestones {
458		if c.Milestones[i].Status == MsFunded {
459			c.Milestones[i].Status = MsRefunded
460			newlyRefunded = append(newlyRefunded, i)
461		} else if c.Milestones[i].Status == MsCompleted {
462			c.Milestones[i].Status = MsReleased
463			newlyReleased = append(newlyReleased, i)
464		}
465		// Already-terminal milestones (MsRefunded from ResolveDispute, MsReleased from
466		// ReleaseFunds) are NOT added to the payment lists — their funds were already
467		// distributed in the original operation.
468	}
469	contracts.Set(contractId, c)
470
471	bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
472	realmAddr := unsafe.CurrentRealm().Address()
473
474	for _, i := range newlyRefunded {
475		ms := c.Milestones[i]
476		// Refund funded milestones minus cancellation fee
477		fee := (ms.Amount * int64(CancelFeePct)) / 100
478		refund := ms.Amount - fee
479		if refund > 0 {
480			bnk.SendCoins(realmAddr, c.Client, chain.Coins{chain.NewCoin("ugnot", refund)})
481		}
482		// Cancellation fee goes to freelancer as compensation for lost opportunity
483		if fee > 0 {
484			bnk.SendCoins(realmAddr, c.Freelancer, chain.Coins{chain.NewCoin("ugnot", fee)})
485		}
486	}
487	for _, i := range newlyReleased {
488		ms := c.Milestones[i]
489		// Pay freelancer for completed work (full amount minus platform fee)
490		platformAmount := (ms.Amount * int64(PlatformFeePct)) / 100
491		freelancerAmount := ms.Amount - platformAmount
492		bnk.SendCoins(realmAddr, c.Freelancer, chain.Coins{chain.NewCoin("ugnot", freelancerAmount)})
493		if platformAmount > 0 {
494			bnk.SendCoins(realmAddr, address(FeeRecipient), chain.Coins{chain.NewCoin("ugnot", platformAmount)})
495		}
496	}
497}
498
499// ── Timeouts (permissionless) ───────────────────────────────
500
501// ClaimRefund refunds a funded milestone that has timed out.
502// Anyone can call — permissionless, prevents fund locking.
503func ClaimRefund(cur realm, contractId string, milestoneIdx int) {
504	c := getContract(contractId)
505
506	if milestoneIdx < 0 || milestoneIdx >= len(c.Milestones) {
507		panic("invalid milestone index")
508	}
509
510	ms := &c.Milestones[milestoneIdx]
511	if ms.Status != MsFunded {
512		panic("milestone not funded")
513	}
514	if ms.FundedAt == 0 {
515		panic("milestone has no funding record")
516	}
517
518	elapsed := runtime.ChainHeight() - ms.FundedAt
519	if elapsed < AutoRefundBlks {
520		panic(ufmt.Sprintf("too early: %d blocks remaining", AutoRefundBlks-elapsed))
521	}
522
523	// STATE-BEFORE-SEND
524	ms.Status = MsRefunded
525	// Update contract status if all milestones are now terminal
526	if allMilestonesTerminal(c) {
527		c.Status = StatusCancelled
528	}
529	contracts.Set(contractId, c)
530
531	bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
532	bnk.SendCoins(
533		unsafe.CurrentRealm().Address(),
534		c.Client,
535		chain.Coins{chain.NewCoin("ugnot", ms.Amount)},
536	)
537}
538
539// ClaimDisputeTimeout auto-resolves a dispute that admin hasn't acted on after
540// AutoResolveBlks (~28 days). Resolution follows the pre-dispute status:
541//   - If the milestone was MsCompleted (freelancer delivered work) before dispute,
542//     funds go to freelancer (minus platform fee). This prevents griefing where
543//     a client disputes after delivery and simply waits out the clock.
544//   - If the milestone was MsFunded (work not yet delivered) before dispute,
545//     funds are refunded to client.
546// Anyone can call (permissionless safety valve).
547func ClaimDisputeTimeout(cur realm, contractId string, milestoneIdx int) {
548	c := getContract(contractId)
549
550	if milestoneIdx < 0 || milestoneIdx >= len(c.Milestones) {
551		panic("invalid milestone index")
552	}
553
554	ms := &c.Milestones[milestoneIdx]
555	if ms.Status != MsDisputed {
556		panic("milestone not in dispute")
557	}
558	if ms.DisputedAt == 0 {
559		panic("milestone has no dispute record")
560	}
561
562	elapsed := runtime.ChainHeight() - ms.DisputedAt
563	if elapsed < AutoResolveBlks {
564		panic(ufmt.Sprintf("too early: %d blocks remaining", AutoResolveBlks-elapsed))
565	}
566
567	// Resolve based on pre-dispute status — fair to both parties.
568	payFreelancer := ms.PreDisputeStatus == MsCompleted
569
570	// STATE-BEFORE-SEND
571	if payFreelancer {
572		ms.Status = MsReleased
573	} else {
574		ms.Status = MsRefunded
575	}
576	// Reset contract status
577	c.Status = StatusActive
578	for _, m := range c.Milestones {
579		if m.Status == MsDisputed {
580			c.Status = StatusDisputed
581			break
582		}
583	}
584	if allMilestonesReleased(c) {
585		c.Status = StatusCompleted
586	} else if allMilestonesTerminal(c) {
587		c.Status = StatusCancelled
588	}
589	contracts.Set(contractId, c)
590
591	bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
592	realmAddr := unsafe.CurrentRealm().Address()
593
594	if payFreelancer {
595		// Work was delivered — pay freelancer minus platform fee
596		platformAmount := (ms.Amount * int64(PlatformFeePct)) / 100
597		freelancerAmount := ms.Amount - platformAmount
598		bnk.SendCoins(realmAddr, c.Freelancer, chain.Coins{chain.NewCoin("ugnot", freelancerAmount)})
599		if platformAmount > 0 {
600			bnk.SendCoins(realmAddr, address(FeeRecipient), chain.Coins{chain.NewCoin("ugnot", platformAmount)})
601		}
602		chain.Emit("DisputeTimedOut",
603			"contractId", contractId,
604			"milestone", strconv.Itoa(milestoneIdx),
605			"resolution", "paid-freelancer-work-delivered",
606		)
607	} else {
608		// Work never delivered — refund client in full
609		bnk.SendCoins(realmAddr, c.Client, chain.Coins{chain.NewCoin("ugnot", ms.Amount)})
610		chain.Emit("DisputeTimedOut",
611			"contractId", contractId,
612			"milestone", strconv.Itoa(milestoneIdx),
613			"resolution", "refunded-client-no-delivery",
614		)
615	}
616}
617
618// ── Render ───────────────────────────────────────────────────
619
620func Render(path string) string {
621	if path == "" {
622		return renderHome()
623	}
624	if strings.HasPrefix(path, "contract/") {
625		id := strings.TrimPrefix(path, "contract/")
626		return renderContract(id)
627	}
628	if path == "stats" {
629		return renderStats()
630	}
631	return "# 404\nNot found: " + path
632}
633
634func renderHome() string {
635	var sb strings.Builder
636	sb.WriteString("# Escrow Contracts\n\n")
637	sb.WriteString("Milestone-based escrow for freelance services on Gno.\n\n")
638
639	if contracts.Size() == 0 {
640		sb.WriteString("*No contracts yet.*\n")
641		return sb.String()
642	}
643
644	sb.WriteString("| ID | Title | Client | Freelancer | Status | Total |\n")
645	sb.WriteString("| --- | --- | --- | --- | --- | --- |\n")
646
647	contracts.Iterate("", "", func(key string, value interface{}) bool {
648		c := value.(*Contract)
649		total := int64(0)
650		for _, ms := range c.Milestones {
651			total += ms.Amount
652		}
653		sb.WriteString(ufmt.Sprintf("| %s | [%s](:contract/%s) | %s | %s | %s | %d ugnot |\n",
654			c.ID, c.Title, c.ID, truncAddr(c.Client), truncAddr(c.Freelancer),
655			string(c.Status), total))
656		return false
657	})
658
659	return sb.String()
660}
661
662func renderContract(id string) string {
663	val, exists := contracts.Get(id)
664	if !exists {
665		return "# 404\nContract not found: " + id
666	}
667	c := val.(*Contract)
668
669	var sb strings.Builder
670	sb.WriteString("# " + c.Title + "\n\n")
671	if len(c.Description) > 0 {
672		sb.WriteString(c.Description + "\n\n")
673	}
674	sb.WriteString("**ID:** " + c.ID + "\n")
675	sb.WriteString("**Client:** " + c.Client.String() + "\n")
676	sb.WriteString("**Freelancer:** " + c.Freelancer.String() + "\n")
677	sb.WriteString("**Status:** " + string(c.Status) + "\n")
678	sb.WriteString("**Created:** block " + strconv.FormatInt(c.CreatedAt, 10) + "\n\n")
679
680	total := int64(0)
681	for _, ms := range c.Milestones {
682		total += ms.Amount
683	}
684	sb.WriteString("**Total Value:** " + strconv.FormatInt(total, 10) + " ugnot\n\n")
685
686	sb.WriteString("## Milestones\n\n")
687	for _, ms := range c.Milestones {
688		sb.WriteString(ufmt.Sprintf("- **%s** — %d ugnot [%s]",
689			ms.Title, ms.Amount, string(ms.Status)))
690		if ms.FundedAt > 0 {
691			sb.WriteString(ufmt.Sprintf(" (funded block %d)", ms.FundedAt))
692		}
693		if ms.CompletedAt > 0 {
694			sb.WriteString(ufmt.Sprintf(" (completed block %d)", ms.CompletedAt))
695		}
696		if ms.DisputedAt > 0 {
697			sb.WriteString(ufmt.Sprintf(" (disputed block %d)", ms.DisputedAt))
698		}
699		sb.WriteString("\n")
700	}
701
702	return sb.String()
703}
704
705func renderStats() string {
706	var sb strings.Builder
707	sb.WriteString("# Escrow Stats\n\n")
708
709	totalContracts := contracts.Size()
710	active, completed, disputed, cancelled := 0, 0, 0, 0
711	totalValue := int64(0)
712
713	contracts.Iterate("", "", func(key string, value interface{}) bool {
714		c := value.(*Contract)
715		switch c.Status {
716		case StatusActive:
717			active++
718		case StatusCompleted:
719			completed++
720		case StatusDisputed:
721			disputed++
722		case StatusCancelled:
723			cancelled++
724		}
725		for _, ms := range c.Milestones {
726			totalValue += ms.Amount
727		}
728		return false
729	})
730
731	sb.WriteString(ufmt.Sprintf("**Total Contracts:** %d\n", totalContracts))
732	sb.WriteString(ufmt.Sprintf("**Active:** %d | **Completed:** %d | **Disputed:** %d | **Cancelled:** %d\n", active, completed, disputed, cancelled))
733	sb.WriteString(ufmt.Sprintf("**Total Value:** %d ugnot\n", totalValue))
734
735	return sb.String()
736}
737
738// ── Helpers ──────────────────────────────────────────────────
739
740func getContract(id string) *Contract {
741	val, exists := contracts.Get(id)
742	if !exists {
743		panic("contract not found: " + id)
744	}
745	return val.(*Contract)
746}
747
748func allMilestonesReleased(c *Contract) bool {
749	for _, m := range c.Milestones {
750		if m.Status != MsReleased {
751			return false
752		}
753	}
754	return true
755}
756
757// allMilestonesTerminal returns true if every milestone is in a final state
758// (released, refunded, or pending — pending with no funds is terminal).
759func allMilestonesTerminal(c *Contract) bool {
760	for _, m := range c.Milestones {
761		if m.Status == MsFunded || m.Status == MsCompleted || m.Status == MsDisputed {
762			return false
763		}
764	}
765	return true
766}
767
768// parseMilestones parses "title1:amount1,title2:amount2" into Milestone slice.
769// Invalid entries cause a panic (not silently skipped).
770func parseMilestones(input string) []Milestone {
771	var result []Milestone
772	parts := strings.Split(input, ",")
773	for i, p := range parts {
774		p = strings.TrimSpace(p)
775		if len(p) == 0 {
776			continue
777		}
778		kv := strings.SplitN(p, ":", 2)
779		if len(kv) != 2 {
780			panic(ufmt.Sprintf("invalid milestone format at position %d: expected 'title:amount'", i))
781		}
782		title := strings.TrimSpace(kv[0])
783		if len(title) == 0 {
784			panic(ufmt.Sprintf("empty milestone title at position %d", i))
785		}
786		if len(title) > MaxTitleLen {
787			panic(ufmt.Sprintf("milestone title too long at position %d: %d/%d chars", i, len(title), MaxTitleLen))
788		}
789		title = sanitizeMilestoneTitle(title)
790		amount, err := strconv.ParseInt(strings.TrimSpace(kv[1]), 10, 64)
791		if err != nil || amount <= 0 {
792			panic(ufmt.Sprintf("invalid milestone amount at position %d: must be positive integer", i))
793		}
794		// Minimum milestone amount prevents fee evasion via integer truncation.
795		// At 2% fee, amounts < 50 ugnot would pay 0 fee.
796		if amount < MinMilestoneAmount {
797			panic(ufmt.Sprintf("milestone amount too small at position %d: minimum %d ugnot", i, MinMilestoneAmount))
798		}
799		result = append(result, Milestone{
800			ID:     i,
801			Title:  title,
802			Amount: amount,
803			Status: MsPending,
804		})
805	}
806	return result
807}
808
809func truncAddr(addr address) string {
810	s := addr.String()
811	if len(s) > 13 {
812		return s[:10] + "..."
813	}
814	return s
815}
816
817// sanitizeMilestoneTitle strips markdown special characters to prevent injection
818// in gnoweb Render output.
819func sanitizeMilestoneTitle(s string) string {
820	var out strings.Builder
821	for _, c := range s {
822		switch c {
823		case '[', ']', '(', ')', '#', '*', '`', '!', '<', '>', '|', '\\', '_', '~', '\n', '\r', '\t':
824			continue // strip markdown-sensitive characters and control whitespace
825		default:
826			out.WriteRune(c)
827		}
828	}
829	return out.String()
830}