Search Apps Documentation Source Content File Folder Download Copy Actions Download

escrow.gno

25.73 Kb · 842 lines
  1package escrow_v3
  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
209	// P0 fund-guard: a payable entrypoint MUST be a direct user call. unsafe.OriginSend()
210	// reports the tx-level `--send`, credited to the DIRECT message target realm — not to
211	// an inner realm reached via a cross-call. Without this guard an attacker funds a
212	// milestone through their own intermediary realm (coins land there), the escrow marks
213	// the milestone funded though it received nothing, then the attacker extracts real
214	// pooled client funds via ReleaseFunds/ClaimRefund. Requiring a direct user call forces
215	// this realm to be the message target, the one case where the OriginSend coins are its own.
216	if !unsafe.PreviousRealm().IsUserCall() {
217		panic("FundMilestone must be a direct user call")
218	}
219
220	caller := unsafe.PreviousRealm().Address()
221	c := getContract(contractId)
222
223	if c.Client != caller {
224		panic("only client can fund")
225	}
226	if c.Status != StatusActive {
227		panic("contract not active")
228	}
229	if milestoneIdx < 0 || milestoneIdx >= len(c.Milestones) {
230		panic("invalid milestone index")
231	}
232
233	ms := &c.Milestones[milestoneIdx]
234	if ms.Status != MsPending {
235		panic("milestone already funded or processed")
236	}
237
238	// Verify coins sent (accumulate to handle multi-entry defensively)
239	sent := unsafe.OriginSend()
240	sentAmount := int64(0)
241	for _, coin := range sent {
242		if coin.Denom == "ugnot" {
243			sentAmount += coin.Amount
244		}
245	}
246	if sentAmount != ms.Amount {
247		panic(ufmt.Sprintf("must send exactly %d ugnot (sent %d)", ms.Amount, sentAmount))
248	}
249
250	ms.Status = MsFunded
251	ms.FundedAt = runtime.ChainHeight()
252	contracts.Set(contractId, c)
253
254	chain.Emit("MilestoneFunded",
255		"contractId", contractId,
256		"milestone", strconv.Itoa(milestoneIdx),
257		"amount", strconv.FormatInt(ms.Amount, 10),
258	)
259}
260
261// CompleteMilestone marks a milestone as completed. Freelancer only.
262func CompleteMilestone(cur realm, contractId string, milestoneIdx int) {
263	assertNotPaused()
264	caller := unsafe.PreviousRealm().Address()
265	c := getContract(contractId)
266
267	if c.Freelancer != caller {
268		panic("only freelancer can mark complete")
269	}
270	// Only allow completion when contract is Active. Disputed contracts are frozen
271	// until ResolveDispute/ClaimDisputeTimeout returns the contract to Active.
272	if c.Status != StatusActive {
273		panic("contract is " + string(c.Status) + " — cannot complete milestones during dispute")
274	}
275	if milestoneIdx < 0 || milestoneIdx >= len(c.Milestones) {
276		panic("invalid milestone index")
277	}
278
279	ms := &c.Milestones[milestoneIdx]
280	if ms.Status != MsFunded {
281		panic("milestone not funded")
282	}
283
284	ms.Status = MsCompleted
285	ms.CompletedAt = runtime.ChainHeight()
286	contracts.Set(contractId, c)
287
288	chain.Emit("MilestoneCompleted",
289		"contractId", contractId,
290		"milestone", strconv.Itoa(milestoneIdx),
291		"freelancer", caller.String(),
292	)
293}
294
295// ReleaseFunds releases funds to freelancer after client approves. Client or Admin.
296func ReleaseFunds(cur realm, contractId string, milestoneIdx int) {
297	assertNotPaused()
298	caller := unsafe.PreviousRealm().Address()
299	c := getContract(contractId)
300
301	if c.Client != caller && caller != address(AdminAddress) {
302		panic("only client or admin can release")
303	}
304	// Only allow release when contract is Active. Disputed contracts are frozen
305	// until ResolveDispute/ClaimDisputeTimeout returns the contract to Active.
306	if c.Status != StatusActive {
307		panic("contract is " + string(c.Status) + " — cannot release funds during dispute")
308	}
309	if milestoneIdx < 0 || milestoneIdx >= len(c.Milestones) {
310		panic("invalid milestone index")
311	}
312
313	ms := &c.Milestones[milestoneIdx]
314	if ms.Status != MsCompleted {
315		panic("milestone not completed")
316	}
317
318	// Calculate fees
319	platformAmount := (ms.Amount * int64(PlatformFeePct)) / 100
320	freelancerAmount := ms.Amount - platformAmount
321
322	// STATE-BEFORE-SEND: update all state before any coin transfers
323	ms.Status = MsReleased
324	if allMilestonesReleased(c) {
325		c.Status = StatusCompleted
326	}
327	contracts.Set(contractId, c)
328
329	// Transfer funds
330	bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
331	realmAddr := unsafe.CurrentRealm().Address()
332
333	bnk.SendCoins(realmAddr, c.Freelancer, chain.Coins{chain.NewCoin("ugnot", freelancerAmount)})
334	if platformAmount > 0 {
335		bnk.SendCoins(realmAddr, address(FeeRecipient), chain.Coins{chain.NewCoin("ugnot", platformAmount)})
336	}
337
338	chain.Emit("FundsReleased",
339		"contractId", contractId,
340		"milestone", strconv.Itoa(milestoneIdx),
341		"freelancer", c.Freelancer.String(),
342		"amount", strconv.FormatInt(freelancerAmount, 10),
343		"fee", strconv.FormatInt(platformAmount, 10),
344	)
345}
346
347// ── Disputes ────────────────────────────────────────────────
348
349// RaiseDispute escalates a milestone to admin arbitration. Client or Freelancer.
350func RaiseDispute(cur realm, contractId string, milestoneIdx int) {
351	assertNotPaused()
352	caller := unsafe.PreviousRealm().Address()
353	c := getContract(contractId)
354
355	if c.Client != caller && c.Freelancer != caller {
356		panic("only client or freelancer can dispute")
357	}
358	if c.Status == StatusCancelled || c.Status == StatusCompleted {
359		panic("contract is " + string(c.Status))
360	}
361	if milestoneIdx < 0 || milestoneIdx >= len(c.Milestones) {
362		panic("invalid milestone index")
363	}
364
365	ms := &c.Milestones[milestoneIdx]
366	if ms.Status != MsFunded && ms.Status != MsCompleted {
367		panic("can only dispute funded or completed milestones")
368	}
369
370	// Capture pre-dispute status so ClaimDisputeTimeout can resolve fairly:
371	// if work was delivered (MsCompleted), pay freelancer; if not (MsFunded), refund client.
372	ms.PreDisputeStatus = ms.Status
373	ms.Status = MsDisputed
374	ms.DisputedAt = runtime.ChainHeight()
375	c.Status = StatusDisputed
376	contracts.Set(contractId, c)
377
378	chain.Emit("DisputeRaised",
379		"contractId", contractId,
380		"milestone", strconv.Itoa(milestoneIdx),
381		"raisedBy", caller.String(),
382	)
383}
384
385// ResolveDispute resolves a dispute. Admin only.
386// refundClient=true refunds to client, false pays freelancer.
387func ResolveDispute(cur realm, contractId string, milestoneIdx int, refundClient bool) {
388	caller := unsafe.PreviousRealm().Address()
389	if caller != address(AdminAddress) {
390		panic("only admin can resolve disputes")
391	}
392
393	c := getContract(contractId)
394	if milestoneIdx < 0 || milestoneIdx >= len(c.Milestones) {
395		panic("invalid milestone index")
396	}
397
398	ms := &c.Milestones[milestoneIdx]
399	if ms.Status != MsDisputed {
400		panic("milestone not in dispute")
401	}
402
403	// STATE-BEFORE-SEND: update state before transfers
404	if refundClient {
405		ms.Status = MsRefunded
406	} else {
407		ms.Status = MsReleased
408	}
409	// Reset contract status if no other milestones are disputed
410	c.Status = StatusActive
411	for _, m := range c.Milestones {
412		if m.Status == MsDisputed {
413			c.Status = StatusDisputed
414			break
415		}
416	}
417	if allMilestonesReleased(c) {
418		c.Status = StatusCompleted
419	}
420	contracts.Set(contractId, c)
421
422	bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
423	realmAddr := unsafe.CurrentRealm().Address()
424
425	if refundClient {
426		bnk.SendCoins(realmAddr, c.Client, chain.Coins{chain.NewCoin("ugnot", ms.Amount)})
427	} else {
428		platformAmount := (ms.Amount * int64(PlatformFeePct)) / 100
429		freelancerAmount := ms.Amount - platformAmount
430		bnk.SendCoins(realmAddr, c.Freelancer, chain.Coins{chain.NewCoin("ugnot", freelancerAmount)})
431		if platformAmount > 0 {
432			bnk.SendCoins(realmAddr, address(FeeRecipient), chain.Coins{chain.NewCoin("ugnot", platformAmount)})
433		}
434	}
435
436	resolution := "released-to-freelancer"
437	if refundClient {
438		resolution = "refunded-to-client"
439	}
440	chain.Emit("DisputeResolved",
441		"contractId", contractId,
442		"milestone", strconv.Itoa(milestoneIdx),
443		"resolution", resolution,
444	)
445}
446
447// ── Cancellation ────────────────────────────────────────────
448
449// CancelContract cancels an active contract. Client only.
450// Funded milestones are refunded minus cancellation fee.
451func CancelContract(cur realm, contractId string) {
452	assertNotPaused()
453	caller := unsafe.PreviousRealm().Address()
454	c := getContract(contractId)
455
456	if c.Client != caller {
457		panic("only client can cancel")
458	}
459	if c.Status != StatusActive {
460		panic("contract not active")
461	}
462
463	// STATE-BEFORE-SEND: update all state before transfers.
464	// Track which milestones are NEWLY transitioned so we only pay those,
465	// preventing double-refund of milestones already resolved via ResolveDispute.
466	c.Status = StatusCancelled
467	var newlyRefunded []int
468	var newlyReleased []int
469	for i := range c.Milestones {
470		if c.Milestones[i].Status == MsFunded {
471			c.Milestones[i].Status = MsRefunded
472			newlyRefunded = append(newlyRefunded, i)
473		} else if c.Milestones[i].Status == MsCompleted {
474			c.Milestones[i].Status = MsReleased
475			newlyReleased = append(newlyReleased, i)
476		}
477		// Already-terminal milestones (MsRefunded from ResolveDispute, MsReleased from
478		// ReleaseFunds) are NOT added to the payment lists — their funds were already
479		// distributed in the original operation.
480	}
481	contracts.Set(contractId, c)
482
483	bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
484	realmAddr := unsafe.CurrentRealm().Address()
485
486	for _, i := range newlyRefunded {
487		ms := c.Milestones[i]
488		// Refund funded milestones minus cancellation fee
489		fee := (ms.Amount * int64(CancelFeePct)) / 100
490		refund := ms.Amount - fee
491		if refund > 0 {
492			bnk.SendCoins(realmAddr, c.Client, chain.Coins{chain.NewCoin("ugnot", refund)})
493		}
494		// Cancellation fee goes to freelancer as compensation for lost opportunity
495		if fee > 0 {
496			bnk.SendCoins(realmAddr, c.Freelancer, chain.Coins{chain.NewCoin("ugnot", fee)})
497		}
498	}
499	for _, i := range newlyReleased {
500		ms := c.Milestones[i]
501		// Pay freelancer for completed work (full amount minus platform fee)
502		platformAmount := (ms.Amount * int64(PlatformFeePct)) / 100
503		freelancerAmount := ms.Amount - platformAmount
504		bnk.SendCoins(realmAddr, c.Freelancer, chain.Coins{chain.NewCoin("ugnot", freelancerAmount)})
505		if platformAmount > 0 {
506			bnk.SendCoins(realmAddr, address(FeeRecipient), chain.Coins{chain.NewCoin("ugnot", platformAmount)})
507		}
508	}
509}
510
511// ── Timeouts (permissionless) ───────────────────────────────
512
513// ClaimRefund refunds a funded milestone that has timed out.
514// Anyone can call — permissionless, prevents fund locking.
515func ClaimRefund(cur realm, contractId string, milestoneIdx int) {
516	c := getContract(contractId)
517
518	if milestoneIdx < 0 || milestoneIdx >= len(c.Milestones) {
519		panic("invalid milestone index")
520	}
521
522	ms := &c.Milestones[milestoneIdx]
523	if ms.Status != MsFunded {
524		panic("milestone not funded")
525	}
526	if ms.FundedAt == 0 {
527		panic("milestone has no funding record")
528	}
529
530	elapsed := runtime.ChainHeight() - ms.FundedAt
531	if elapsed < AutoRefundBlks {
532		panic(ufmt.Sprintf("too early: %d blocks remaining", AutoRefundBlks-elapsed))
533	}
534
535	// STATE-BEFORE-SEND
536	ms.Status = MsRefunded
537	// Update contract status if all milestones are now terminal
538	if allMilestonesTerminal(c) {
539		c.Status = StatusCancelled
540	}
541	contracts.Set(contractId, c)
542
543	bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
544	bnk.SendCoins(
545		unsafe.CurrentRealm().Address(),
546		c.Client,
547		chain.Coins{chain.NewCoin("ugnot", ms.Amount)},
548	)
549}
550
551// ClaimDisputeTimeout auto-resolves a dispute that admin hasn't acted on after
552// AutoResolveBlks (~28 days). Resolution follows the pre-dispute status:
553//   - If the milestone was MsCompleted (freelancer delivered work) before dispute,
554//     funds go to freelancer (minus platform fee). This prevents griefing where
555//     a client disputes after delivery and simply waits out the clock.
556//   - If the milestone was MsFunded (work not yet delivered) before dispute,
557//     funds are refunded to client.
558// Anyone can call (permissionless safety valve).
559func ClaimDisputeTimeout(cur realm, contractId string, milestoneIdx int) {
560	c := getContract(contractId)
561
562	if milestoneIdx < 0 || milestoneIdx >= len(c.Milestones) {
563		panic("invalid milestone index")
564	}
565
566	ms := &c.Milestones[milestoneIdx]
567	if ms.Status != MsDisputed {
568		panic("milestone not in dispute")
569	}
570	if ms.DisputedAt == 0 {
571		panic("milestone has no dispute record")
572	}
573
574	elapsed := runtime.ChainHeight() - ms.DisputedAt
575	if elapsed < AutoResolveBlks {
576		panic(ufmt.Sprintf("too early: %d blocks remaining", AutoResolveBlks-elapsed))
577	}
578
579	// Resolve based on pre-dispute status — fair to both parties.
580	payFreelancer := ms.PreDisputeStatus == MsCompleted
581
582	// STATE-BEFORE-SEND
583	if payFreelancer {
584		ms.Status = MsReleased
585	} else {
586		ms.Status = MsRefunded
587	}
588	// Reset contract status
589	c.Status = StatusActive
590	for _, m := range c.Milestones {
591		if m.Status == MsDisputed {
592			c.Status = StatusDisputed
593			break
594		}
595	}
596	if allMilestonesReleased(c) {
597		c.Status = StatusCompleted
598	} else if allMilestonesTerminal(c) {
599		c.Status = StatusCancelled
600	}
601	contracts.Set(contractId, c)
602
603	bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
604	realmAddr := unsafe.CurrentRealm().Address()
605
606	if payFreelancer {
607		// Work was delivered — pay freelancer minus platform fee
608		platformAmount := (ms.Amount * int64(PlatformFeePct)) / 100
609		freelancerAmount := ms.Amount - platformAmount
610		bnk.SendCoins(realmAddr, c.Freelancer, chain.Coins{chain.NewCoin("ugnot", freelancerAmount)})
611		if platformAmount > 0 {
612			bnk.SendCoins(realmAddr, address(FeeRecipient), chain.Coins{chain.NewCoin("ugnot", platformAmount)})
613		}
614		chain.Emit("DisputeTimedOut",
615			"contractId", contractId,
616			"milestone", strconv.Itoa(milestoneIdx),
617			"resolution", "paid-freelancer-work-delivered",
618		)
619	} else {
620		// Work never delivered — refund client in full
621		bnk.SendCoins(realmAddr, c.Client, chain.Coins{chain.NewCoin("ugnot", ms.Amount)})
622		chain.Emit("DisputeTimedOut",
623			"contractId", contractId,
624			"milestone", strconv.Itoa(milestoneIdx),
625			"resolution", "refunded-client-no-delivery",
626		)
627	}
628}
629
630// ── Render ───────────────────────────────────────────────────
631
632func Render(path string) string {
633	if path == "" {
634		return renderHome()
635	}
636	if strings.HasPrefix(path, "contract/") {
637		id := strings.TrimPrefix(path, "contract/")
638		return renderContract(id)
639	}
640	if path == "stats" {
641		return renderStats()
642	}
643	return "# 404\nNot found: " + path
644}
645
646func renderHome() string {
647	var sb strings.Builder
648	sb.WriteString("# Escrow Contracts\n\n")
649	sb.WriteString("Milestone-based escrow for freelance services on Gno.\n\n")
650
651	if contracts.Size() == 0 {
652		sb.WriteString("*No contracts yet.*\n")
653		return sb.String()
654	}
655
656	sb.WriteString("| ID | Title | Client | Freelancer | Status | Total |\n")
657	sb.WriteString("| --- | --- | --- | --- | --- | --- |\n")
658
659	contracts.Iterate("", "", func(key string, value interface{}) bool {
660		c := value.(*Contract)
661		total := int64(0)
662		for _, ms := range c.Milestones {
663			total += ms.Amount
664		}
665		sb.WriteString(ufmt.Sprintf("| %s | [%s](:contract/%s) | %s | %s | %s | %d ugnot |\n",
666			c.ID, c.Title, c.ID, truncAddr(c.Client), truncAddr(c.Freelancer),
667			string(c.Status), total))
668		return false
669	})
670
671	return sb.String()
672}
673
674func renderContract(id string) string {
675	val, exists := contracts.Get(id)
676	if !exists {
677		return "# 404\nContract not found: " + id
678	}
679	c := val.(*Contract)
680
681	var sb strings.Builder
682	sb.WriteString("# " + c.Title + "\n\n")
683	if len(c.Description) > 0 {
684		sb.WriteString(c.Description + "\n\n")
685	}
686	sb.WriteString("**ID:** " + c.ID + "\n")
687	sb.WriteString("**Client:** " + c.Client.String() + "\n")
688	sb.WriteString("**Freelancer:** " + c.Freelancer.String() + "\n")
689	sb.WriteString("**Status:** " + string(c.Status) + "\n")
690	sb.WriteString("**Created:** block " + strconv.FormatInt(c.CreatedAt, 10) + "\n\n")
691
692	total := int64(0)
693	for _, ms := range c.Milestones {
694		total += ms.Amount
695	}
696	sb.WriteString("**Total Value:** " + strconv.FormatInt(total, 10) + " ugnot\n\n")
697
698	sb.WriteString("## Milestones\n\n")
699	for _, ms := range c.Milestones {
700		sb.WriteString(ufmt.Sprintf("- **%s** — %d ugnot [%s]",
701			ms.Title, ms.Amount, string(ms.Status)))
702		if ms.FundedAt > 0 {
703			sb.WriteString(ufmt.Sprintf(" (funded block %d)", ms.FundedAt))
704		}
705		if ms.CompletedAt > 0 {
706			sb.WriteString(ufmt.Sprintf(" (completed block %d)", ms.CompletedAt))
707		}
708		if ms.DisputedAt > 0 {
709			sb.WriteString(ufmt.Sprintf(" (disputed block %d)", ms.DisputedAt))
710		}
711		sb.WriteString("\n")
712	}
713
714	return sb.String()
715}
716
717func renderStats() string {
718	var sb strings.Builder
719	sb.WriteString("# Escrow Stats\n\n")
720
721	totalContracts := contracts.Size()
722	active, completed, disputed, cancelled := 0, 0, 0, 0
723	totalValue := int64(0)
724
725	contracts.Iterate("", "", func(key string, value interface{}) bool {
726		c := value.(*Contract)
727		switch c.Status {
728		case StatusActive:
729			active++
730		case StatusCompleted:
731			completed++
732		case StatusDisputed:
733			disputed++
734		case StatusCancelled:
735			cancelled++
736		}
737		for _, ms := range c.Milestones {
738			totalValue += ms.Amount
739		}
740		return false
741	})
742
743	sb.WriteString(ufmt.Sprintf("**Total Contracts:** %d\n", totalContracts))
744	sb.WriteString(ufmt.Sprintf("**Active:** %d | **Completed:** %d | **Disputed:** %d | **Cancelled:** %d\n", active, completed, disputed, cancelled))
745	sb.WriteString(ufmt.Sprintf("**Total Value:** %d ugnot\n", totalValue))
746
747	return sb.String()
748}
749
750// ── Helpers ──────────────────────────────────────────────────
751
752func getContract(id string) *Contract {
753	val, exists := contracts.Get(id)
754	if !exists {
755		panic("contract not found: " + id)
756	}
757	return val.(*Contract)
758}
759
760func allMilestonesReleased(c *Contract) bool {
761	for _, m := range c.Milestones {
762		if m.Status != MsReleased {
763			return false
764		}
765	}
766	return true
767}
768
769// allMilestonesTerminal returns true if every milestone is in a final state
770// (released, refunded, or pending — pending with no funds is terminal).
771func allMilestonesTerminal(c *Contract) bool {
772	for _, m := range c.Milestones {
773		if m.Status == MsFunded || m.Status == MsCompleted || m.Status == MsDisputed {
774			return false
775		}
776	}
777	return true
778}
779
780// parseMilestones parses "title1:amount1,title2:amount2" into Milestone slice.
781// Invalid entries cause a panic (not silently skipped).
782func parseMilestones(input string) []Milestone {
783	var result []Milestone
784	parts := strings.Split(input, ",")
785	for i, p := range parts {
786		p = strings.TrimSpace(p)
787		if len(p) == 0 {
788			continue
789		}
790		kv := strings.SplitN(p, ":", 2)
791		if len(kv) != 2 {
792			panic(ufmt.Sprintf("invalid milestone format at position %d: expected 'title:amount'", i))
793		}
794		title := strings.TrimSpace(kv[0])
795		if len(title) == 0 {
796			panic(ufmt.Sprintf("empty milestone title at position %d", i))
797		}
798		if len(title) > MaxTitleLen {
799			panic(ufmt.Sprintf("milestone title too long at position %d: %d/%d chars", i, len(title), MaxTitleLen))
800		}
801		title = sanitizeMilestoneTitle(title)
802		amount, err := strconv.ParseInt(strings.TrimSpace(kv[1]), 10, 64)
803		if err != nil || amount <= 0 {
804			panic(ufmt.Sprintf("invalid milestone amount at position %d: must be positive integer", i))
805		}
806		// Minimum milestone amount prevents fee evasion via integer truncation.
807		// At 2% fee, amounts < 50 ugnot would pay 0 fee.
808		if amount < MinMilestoneAmount {
809			panic(ufmt.Sprintf("milestone amount too small at position %d: minimum %d ugnot", i, MinMilestoneAmount))
810		}
811		result = append(result, Milestone{
812			ID:     i,
813			Title:  title,
814			Amount: amount,
815			Status: MsPending,
816		})
817	}
818	return result
819}
820
821func truncAddr(addr address) string {
822	s := addr.String()
823	if len(s) > 13 {
824		return s[:10] + "..."
825	}
826	return s
827}
828
829// sanitizeMilestoneTitle strips markdown special characters to prevent injection
830// in gnoweb Render output.
831func sanitizeMilestoneTitle(s string) string {
832	var out strings.Builder
833	for _, c := range s {
834		switch c {
835		case '[', ']', '(', ')', '#', '*', '`', '!', '<', '>', '|', '\\', '_', '~', '\n', '\r', '\t':
836			continue // strip markdown-sensitive characters and control whitespace
837		default:
838			out.WriteRune(c)
839		}
840	}
841	return out.String()
842}