Realm
gno.land/r/samcrew/escrow_v4
Overview
Realm Path
gno.land/r/samcrew/escrow_v4
Exported Functions
38
State Entries
50
Source Files
4
Total Package Entries
131
Exported Functions
38 exported functions
State
50 state entries
Source Code
FILES
escrow.gno
go
1package escrow_v4
2
3// Milestone-based escrow: on-chain freelance service contracts for Memba.
4// Successor of escrow_v3 with the same user API; see DESIGN.md for what
5// changed and why.
6//
7// Flow: CreateContract → FundMilestone → CompleteMilestone → ReleaseFunds
8// Disputes: RaiseDispute → admin ResolveDispute (or ClaimDisputeTimeout after
9// AutoResolveBlks)
10// Timeouts: ClaimRefund (a funded milestone nobody completed, after
11// AutoRefundBlks); ExpireUnfunded (a contract never funded, after
12// UnfundedExpiryBlks)
13// Cleanup: ArchiveContract (client deletes a settled contract; the chain
14// refunds the freed storage deposit to the signer)
15//
16// Invariants:
17// - State is updated before any coins are sent.
18// - realm balance >= TotalLiabilities() (NF-2).
19// - A contract is open until it is completed or cancelled; an open contract
20// holds one of its client's MaxActivePerClient slots.
21// - completed and cancelled are absorbing and hold no escrowed funds.
22//
23// Caller authentication: every crossing entrypoint checks cur.IsCurrent() and
24// reads the caller from cur.Previous(). Every use of the unsafe runtime
25// package is in origin.gno. Read-only views are in views.gno.
26
27import (
28 "chain"
29 "chain/banker"
30 "chain/runtime"
31 "strconv"
32 "strings"
33 "unicode"
34 "unicode/utf8"
35
36 "gno.land/p/nt/ufmt/v0"
37 "gno.land/p/samcrew/avl"
38 cfg "gno.land/r/samcrew/memba_market_config"
39)
40
41// ── Constants ────────────────────────────────────────────────
42
43// laneService is this engine's lane key into the DAO fee spine
44// (memba_market_config), seeded there at 200 bps.
45const laneService = "service"
46
47const (
48 // PlatformFeePct is only the fail-safe fallback rate (see resolveFee); the
49 // live rate is cfg.GetFeeBPS(laneService), read at each payout.
50 PlatformFeePct = 2
51 // CancelFeePct is paid to the freelancer from each funded milestone when
52 // the client cancels. Escrow-internal, not a protocol fee.
53 CancelFeePct = 5
54 // Timeouts, in blocks, as in v3 (sized at 3 s per block; at gnoland-1's
55 // ~3.3 s they are ~33 and ~31 days). Blocks inside a pause's blocking
56 // window do not count (see the pause section).
57 AutoRefundBlks = int64(864000)
58 AutoResolveBlks = int64(806400)
59 // UnfundedExpiryBlks: a contract none of whose milestones was ever funded
60 // can be cancelled by anyone after this many blocks, freeing its slot.
61 UnfundedExpiryBlks = AutoRefundBlks
62
63 MaxTitleLen = 200 // bytes, after sanitising (also bounds the input)
64 MaxDescLen = 5000 // bytes, after sanitising (also bounds the input)
65 MaxMilestones = 20
66 MinMilestoneAmount = int64(1000) // 0.001 GNOT: below ~50 ugnot a 2% fee truncates to 0
67 // maxMilestonesArgLen bounds the raw milestones argument before parsing:
68 // 20 entries of a 200-byte title, a 19-digit amount and separators, with
69 // room for surrounding spaces.
70 maxMilestonesArgLen = MaxMilestones * (MaxTitleLen + 64)
71
72 // MaxActivePerClient bounds the open contracts one client address may
73 // hold. It counts only contracts the address created, never those naming
74 // it as freelancer, so nobody can use up someone else's slots, and it
75 // bounds what one key can lock (five of the largest contracts are about
76 // 15 GNOT of storage deposit). There is no global cap: nothing iterates
77 // over every contract, so a global number would bound no gas and would
78 // only give a funded attacker a switch to turn escrow off for everyone.
79 MaxActivePerClient = 5
80)
81
82// ── Types ────────────────────────────────────────────────────
83
84type ContractStatus string
85
86const (
87 StatusActive ContractStatus = "active"
88 StatusCompleted ContractStatus = "completed"
89 StatusDisputed ContractStatus = "disputed"
90 StatusCancelled ContractStatus = "cancelled"
91)
92
93type MilestoneStatus string
94
95const (
96 MsPending MilestoneStatus = "pending"
97 MsFunded MilestoneStatus = "funded"
98 MsCompleted MilestoneStatus = "completed"
99 MsReleased MilestoneStatus = "released"
100 MsDisputed MilestoneStatus = "disputed"
101 MsRefunded MilestoneStatus = "refunded"
102)
103
104type Contract struct {
105 ID string
106 Client address
107 Freelancer address
108 Title string
109 Description string
110 Status ContractStatus
111 CreatedAt int64 // block height
112 Milestones []Milestone
113
114 createdPauseMark int64 // pausedBlocksAt(CreatedAt), for UnfundedExpiryBlks
115}
116
117type Milestone struct {
118 ID int // equals the milestone's index
119 Title string
120 Amount int64 // ugnot
121 Status MilestoneStatus
122 FundedAt int64 // block height (0 if not funded)
123 CompletedAt int64 // block height (0 if not completed)
124 DisputedAt int64 // block height (0 if not disputed)
125 PreDisputeStatus MilestoneStatus // status before dispute (MsFunded or MsCompleted)
126
127 fundedPauseMark int64 // pausedBlocksAt(FundedAt), for AutoRefundBlks
128 disputedPauseMark int64 // pausedBlocksAt(DisputedAt), for AutoResolveBlks
129}
130
131// ── State ────────────────────────────────────────────────────
132
133var (
134 contracts *avl.Tree // id -> *Contract; live (not archived) contracts only
135 clientSlots *avl.Tree // client address -> int open contracts (removed on archive once 0); "c/<client>/<id>" -> nil client index
136 nextID int // monotonic, never reused
137 activeCount int // open contracts
138 archivedCount int
139 totalLiable int64 // NF-2: ugnot owed to funded, completed and disputed milestones
140)
141
142func init() {
143 contracts = avl.NewTree()
144 clientSlots = avl.NewTree()
145}
146
147// resolveFee reads the "service" lane fee (bps) and treasury from the DAO fee
148// spine at payout time. The config getters are pure and non-failing; on an
149// out-of-range rate or an empty or malformed treasury this falls back to the
150// frozen rate and the realm's fee recipient rather than reverting a payout, so
151// a config mistake never strands escrowed funds.
152func resolveFee() (int64, address) {
153 bps := int64(cfg.GetFeeBPS(laneService))
154 if bps < 0 || bps > cfg.MaxFeeBPS {
155 bps = int64(PlatformFeePct) * 100
156 }
157 treasury := cfg.GetTreasury()
158 if treasury == "" || !treasury.IsValid() {
159 treasury = feeRecipient
160 }
161 return bps, treasury
162}
163
164// callerOf returns the immediate caller of a crossing entrypoint.
165func callerOf(cur realm) address {
166 if !cur.IsCurrent() {
167 panic("spoofed realm")
168 }
169 return cur.Previous().Address()
170}
171
172// ── Contract lifecycle ───────────────────────────────────────
173
174// CreateContract creates a new escrow contract with milestones, as the
175// caller's client. Milestones format: "title1:amount1,title2:amount2".
176// The caller must be a user (a direct call or the user's own run script), so a
177// realm cannot mint client identities with cur.Sub to hold slots.
178func CreateContract(cur realm, freelancer address, title, description, milestones string) string {
179 assertAcceptsNewMoney()
180 caller := callerOf(cur)
181 if !cur.Previous().IsUser() {
182 panic("CreateContract must be called by a user, not a realm")
183 }
184 if !freelancer.IsValid() {
185 panic("invalid freelancer address")
186 }
187 if freelancer == caller {
188 panic("cannot hire yourself")
189 }
190 if len(title) == 0 {
191 panic(ufmt.Sprintf("title must be 1-%d characters", MaxTitleLen))
192 }
193 cleanTitle := cleanText(title, "title", MaxTitleLen)
194 if strings.TrimSpace(cleanTitle) == "" {
195 panic("title must contain visible characters")
196 }
197 cleanDesc := cleanText(description, "description", MaxDescLen)
198
199 ms := parseMilestones(milestones)
200 if len(ms) == 0 {
201 panic("at least one milestone required")
202 }
203 if len(ms) > MaxMilestones {
204 panic(ufmt.Sprintf("maximum %d milestones allowed", MaxMilestones))
205 }
206
207 takeSlot(caller)
208 n := nextID
209 id := strconv.Itoa(n)
210 nextID++
211 clientSlots.Set(indexKey(caller, n), nil) // client index (views.gno)
212 now := runtime.ChainHeight()
213 contracts.Set(id, &Contract{
214 ID: id,
215 Client: caller,
216 Freelancer: freelancer,
217 Title: cleanTitle,
218 Description: cleanDesc,
219 Status: StatusActive,
220 CreatedAt: now,
221 Milestones: ms,
222 createdPauseMark: pausedBlocksAt(now),
223 })
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 exactly the milestone amount. Client only, and only
235// as a direct user call: the tx-level send lands on the message's target
236// realm, so from an intermediary realm the coins would never reach escrow
237// while the milestone was marked funded.
238func FundMilestone(cur realm, contractId string, milestoneIdx int) {
239 assertAcceptsNewMoney()
240 caller := callerOf(cur)
241 if !cur.Previous().IsUserCall() {
242 panic("FundMilestone must be a direct user call")
243 }
244 c := getContract(contractId)
245 if c.Client != caller {
246 panic("only client can fund")
247 }
248 if c.Status != StatusActive {
249 panic("contract not active")
250 }
251 ms := milestoneAt(c, milestoneIdx)
252 if ms.Status != MsPending {
253 panic("milestone already funded or processed")
254 }
255 requireExactSend(ms.Amount, cur.Previous())
256
257 now := runtime.ChainHeight()
258 ms.Status = MsFunded
259 ms.FundedAt = now
260 ms.fundedPauseMark = pausedBlocksAt(now)
261 totalLiable += ms.Amount
262
263 chain.Emit("MilestoneFunded",
264 "contractId", contractId,
265 "milestone", strconv.Itoa(milestoneIdx),
266 "amount", strconv.FormatInt(ms.Amount, 10),
267 )
268}
269
270// CompleteMilestone marks a funded milestone delivered. Freelancer only.
271func CompleteMilestone(cur realm, contractId string, milestoneIdx int) {
272 assertOpen()
273 caller := callerOf(cur)
274 c := getContract(contractId)
275 if c.Freelancer != caller {
276 panic("only freelancer can mark complete")
277 }
278 // A disputed contract is frozen until the dispute is resolved.
279 if c.Status != StatusActive {
280 panic("contract is " + string(c.Status) + " — cannot complete milestones during dispute")
281 }
282 ms := milestoneAt(c, milestoneIdx)
283 if ms.Status != MsFunded {
284 panic("milestone not funded")
285 }
286
287 ms.Status = MsCompleted
288 ms.CompletedAt = runtime.ChainHeight()
289
290 chain.Emit("MilestoneCompleted",
291 "contractId", contractId,
292 "milestone", strconv.Itoa(milestoneIdx),
293 "freelancer", caller.String(),
294 )
295}
296
297// ReleaseFunds pays a completed milestone to the freelancer, minus the fee.
298// Client, or admin as the trusted arbiter (v3 behaviour).
299func ReleaseFunds(cur realm, contractId string, milestoneIdx int) {
300 assertOpen()
301 caller := callerOf(cur)
302 c := getContract(contractId)
303 if c.Client != caller && caller != admin {
304 panic("only client or admin can release")
305 }
306 if c.Status != StatusActive {
307 panic("contract is " + string(c.Status) + " — cannot release funds during dispute")
308 }
309 ms := milestoneAt(c, milestoneIdx)
310 if ms.Status != MsCompleted {
311 panic("milestone not completed")
312 }
313
314 ms.Status = MsReleased
315 settleIfFinished(c)
316 totalLiable -= ms.Amount
317
318 p := payFreelancer(cur, c, ms.Amount)
319
320 chain.Emit("FundsReleased",
321 "contractId", contractId,
322 "milestone", strconv.Itoa(milestoneIdx),
323 "freelancer", c.Freelancer.String(),
324 "amount", strconv.FormatInt(p.net, 10),
325 "fee", strconv.FormatInt(p.fee, 10),
326 "bps", strconv.FormatInt(p.bps, 10),
327 "treasury", p.treasury.String(),
328 "status", string(c.Status),
329 )
330}
331
332// ── Disputes ─────────────────────────────────────────────────
333
334// RaiseDispute escalates a funded or completed milestone to the admin.
335// Client or freelancer.
336func RaiseDispute(cur realm, contractId string, milestoneIdx int) {
337 assertOpen()
338 caller := callerOf(cur)
339 c := getContract(contractId)
340 if c.Client != caller && c.Freelancer != caller {
341 panic("only client or freelancer can dispute")
342 }
343 if !isOpen(c) {
344 panic("contract is " + string(c.Status))
345 }
346 ms := milestoneAt(c, milestoneIdx)
347 if ms.Status != MsFunded && ms.Status != MsCompleted {
348 panic("can only dispute funded or completed milestones")
349 }
350
351 // ClaimDisputeTimeout pays the freelancer if the work had been delivered
352 // (MsCompleted) and refunds the client otherwise.
353 now := runtime.ChainHeight()
354 ms.PreDisputeStatus = ms.Status
355 ms.Status = MsDisputed
356 ms.DisputedAt = now
357 ms.disputedPauseMark = pausedBlocksAt(now)
358 c.Status = StatusDisputed
359
360 chain.Emit("DisputeRaised",
361 "contractId", contractId,
362 "milestone", strconv.Itoa(milestoneIdx),
363 "raisedBy", caller.String(),
364 )
365}
366
367// ResolveDispute settles a disputed milestone. Admin only.
368// refundClient=true refunds the client in full; false pays the freelancer
369// minus the fee. The contract then returns to active, stays disputed if
370// another milestone still is, or settles: completed if every milestone is
371// released, cancelled if every milestone is released or refunded.
372func ResolveDispute(cur realm, contractId string, milestoneIdx int, refundClient bool) {
373 assertOpen()
374 if callerOf(cur) != admin {
375 panic("only admin can resolve disputes")
376 }
377 c := getContract(contractId)
378 ms := milestoneAt(c, milestoneIdx)
379 if ms.Status != MsDisputed {
380 panic("milestone not in dispute")
381 }
382
383 if refundClient {
384 ms.Status = MsRefunded
385 } else {
386 ms.Status = MsReleased
387 }
388 c.Status = StatusActive
389 if anyMilestoneDisputed(c) {
390 c.Status = StatusDisputed
391 }
392 settleIfFinished(c)
393 totalLiable -= ms.Amount
394
395 var p payout
396 resolution := "released-to-freelancer"
397 if refundClient {
398 resolution = "refunded-to-client"
399 send(cur, c.Client, ms.Amount)
400 p = payout{net: ms.Amount, recipient: c.Client}
401 } else {
402 p = payFreelancer(cur, c, ms.Amount)
403 }
404 chain.Emit("DisputeResolved",
405 "contractId", contractId,
406 "milestone", strconv.Itoa(milestoneIdx),
407 "resolution", resolution,
408 "recipient", p.recipient.String(),
409 "amount", strconv.FormatInt(p.net, 10),
410 "fee", strconv.FormatInt(p.fee, 10),
411 "bps", strconv.FormatInt(p.bps, 10),
412 "treasury", p.treasury.String(),
413 "status", string(c.Status),
414 )
415}
416
417// ── Cancellation and expiry ──────────────────────────────────
418
419// CancelContract cancels an active contract. Client only. Funded milestones
420// are refunded minus CancelFeePct, which goes to the freelancer; completed
421// milestones are paid to the freelancer minus the service fee.
422func CancelContract(cur realm, contractId string) {
423 assertOpen()
424 caller := callerOf(cur)
425 c := getContract(contractId)
426 if c.Client != caller {
427 panic("only client can cancel")
428 }
429 if c.Status != StatusActive {
430 panic("contract not active")
431 }
432
433 // Only milestones transitioned here are paid, so a milestone already
434 // refunded or released is never paid twice.
435 var refunded, released []int
436 for i := range c.Milestones {
437 switch c.Milestones[i].Status {
438 case MsFunded:
439 c.Milestones[i].Status = MsRefunded
440 refunded = append(refunded, i)
441 case MsCompleted:
442 c.Milestones[i].Status = MsReleased
443 released = append(released, i)
444 }
445 }
446 settle(c, StatusCancelled)
447 for _, i := range refunded {
448 totalLiable -= c.Milestones[i].Amount
449 }
450 for _, i := range released {
451 totalLiable -= c.Milestones[i].Amount
452 }
453
454 var toClient, compensation, toFreelancer, fees, bps int64
455 var treasury address
456 for _, i := range refunded {
457 amount := c.Milestones[i].Amount
458 fee := basisPointsFee(amount, int64(CancelFeePct)*100)
459 send(cur, c.Client, amount-fee)
460 send(cur, c.Freelancer, fee)
461 toClient += amount - fee
462 compensation += fee
463 }
464 for _, i := range released {
465 p := payFreelancer(cur, c, c.Milestones[i].Amount)
466 toFreelancer += p.net
467 fees += p.fee
468 bps, treasury = p.bps, p.treasury
469 }
470
471 chain.Emit("ContractCancelled",
472 "contractId", contractId,
473 "client", c.Client.String(),
474 "freelancer", c.Freelancer.String(),
475 "refunded", strconv.Itoa(len(refunded)),
476 "released", strconv.Itoa(len(released)),
477 "refundToClient", strconv.FormatInt(toClient, 10),
478 "cancelCompensation", strconv.FormatInt(compensation, 10),
479 "cancelFeeBps", strconv.FormatInt(int64(CancelFeePct)*100, 10),
480 "releasedToFreelancer", strconv.FormatInt(toFreelancer, 10),
481 "fee", strconv.FormatInt(fees, 10),
482 "bps", strconv.FormatInt(bps, 10),
483 "treasury", treasury.String(),
484 )
485}
486
487// ExpireUnfunded cancels a contract none of whose milestones was ever funded,
488// once UnfundedExpiryBlks have passed since creation (blocking-window blocks
489// excluded). Anyone may call. It frees the client's slot and moves no coins;
490// it does not delete, so the storage deposit stays for the client's
491// ArchiveContract.
492func ExpireUnfunded(cur realm, contractId string) {
493 assertOpen()
494 caller := callerOf(cur)
495 c := getContract(contractId)
496 if c.Status != StatusActive {
497 panic("contract not active")
498 }
499 for _, m := range c.Milestones {
500 if m.Status != MsPending {
501 panic("contract was funded; use ClaimRefund or CancelContract")
502 }
503 }
504 elapsed := elapsedOpen(c.CreatedAt, c.createdPauseMark)
505 if elapsed < UnfundedExpiryBlks {
506 panic(ufmt.Sprintf("too early: %d blocks remaining", UnfundedExpiryBlks-elapsed))
507 }
508
509 settle(c, StatusCancelled)
510
511 chain.Emit("ContractExpired",
512 "contractId", contractId,
513 "client", c.Client.String(),
514 "expiredBy", caller.String(),
515 )
516}
517
518// ── Timeouts (permissionless) ────────────────────────────────
519
520// ClaimRefund refunds a funded milestone nobody completed within
521// AutoRefundBlks (blocking-window blocks excluded). Anyone may call; the
522// client receives the funds. If no milestone then holds funds the contract is
523// cancelled, pending milestones included (v3 behaviour).
524func ClaimRefund(cur realm, contractId string, milestoneIdx int) {
525 assertOpen()
526 c := getContract(contractId)
527 ms := milestoneAt(c, milestoneIdx)
528 if ms.Status != MsFunded {
529 panic("milestone not funded")
530 }
531 if ms.FundedAt == 0 {
532 panic("milestone has no funding record")
533 }
534 elapsed := elapsedOpen(ms.FundedAt, ms.fundedPauseMark)
535 if elapsed < AutoRefundBlks {
536 panic(ufmt.Sprintf("too early: %d blocks remaining", AutoRefundBlks-elapsed))
537 }
538
539 ms.Status = MsRefunded
540 if allMilestonesTerminal(c) {
541 settle(c, StatusCancelled)
542 }
543 totalLiable -= ms.Amount
544
545 send(cur, c.Client, ms.Amount)
546
547 chain.Emit("RefundClaimed",
548 "contractId", contractId,
549 "milestone", strconv.Itoa(milestoneIdx),
550 "recipient", c.Client.String(),
551 "amount", strconv.FormatInt(ms.Amount, 10),
552 "status", string(c.Status),
553 )
554}
555
556// ClaimDisputeTimeout resolves a dispute the admin has not acted on within
557// AutoResolveBlks (blocking-window blocks excluded), following the pre-dispute
558// status: a delivered milestone (MsCompleted) pays the freelancer minus the
559// fee, an undelivered one (MsFunded) refunds the client. Anyone may call.
560func ClaimDisputeTimeout(cur realm, contractId string, milestoneIdx int) {
561 assertOpen()
562 c := getContract(contractId)
563 ms := milestoneAt(c, milestoneIdx)
564 if ms.Status != MsDisputed {
565 panic("milestone not in dispute")
566 }
567 if ms.DisputedAt == 0 {
568 panic("milestone has no dispute record")
569 }
570 elapsed := elapsedOpen(ms.DisputedAt, ms.disputedPauseMark)
571 if elapsed < AutoResolveBlks {
572 panic(ufmt.Sprintf("too early: %d blocks remaining", AutoResolveBlks-elapsed))
573 }
574
575 payToFreelancer := ms.PreDisputeStatus == MsCompleted
576 if payToFreelancer {
577 ms.Status = MsReleased
578 } else {
579 ms.Status = MsRefunded
580 }
581 c.Status = StatusActive
582 if anyMilestoneDisputed(c) {
583 c.Status = StatusDisputed
584 }
585 if allMilestonesReleased(c) {
586 settle(c, StatusCompleted)
587 } else if allMilestonesTerminal(c) {
588 settle(c, StatusCancelled)
589 }
590 totalLiable -= ms.Amount
591
592 var p payout
593 resolution := "refunded-client-no-delivery"
594 if payToFreelancer {
595 resolution = "paid-freelancer-work-delivered"
596 p = payFreelancer(cur, c, ms.Amount)
597 } else {
598 send(cur, c.Client, ms.Amount)
599 p = payout{net: ms.Amount, recipient: c.Client}
600 }
601 chain.Emit("DisputeTimedOut",
602 "contractId", contractId,
603 "milestone", strconv.Itoa(milestoneIdx),
604 "resolution", resolution,
605 "recipient", p.recipient.String(),
606 "amount", strconv.FormatInt(p.net, 10),
607 "fee", strconv.FormatInt(p.fee, 10),
608 "bps", strconv.FormatInt(p.bps, 10),
609 "treasury", p.treasury.String(),
610 "status", string(c.Status),
611 )
612}
613
614// ── Archive ──────────────────────────────────────────────────
615
616// ArchiveContract deletes a settled contract (completed or cancelled, with
617// nothing escrowed). Client only. The freed bytes shrink the realm's storage,
618// and the chain refunds their storage deposit to the signer of this message
619// (see DESIGN.md), so the client, who paid for the contract when creating it,
620// gets that deposit back. The id is never reused. The ContractArchived event
621// is the permanent record; nothing of the contract stays in realm state.
622func ArchiveContract(cur realm, contractId string) {
623 assertOpen()
624 caller := callerOf(cur)
625 c := getContract(contractId)
626 if c.Client != caller {
627 panic("only client can archive")
628 }
629 if isOpen(c) {
630 panic("contract not settled: status is " + string(c.Status))
631 }
632 var released, refunded int64
633 for _, m := range c.Milestones {
634 switch m.Status {
635 case MsReleased:
636 released += m.Amount
637 case MsRefunded:
638 refunded += m.Amount
639 case MsPending:
640 default:
641 panic("escrowed funds remain in milestone " + strconv.Itoa(m.ID))
642 }
643 }
644 hash := contentHash(c)
645
646 contracts.Remove(contractId)
647 n, _ := strconv.Atoi(contractId)
648 clientSlots.Remove(indexKey(c.Client, n))
649 if clientActive(c.Client) == 0 {
650 clientSlots.Remove(c.Client.String())
651 }
652 archivedCount++
653
654 // milestone*Total are escrow principal, gross of fees and of the cancel
655 // compensation. The storage-deposit refund is not an escrow amount: the
656 // chain reports it in its own StorageUnlockEvent.
657 chain.Emit("ContractArchived",
658 "contractId", contractId,
659 "client", c.Client.String(),
660 "freelancer", c.Freelancer.String(),
661 "status", string(c.Status),
662 "milestoneReleasedTotal", strconv.FormatInt(released, 10),
663 "milestoneRefundedTotal", strconv.FormatInt(refunded, 10),
664 "contentHash", hash,
665 )
666}
667
668// ── Slots and settlement ─────────────────────────────────────
669
670// isOpen reports whether c still holds an active slot.
671func isOpen(c *Contract) bool {
672 return c.Status != StatusCompleted && c.Status != StatusCancelled
673}
674
675func clientActive(client address) int {
676 v, ok := clientSlots.Get(client.String())
677 if !ok {
678 return 0
679 }
680 return v.(int)
681}
682
683func takeSlot(client address) {
684 n := clientActive(client)
685 if n >= MaxActivePerClient {
686 panic(ufmt.Sprintf("limit of %d active contracts for this client reached", MaxActivePerClient))
687 }
688 clientSlots.Set(client.String(), n+1)
689 activeCount++
690}
691
692// settle moves an open contract to a terminal status and releases its slot.
693// Terminal statuses are absorbing, so this runs at most once per contract.
694func settle(c *Contract, status ContractStatus) {
695 if !isOpen(c) {
696 panic("contract already settled")
697 }
698 c.Status = status
699 n := clientActive(c.Client)
700 if n <= 0 || activeCount <= 0 {
701 panic("slot accounting underflow")
702 }
703 // A zero count is kept until the client archives: the client paid for
704 // this tree node, and removing it here would refund its deposit to
705 // whoever signs the settling message (a permissionless ClaimRefund
706 // caller, the admin, the freelancer).
707 clientSlots.Set(c.Client.String(), n-1)
708 activeCount--
709}
710
711// settleIfFinished settles an open contract whose milestones are all released
712// (completed) or all released or refunded (cancelled). A pending, funded,
713// completed or disputed milestone keeps it open.
714func settleIfFinished(c *Contract) {
715 if !isOpen(c) {
716 return
717 }
718 allReleased := true
719 for _, m := range c.Milestones {
720 if m.Status != MsReleased && m.Status != MsRefunded {
721 return
722 }
723 if m.Status != MsReleased {
724 allReleased = false
725 }
726 }
727 if allReleased {
728 settle(c, StatusCompleted)
729 } else {
730 settle(c, StatusCancelled)
731 }
732}
733
734// ── Payments ─────────────────────────────────────────────────
735
736type payout struct {
737 recipient address
738 net, fee int64
739 bps int64
740 treasury address
741}
742
743func send(cur realm, to address, amount int64) {
744 if amount <= 0 {
745 return
746 }
747 bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
748 bnk.SendCoins(cur.Address(), to, chain.Coins{chain.NewCoin("ugnot", amount)})
749}
750
751// payFreelancer pays amount to the freelancer minus the service fee read live
752// from the fee spine, which goes to the treasury.
753func payFreelancer(cur realm, c *Contract, amount int64) payout {
754 bps, treasury := resolveFee()
755 fee := basisPointsFee(amount, bps)
756 send(cur, c.Freelancer, amount-fee)
757 send(cur, treasury, fee)
758 return payout{recipient: c.Freelancer, net: amount - fee, fee: fee, bps: bps, treasury: treasury}
759}
760
761// ── Helpers ──────────────────────────────────────────────────
762
763func getContract(id string) *Contract {
764 val, exists := contracts.Get(id)
765 if !exists {
766 panic("contract not found: " + id)
767 }
768 return val.(*Contract)
769}
770
771func milestoneAt(c *Contract, idx int) *Milestone {
772 if idx < 0 || idx >= len(c.Milestones) {
773 panic("invalid milestone index")
774 }
775 return &c.Milestones[idx]
776}
777
778func allMilestonesReleased(c *Contract) bool {
779 for _, m := range c.Milestones {
780 if m.Status != MsReleased {
781 return false
782 }
783 }
784 return true
785}
786
787func anyMilestoneDisputed(c *Contract) bool {
788 for _, m := range c.Milestones {
789 if m.Status == MsDisputed {
790 return true
791 }
792 }
793 return false
794}
795
796// allMilestonesTerminal reports whether no milestone holds funds (pending with
797// no funds counts as terminal).
798func allMilestonesTerminal(c *Contract) bool {
799 for _, m := range c.Milestones {
800 if m.Status == MsFunded || m.Status == MsCompleted || m.Status == MsDisputed {
801 return false
802 }
803 }
804 return true
805}
806
807// parseMilestones parses "title1:amount1,title2:amount2". Blank entries are
808// skipped; any other malformed entry panics.
809func parseMilestones(input string) []Milestone {
810 if len(input) > maxMilestonesArgLen {
811 panic(ufmt.Sprintf("milestones argument too long: %d/%d bytes", len(input), maxMilestonesArgLen))
812 }
813 var result []Milestone
814 total := int64(0)
815 for i, p := range strings.Split(input, ",") {
816 p = strings.TrimSpace(p)
817 if len(p) == 0 {
818 continue
819 }
820 kv := strings.SplitN(p, ":", 2)
821 if len(kv) != 2 {
822 panic(ufmt.Sprintf("invalid milestone format at position %d: expected 'title:amount'", i))
823 }
824 title := strings.TrimSpace(kv[0])
825 if len(title) == 0 {
826 panic(ufmt.Sprintf("empty milestone title at position %d", i))
827 }
828 title = cleanText(title, ufmt.Sprintf("milestone title at position %d", i), MaxTitleLen)
829 if strings.TrimSpace(title) == "" {
830 panic(ufmt.Sprintf("milestone title at position %d has no visible characters", i))
831 }
832 amount, err := strconv.ParseInt(strings.TrimSpace(kv[1]), 10, 64)
833 if err != nil || amount <= 0 {
834 panic(ufmt.Sprintf("invalid milestone amount at position %d: must be positive integer", i))
835 }
836 if amount < MinMilestoneAmount {
837 panic(ufmt.Sprintf("milestone amount too small at position %d: minimum %d ugnot", i, MinMilestoneAmount))
838 }
839 if amount > 9223372036854775807-total {
840 panic("contract total overflows int64")
841 }
842 total += amount
843 result = append(result, Milestone{
844 ID: len(result),
845 Title: title,
846 Amount: amount,
847 Status: MsPending,
848 })
849 }
850 return result
851}
852
853// Time-boxed emergency pause.
854//
855// Pause (admin) has two effects:
856// - New money stops: CreateContract and FundMilestone abort until Unpause.
857// - Every other state-changing entrypoint (settlement, disputes, timeouts,
858// cancellation, expiry, archive) aborts only during the BLOCKING WINDOW,
859// the first MaxPauseBlks blocks of the pause. After that it reopens even if
860// nobody unpauses.
861//
862// The timeout clocks (AutoRefundBlks, AutoResolveBlks, UnfundedExpiryBlks) do
863// not run inside a blocking window: each deadline moves by the paused blocks
864// that fell inside it. After Unpause, a new Pause is refused for
865// PauseCooldownBlks, so pause, unpause, pause cannot keep exits shut.
866//
867// Without the time box one admin key (after the DAO handoff, any single member
868// allowed to call the emergency pause) could freeze every exit forever, and
869// losing the admin key during a pause would do the same: admin rotation needs
870// the admin key.
871//
872// Admin controls (Pause, Unpause, admin and fee-recipient rotation) are never
873// paused.
874
875const (
876 // MaxPauseBlks is the blocking window: about 7 days at gnoland-1's
877 // observed average of about 3.3 s per block (7*24*3600/3.3 = 183,273).
878 MaxPauseBlks = int64(183273)
879 // PauseCooldownBlks is the minimum gap between an Unpause and the next
880 // Pause, so a pause can hold exits shut at most half of the time.
881 PauseCooldownBlks = MaxPauseBlks
882)
883
884var (
885 paused bool
886 pausedAt int64 // height of the current pause; 0 when not paused
887 pauseCooldownUntil int64 // first height at which Pause is accepted again
888 pausedBlocksDone int64 // blocking-window blocks of finished pauses
889)
890
891// PauseInfo is the read-back of the pause state.
892type PauseInfo struct {
893 Paused bool // set by Pause, cleared only by Unpause
894 PausedAt int64 // height of the current pause (0 when not paused)
895 ExitsReopenAt int64 // PausedAt + MaxPauseBlks (0 when not paused)
896 ExitsOpen bool // false only inside a blocking window
897 CooldownUntil int64 // first height at which Pause is accepted
898 PausedBlocks int64 // total blocking-window blocks so far (clocks skip them)
899}
900
901// PauseState returns the pause state at the current height.
902func PauseState() PauseInfo {
903 now := runtime.ChainHeight()
904 s := PauseInfo{
905 Paused: paused,
906 ExitsOpen: !inBlockingWindow(now),
907 CooldownUntil: pauseCooldownUntil,
908 PausedBlocks: pausedBlocksAt(now),
909 }
910 if paused {
911 s.PausedAt = pausedAt
912 s.ExitsReopenAt = pausedAt + MaxPauseBlks
913 }
914 return s
915}
916
917// IsPaused reports whether the pause flag is set. Exits may already have
918// reopened: see PauseState().ExitsOpen.
919func IsPaused() bool { return paused }
920
921// Pause stops new money and, for MaxPauseBlks, every other state change.
922// Admin only. Refused while paused and during the cooldown after an Unpause.
923func Pause(cur realm) {
924 assertAdmin(cur)
925 now := runtime.ChainHeight()
926 if paused {
927 panic("already paused")
928 }
929 if now < pauseCooldownUntil {
930 panic("pause cooldown: next pause allowed at block " + strconv.FormatInt(pauseCooldownUntil, 10))
931 }
932 paused = true
933 pausedAt = now
934 chain.Emit("Paused",
935 "at", strconv.FormatInt(now, 10),
936 "exitsReopenAt", strconv.FormatInt(now+MaxPauseBlks, 10),
937 )
938}
939
940// Unpause resumes normal operation and starts the cooldown. Admin only.
941func Unpause(cur realm) {
942 assertAdmin(cur)
943 now := runtime.ChainHeight()
944 if !paused {
945 panic("not paused")
946 }
947 pausedBlocksDone = pausedBlocksAt(now)
948 paused = false
949 pausedAt = 0
950 pauseCooldownUntil = now + PauseCooldownBlks
951 chain.Emit("Unpaused",
952 "at", strconv.FormatInt(now, 10),
953 "cooldownUntil", strconv.FormatInt(pauseCooldownUntil, 10),
954 )
955}
956
957// inBlockingWindow reports whether now falls in the first MaxPauseBlks blocks
958// of the current pause.
959func inBlockingWindow(now int64) bool {
960 return paused && now < pausedAt+MaxPauseBlks
961}
962
963// pausedBlocksAt returns the blocking-window blocks up to now, finished pauses
964// included. Timeout clocks subtract the growth of this value.
965func pausedBlocksAt(now int64) int64 {
966 total := pausedBlocksDone
967 if paused {
968 end := now
969 if end > pausedAt+MaxPauseBlks {
970 end = pausedAt + MaxPauseBlks
971 }
972 total += end - pausedAt
973 }
974 return total
975}
976
977// assertAcceptsNewMoney guards CreateContract and FundMilestone.
978func assertAcceptsNewMoney() {
979 if paused {
980 panic("realm is paused — no new contracts or funding until unpaused")
981 }
982}
983
984// assertOpen guards every other state-changing user and admin entrypoint.
985func assertOpen() {
986 if inBlockingWindow(runtime.ChainHeight()) {
987 panic("realm is paused — exits reopen at block " + strconv.FormatInt(pausedAt+MaxPauseBlks, 10))
988 }
989}
990
991// elapsedOpen returns the blocks since `since` that fell outside any blocking
992// window, given pausedBlocksAt(since) recorded as mark.
993func elapsedOpen(since, mark int64) int64 {
994 now := runtime.ChainHeight()
995 return now - since - (pausedBlocksAt(now) - mark)
996}
997
998// ── Admin authority and the fallback fee recipient ──────────────────────
999//
1000// No authority is compiled in. `admin` and `feeRecipient` are seeded at
1001// package load from the publishing transaction's signer (on gnoland-1 the
1002// samcrew namespace multisig), and every gate reads the mutable variables.
1003// Realms are immutable and mainnet has no faucet, so a compile-time authority
1004// could never be corrected; losing it would disable Pause/Unpause and
1005// ResolveDispute, the only path that settles a dispute before its timeout.
1006//
1007// Both rotations are TWO-STEP: the current admin stages an address, and that
1008// address must accept with its own transaction. A one-step setter could hand
1009// the role to an address that cannot act (a typo, an unfunded key), with no
1010// way back. The admin can abort a staged proposal without the target.
1011//
1012// Rotation stays available while paused. It needs the current admin key, so
1013// it cannot rescue a pause after that key is lost; the pause's time box
1014// (pause section above) is what keeps exits from freezing.
1015
1016var (
1017 // admin is the live authority, seeded with the publisher at load.
1018 admin address = publisherAtLoad()
1019 // pendingAdmin is the staged successor; "" when none.
1020 pendingAdmin address
1021
1022 // feeRecipient receives the protocol fee only when memba_market_config
1023 // reports no treasury (see resolveFee). Seeded with the publisher.
1024 feeRecipient address = publisherAtLoad()
1025 // pendingFeeRecipient is the staged successor; "" when none.
1026 pendingFeeRecipient address
1027)
1028
1029// assertAdmin rejects a stale or sibling `cur`, then checks the caller.
1030func assertAdmin(cur realm) {
1031 if callerOf(cur) != admin {
1032 panic("unauthorized: only admin may perform this action")
1033 }
1034}
1035
1036// ── Admin rotation ───────────────────────────────────────────
1037
1038// TransferOwnership stages a handoff to newAdmin. Admin only. Calling it again
1039// replaces the staged address.
1040func TransferOwnership(cur realm, newAdmin address) {
1041 assertAdmin(cur)
1042 if !newAdmin.IsValid() {
1043 panic("newAdmin must be a valid address")
1044 }
1045 pendingAdmin = newAdmin
1046 chain.Emit("OwnershipTransferStarted", "pending", newAdmin.String())
1047}
1048
1049// AcceptOwnership completes the handoff. Only the staged address may call it,
1050// so the outgoing admin cannot collapse the two steps into one.
1051func AcceptOwnership(cur realm) {
1052 caller := callerOf(cur)
1053 if pendingAdmin == "" {
1054 panic("no pending ownership transfer")
1055 }
1056 if caller != pendingAdmin {
1057 panic("unauthorized: only the pending admin may accept")
1058 }
1059 admin = pendingAdmin
1060 pendingAdmin = ""
1061 chain.Emit("OwnershipTransferAccepted", "admin", admin.String())
1062}
1063
1064// CancelOwnershipTransfer clears a staged handoff. Admin only.
1065func CancelOwnershipTransfer(cur realm) {
1066 assertAdmin(cur)
1067 if pendingAdmin == "" {
1068 panic("no pending ownership transfer")
1069 }
1070 cancelled := pendingAdmin
1071 pendingAdmin = ""
1072 chain.Emit("OwnershipTransferCancelled", "cancelled", cancelled.String())
1073}
1074
1075// GetAdmin returns the address that holds admin.
1076func GetAdmin() string { return admin.String() }
1077
1078// GetPendingAdmin returns the staged successor ("" when none).
1079func GetPendingAdmin() string { return pendingAdmin.String() }
1080
1081// ── Fee recipient rotation ───────────────────────────────────
1082
1083// ProposeFeeRecipient stages a new fallback fee recipient. Admin only.
1084func ProposeFeeRecipient(cur realm, recipient address) {
1085 assertAdmin(cur)
1086 if !recipient.IsValid() {
1087 panic("fee recipient must be a valid address")
1088 }
1089 pendingFeeRecipient = recipient
1090 chain.Emit("FeeRecipientProposed", "pending", recipient.String())
1091}
1092
1093// AcceptFeeRecipient completes the rotation. Only the staged address may call
1094// it, which proves it can transact.
1095func AcceptFeeRecipient(cur realm) {
1096 caller := callerOf(cur)
1097 if pendingFeeRecipient == "" {
1098 panic("no pending fee recipient")
1099 }
1100 if caller != pendingFeeRecipient {
1101 panic("unauthorized: only the pending fee recipient may accept")
1102 }
1103 feeRecipient = pendingFeeRecipient
1104 pendingFeeRecipient = ""
1105 chain.Emit("FeeRecipientAccepted", "recipient", feeRecipient.String())
1106}
1107
1108// CancelFeeRecipientProposal clears a staged fee recipient. Admin only.
1109func CancelFeeRecipientProposal(cur realm) {
1110 assertAdmin(cur)
1111 if pendingFeeRecipient == "" {
1112 panic("no pending fee recipient")
1113 }
1114 cancelled := pendingFeeRecipient
1115 pendingFeeRecipient = ""
1116 chain.Emit("FeeRecipientProposalCancelled", "cancelled", cancelled.String())
1117}
1118
1119// GetFeeRecipient returns the fallback fee recipient.
1120func GetFeeRecipient() string { return feeRecipient.String() }
1121
1122// GetPendingFeeRecipient returns the staged fee recipient ("" when none).
1123func GetPendingFeeRecipient() string { return pendingFeeRecipient.String() }
1124
1125// basisPointsFee computes floor(amount*bps/10000) without overflowing the
1126// intermediate product. For nonnegative amount and bps <= 10000, the quotient
1127// product and final sum are <= amount; the remainder product is < 100000000.
1128// Callers retain their existing configured rates, recipients and rounding.
1129func basisPointsFee(amount, bps int64) int64 {
1130 if amount < 0 || bps < 0 || bps > 10000 {
1131 panic("invalid fee inputs")
1132 }
1133 return (amount/10000)*bps + ((amount%10000)*bps)/10000
1134}
1135
1136// cleanText validates and sanitises user text before it is stored and
1137// rendered. It refuses input longer than max bytes, input that is not valid
1138// UTF-8 (the sanitiser would otherwise turn each bad byte into a 3-byte
1139// U+FFFD, storing up to three times the limit), and control or format
1140// characters (\p{Cc}, \p{Cf}: bidi overrides, zero-width characters), which
1141// could hide or reorder text. It then strips markdown-sensitive characters and
1142// line breaks and tabs, as v3 did. Stripping only removes bytes, so the result
1143// is within max; that is checked again because storage is bounded by it.
1144func cleanText(s, what string, max int) string {
1145 if len(s) > max {
1146 panic(ufmt.Sprintf("%s must be at most %d bytes", what, max))
1147 }
1148 if !utf8.ValidString(s) {
1149 panic(what + " must be valid UTF-8")
1150 }
1151 strip := false
1152 for _, c := range s {
1153 if isStripped(c) {
1154 strip = true
1155 continue
1156 }
1157 if c < 0x20 || c == 0x7f || (c >= 0x80 && c < 0xa0) || (mayBeFormat(c) && unicode.Is(unicode.Cf, c)) {
1158 panic(what + " contains invisible or control characters")
1159 }
1160 }
1161 if !strip {
1162 return s
1163 }
1164 var out strings.Builder
1165 for _, c := range s {
1166 if !isStripped(c) {
1167 out.WriteRune(c)
1168 }
1169 }
1170 clean := out.String()
1171 if len(clean) > max {
1172 panic(ufmt.Sprintf("%s must be at most %d bytes", what, max))
1173 }
1174 return clean
1175}
1176
1177// isStripped reports the markdown-sensitive characters and line breaks and
1178// tabs removed from stored text, as in v3.
1179func isStripped(c rune) bool {
1180 switch c {
1181 case '[', ']', '(', ')', '#', '*', '`', '!', '<', '>', '|', '\\', '_', '~', '\n', '\r', '\t':
1182 return true
1183 }
1184 return false
1185}
1186
1187// mayBeFormat is a cheap superset test for \p{Cf} (every format character is
1188// U+00AD, in U+0600..U+206F, or at or above U+FEFF), so common text skips the
1189// table lookup.
1190func mayBeFormat(c rune) bool {
1191 return c == 0xad || (c >= 0x600 && c <= 0x206f) || c >= 0xfeff
1192}
1193Raw Package Data
Raw JSON data