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

escrow.gno

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