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

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}