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