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