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