← Back to Realms

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}
1193

Raw Package Data

Raw JSON data