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