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}