Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

escrow.gno

39.08 Kb · 1192 lines
   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}