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

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

deposits.gno

37.12 Kb · 1050 lines
   1// Package deposits is a refundable security deposit with condition-gated,
   2// partially-forfeitable settlement.
   3//
   4// # The shape
   5//
   6// A HOLDER (landlord, service provider, equipment lessor) opens an agreement
   7// naming a DEPOSITOR, an exact amount, a term, and a FORFEIT CEILING — the most
   8// the holder may ever claim out of the deposit. The depositor reads all of it,
   9// including the named arbiter, and consents by FUNDING. At the end of the term
  10// the holder may file a claim up to the ceiling; the depositor may accept it,
  11// dispute it to the arbiter, or let it stand. Whatever is not awarded to the
  12// holder is refunded to the depositor.
  13//
  14// That makes this the first realm in the portfolio where ONE POT SPLITS
  15// PARTIALLY BETWEEN TWO RIVAL CLAIMANTS by a conditional rule, and the first
  16// where the depositor's own money is at risk of partial loss. `crowdfund` is
  17// all-or-nothing, `auction` is winner-takes, `loans` is principal-out /
  18// larger-back, `reimbursements` is approver-gated to a single claimant. See
  19// DISCOVERY.md for the §7 gate and the five candidates it killed.
  20//
  21// # Lifecycle
  22//
  23//	OpenAgreement  -> open       (nothing held; depositor has not consented)
  24//	Fund           -> funded     (depositor attaches the exact amount)
  25//	FileClaim      -> claimed    (holder, after term end, within the claim window)
  26//	Dispute        -> disputed   (depositor, within the dispute window)
  27//	ResolveDispute -> settled    (arbiter awards 0..claim to the holder)
  28//
  29// Five routes reach `settled`, and THREE OF THEM ARE PERMISSIONLESS, so no
  30// terminal state depends on a party acting:
  31//
  32//	AcceptClaim       depositor agrees           award = claim
  33//	SettleUndisputed  ANYONE, dispute window up  award = claim
  34//	ReleaseUnclaimed  ANYONE, claim window up    award = 0  (holder never claimed)
  35//	LapseDispute      ANYONE, arbiter silent     award = 0  (claim unsubstantiated)
  36//	Release           holder waives early        award = 0
  37//
  38// # Custody
  39//
  40// Coins are held by the realm from Fund until settlement, then become
  41// PULL-CLAIMABLE through `p/feeledger`. Nothing is ever pushed: a settlement
  42// credits balances and the parties `Claim` them, which removes the grief and
  43// reentrancy surface a push settlement would have.
  44//
  45// The conservation invariant carries one bucket more than the sibling realms:
  46//
  47//	Held == locked + contested + feeledger.Liabilities()
  48//
  49// `locked` and `contested` are DISJOINT — a disputed deposit moves out of one
  50// and into the other in the same transaction. The split exists because a
  51// contested deposit is the one balance whose destination is not yet decided by
  52// this realm's own rules; keeping it separate makes "what is the arbiter
  53// deciding over" a readable number rather than an inference.
  54//
  55// # The fee
  56//
  57// The protocol fee applies ONLY to the portion AWARDED to the holder. A refund
  58// to the depositor is returned in full at fee 0, matching `auction`'s rule that
  59// a refund is not a fee event. A fully-refunded deposit therefore costs the
  60// depositor nothing.
  61//
  62// # What the depositor is trusting, stated plainly
  63//
  64//   - The FORFEIT CEILING is the depositor's hard protection, and they consent
  65//     to it by funding. Nothing can take more than the ceiling.
  66//   - If NO ARBITER is named, an unaccepted claim STANDS once the dispute
  67//     window passes. That is a legitimate configuration — a deposit with a low
  68//     ceiling and no arbiter — and the depositor sees `Arbiter() == ""` before
  69//     funding.
  70//   - If an arbiter IS named, `LapseDispute` awards the contested amount to the
  71//     DEPOSITOR. A disappeared arbiter cannot strand funds, and the holder —
  72//     who chose the arbiter — bears the cost of that choice failing.
  73//   - Residual, not fixed here: a hostile depositor can dispute a legitimate
  74//     claim to delay settlement by one dispute window. The delay is bounded and
  75//     costs the depositor the same wait; it is disclosed rather than mitigated.
  76//   - The realm settles MONEY. Whether the damage was real is off-chain; this
  77//     realm binds the ceiling, the windows, and the arbiter's authority.
  78//
  79// # Scope
  80//
  81// Single-denom (ugnot), single depositor per agreement, flat ceiling. Graduated
  82// forfeiture over damage severity (`p/tiers`) is a deliberate v2 candidate, not
  83// an omission — see DISCOVERY.md §5.
  84package deposits
  85
  86import (
  87	"chain"
  88	"chain/runtime"
  89	"chain/runtime/unsafe"
  90	"strconv"
  91
  92	"gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/coinio"
  93	"gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/feeledger"
  94	"gno.land/p/nt/avl/v0"
  95	"gno.land/p/nt/markdown/sanitize/v0"
  96)
  97
  98// Denom is the only asset this realm accepts.
  99const Denom = "ugnot"
 100
 101// MaxFeeBps is the hard protocol-fee cap: 1000 bps = 10%. The fee applies only
 102// to the portion AWARDED to the holder, never to a refund.
 103const MaxFeeBps = int64(1000)
 104
 105// Agreement status values. Settled and Cancelled are terminal.
 106const (
 107	DepositOpen      = "open"      // created; the depositor has not funded
 108	DepositFunded    = "funded"    // funded; the term is running or the claim window is open
 109	DepositClaimed   = "claimed"   // the holder filed a claim; the depositor may accept or dispute
 110	DepositDisputed  = "disputed"  // the depositor disputed; the arbiter decides
 111	DepositSettled   = "settled"   // split credited; both parties pull
 112	DepositCancelled = "cancelled" // never funded; nothing moved
 113)
 114
 115// Input bounds.
 116const (
 117	// MinDeposit is the dust floor; MaxDeposit keeps the split, the ceiling and
 118	// the fee far from int64 overflow. The depositor must ATTACH the deposit, so
 119	// the real ceiling is their balance.
 120	MinDeposit = int64(10_000)
 121	MaxDeposit = int64(1_000_000_000_000) // 1,000,000 GNOT
 122
 123	// MinClaim is the dust floor for a forfeit ceiling and for any claim filed
 124	// against it. A ceiling below it would make FileClaim unsatisfiable.
 125	MinClaim = int64(1_000)
 126
 127	// Term and window bounds, in blocks. The window floors keep a counterparty
 128	// from being given a deadline they cannot physically meet; the ceilings keep
 129	// height arithmetic far from overflow.
 130	MinTerm   = int64(10)
 131	MaxTerm   = int64(7_500_000)
 132	MinWindow = int64(10)
 133	MaxWindow = int64(7_500_000)
 134
 135	// FundWindow is how long an unfunded agreement stays fundable. After it,
 136	// Fund refuses and CancelUnfunded is permissionless, so an abandoned offer
 137	// cannot occupy a holder's slot forever.
 138	FundWindow = int64(1_000_000)
 139
 140	// MaxMemoLen and MaxReasonLen bound the stored free text.
 141	MaxMemoLen   = 200
 142	MaxReasonLen = 200
 143
 144	// MaxAgreementsPerHolder bounds one holder's stored agreements. A terminal
 145	// agreement does NOT free the slot: a settled deposit is a permanent record
 146	// both parties and an auditor may need to read.
 147	MaxAgreementsPerHolder = 50
 148
 149	// RenderLimit bounds every rendered list — an unbounded Render was YELLOW in
 150	// several prior audits in this portfolio.
 151	RenderLimit = 20
 152)
 153
 154type agreement struct {
 155	id        int64
 156	holder    address
 157	depositor address
 158	arbiter   address // "" = none; a claim then cannot be disputed
 159	memo      string
 160
 161	amount         int64 // the deposit; Fund must attach exactly this
 162	forfeitCeiling int64 // the most the holder may ever claim, in [MinClaim, amount]
 163	feeBps         int64 // snapshot at open; applied ONLY to the awarded portion
 164	termBlocks     int64
 165	claimWindow    int64
 166	disputeWindow  int64
 167
 168	status        string
 169	createdHeight int64
 170	fundDeadline  int64 // createdHeight + FundWindow
 171	fundedHeight  int64 // 0 until funded
 172	termEnd       int64 // fundedHeight + termBlocks
 173	claimDeadline int64 // termEnd + claimWindow
 174
 175	claimAmount     int64
 176	claimReason     string
 177	disputeDeadline int64 // claim height + disputeWindow
 178	disputeReason   string
 179	arbiterDeadline int64 // dispute height + disputeWindow
 180
 181	award         int64 // credited to the holder at settlement
 182	settledHeight int64
 183}
 184
 185var (
 186	admin        address
 187	pendingAdmin address
 188	feeRecipient address
 189	feeBps       int64 // snapshotted into NEW agreements
 190
 191	self      address
 192	nextID    int64
 193	locked    int64 // Σ amounts of funded/claimed agreements (destination not yet decided by a party)
 194	contested int64 // Σ amounts of DISPUTED agreements (an arbiter is deciding)
 195
 196	agreements = avl.NewTree() // padID(id)       -> *agreement
 197	holderNum  = avl.NewTree() // holder.String() -> *int64, stored agreements
 198	ledger     = feeledger.MustNew(MaxFeeBps)
 199)
 200
 201func init() {
 202	admin = unsafe.OriginCaller()
 203	feeRecipient = admin
 204	self = unsafe.CurrentRealm().Address()
 205}
 206
 207// --- holder side ---
 208
 209// OpenAgreement offers a deposit agreement. The caller becomes the holder and
 210// names the `depositor`, the exact `amount` they must attach, the `termBlocks`
 211// the deposit is held for, the `claimWindow` the holder has to file a claim
 212// after the term ends, the `disputeWindow` the depositor has to dispute one (and
 213// the arbiter then has to rule), and the `forfeitCeiling` — the most the holder
 214// may ever claim, in [MinClaim, amount]. `arbiter` may be the zero address, in
 215// which case a claim cannot be disputed; it may not be either party. Nothing is
 216// held until the depositor Funds, which is how they consent to all of it. The
 217// current protocol fee is snapshotted in. Returns the id.
 218func OpenAgreement(cur realm, depositor address, amount, forfeitCeiling, termBlocks, claimWindow, disputeWindow int64, arbiter address, memo string) int64 {
 219	assertNoSend()
 220	holder := cur.Previous().Address()
 221	if !depositor.IsValid() {
 222		panic("invalid depositor address")
 223	}
 224	if depositor == holder {
 225		panic("holder and depositor must differ")
 226	}
 227	if arbiter != "" {
 228		if !arbiter.IsValid() {
 229			panic("invalid arbiter address")
 230		}
 231		if arbiter == holder || arbiter == depositor {
 232			panic("the arbiter may not be either party")
 233		}
 234	}
 235	if amount < MinDeposit || amount > MaxDeposit {
 236		panic("deposit must be " + itoa(MinDeposit) + ".." + itoa(MaxDeposit) + Denom)
 237	}
 238	if forfeitCeiling < MinClaim || forfeitCeiling > amount {
 239		panic("forfeit ceiling must be " + itoa(MinClaim) + ".." + itoa(amount) + Denom)
 240	}
 241	if termBlocks < MinTerm || termBlocks > MaxTerm {
 242		panic("term must be " + itoa(MinTerm) + ".." + itoa(MaxTerm) + " blocks")
 243	}
 244	if claimWindow < MinWindow || claimWindow > MaxWindow {
 245		panic("claim window must be " + itoa(MinWindow) + ".." + itoa(MaxWindow) + " blocks")
 246	}
 247	if disputeWindow < MinWindow || disputeWindow > MaxWindow {
 248		panic("dispute window must be " + itoa(MinWindow) + ".." + itoa(MaxWindow) + " blocks")
 249	}
 250	if len(memo) > MaxMemoLen {
 251		panic("memo must be at most " + itoa(MaxMemoLen) + " bytes")
 252	}
 253	n := counter(holderNum, holder)
 254	if *n >= MaxAgreementsPerHolder {
 255		panic("holder has reached " + itoa(MaxAgreementsPerHolder) + " stored agreements")
 256	}
 257
 258	now := runtime.ChainHeight()
 259	deadline, ok := addChecked(now, FundWindow)
 260	if !ok {
 261		panic("fund-deadline height overflow")
 262	}
 263	id := nextID
 264	nextID++
 265	d := &agreement{
 266		id:             id,
 267		holder:         holder,
 268		depositor:      depositor,
 269		arbiter:        arbiter,
 270		memo:           memo,
 271		amount:         amount,
 272		forfeitCeiling: forfeitCeiling,
 273		feeBps:         feeBps,
 274		termBlocks:     termBlocks,
 275		claimWindow:    claimWindow,
 276		disputeWindow:  disputeWindow,
 277		status:         DepositOpen,
 278		createdHeight:  now,
 279		fundDeadline:   deadline,
 280	}
 281	agreements.Set(padID(id), d)
 282	*n++
 283
 284	chain.Emit("AgreementOpened",
 285		"id", itoa(id),
 286		"holder", holder.String(),
 287		"depositor", depositor.String(),
 288		"arbiter", arbiter.String(),
 289		"amount", itoa(amount),
 290		"forfeitCeiling", itoa(forfeitCeiling),
 291		"fundDeadline", itoa(deadline),
 292	)
 293	return id
 294}
 295
 296// FileClaim claims `amount` of the deposit against the ceiling. Holder only,
 297// only once, only after the term has ended and before the claim window closes.
 298// `amount` must be in [MinClaim, forfeitCeiling]. The depositor may then accept
 299// it, dispute it to the arbiter, or let it stand.
 300func FileClaim(cur realm, id, amount int64, reason string) {
 301	assertNoSend()
 302	d := mustAgreement(id)
 303	if cur.Previous().Address() != d.holder {
 304		panic("holder only")
 305	}
 306	if d.status != DepositFunded {
 307		panic("agreement is " + d.status)
 308	}
 309	now := runtime.ChainHeight()
 310	if now < d.termEnd {
 311		panic("the term has not ended; it ends at height " + itoa(d.termEnd))
 312	}
 313	if now >= d.claimDeadline {
 314		panic("the claim window closed at height " + itoa(d.claimDeadline))
 315	}
 316	if amount < MinClaim || amount > d.forfeitCeiling {
 317		panic("claim must be " + itoa(MinClaim) + ".." + itoa(d.forfeitCeiling) + Denom)
 318	}
 319	if len(reason) == 0 || len(reason) > MaxReasonLen {
 320		panic("reason must be 1.." + itoa(MaxReasonLen) + " bytes")
 321	}
 322	// Checked like every other deadline, though the window bounds put overflow
 323	// out of reach.
 324	until, ok := addChecked(now, d.disputeWindow)
 325	if !ok {
 326		panic("dispute-deadline height overflow")
 327	}
 328	d.claimAmount = amount
 329	d.claimReason = reason
 330	d.disputeDeadline = until
 331	d.status = DepositClaimed
 332
 333	chain.Emit("ClaimFiled",
 334		"id", itoa(id),
 335		"holder", d.holder.String(),
 336		"amount", itoa(amount),
 337		"disputeDeadline", itoa(until),
 338	)
 339}
 340
 341// Release returns the whole deposit to the depositor early. Holder only, while
 342// the agreement is funded and unclaimed. It is the holder waiving the claim they
 343// have not yet filed — the one route to settlement that needs no clock.
 344func Release(cur realm, id int64) {
 345	assertNoSend()
 346	d := mustAgreement(id)
 347	if cur.Previous().Address() != d.holder {
 348		panic("holder only")
 349	}
 350	if d.status != DepositFunded {
 351		panic("agreement is " + d.status)
 352	}
 353	settle(d, 0, false)
 354	chain.Emit("Settled",
 355		"id", itoa(id),
 356		"route", "release",
 357		"award", "0",
 358		"refund", itoa(d.amount),
 359	)
 360}
 361
 362// --- depositor side ---
 363
 364// Fund attaches the exact deposit and starts the term. The named depositor only;
 365// the attached amount must equal the agreement's amount exactly, and the fund
 366// deadline must not have passed. Funding IS the depositor's consent to the
 367// ceiling, the windows and the arbiter, all of which were readable beforehand.
 368func Fund(cur realm, id int64) {
 369	from, amount := coinio.Receive(0, cur, Denom)
 370	d := mustAgreement(id)
 371	if d.status != DepositOpen {
 372		panic("agreement is " + d.status)
 373	}
 374	if from != d.depositor {
 375		panic("only the named depositor may fund this agreement")
 376	}
 377	now := runtime.ChainHeight()
 378	if now >= d.fundDeadline {
 379		panic("the fund window closed at height " + itoa(d.fundDeadline))
 380	}
 381	if amount != d.amount {
 382		panic("attach exactly " + itoa(d.amount) + Denom + "; got " + itoa(amount))
 383	}
 384	termEnd, ok := addChecked(now, d.termBlocks)
 385	if !ok {
 386		panic("term-end height overflow")
 387	}
 388	claimDeadline, ok := addChecked(termEnd, d.claimWindow)
 389	if !ok {
 390		panic("claim-deadline height overflow")
 391	}
 392	// coinio already received `amount`, so Held rose by amount; locking it keeps
 393	// Held == locked + contested + Liabilities exactly.
 394	locked += amount
 395	d.fundedHeight = now
 396	d.termEnd = termEnd
 397	d.claimDeadline = claimDeadline
 398	d.status = DepositFunded
 399
 400	chain.Emit("Funded",
 401		"id", itoa(id),
 402		"depositor", from.String(),
 403		"amount", itoa(amount),
 404		"termEnd", itoa(termEnd),
 405		"claimDeadline", itoa(claimDeadline),
 406	)
 407}
 408
 409// AcceptClaim settles at the claimed amount. Depositor only, while a claim
 410// stands. Accepting early is allowed — the dispute window is a deadline for
 411// disputing, not a waiting period.
 412func AcceptClaim(cur realm, id int64) {
 413	assertNoSend()
 414	d := mustAgreement(id)
 415	if cur.Previous().Address() != d.depositor {
 416		panic("depositor only")
 417	}
 418	if d.status != DepositClaimed {
 419		panic("agreement is " + d.status)
 420	}
 421	award := d.claimAmount
 422	credited, fee := settle(d, award, false)
 423	chain.Emit("Settled",
 424		"id", itoa(id),
 425		"route", "accept",
 426		"award", itoa(award),
 427		"credited", itoa(credited),
 428		"fee", itoa(fee),
 429		"refund", itoa(d.amount-award),
 430	)
 431}
 432
 433// Dispute sends a standing claim to the arbiter. Depositor only, before the
 434// dispute deadline, and only if an arbiter was named — with no arbiter there is
 435// nobody to decide, and the depositor saw that before funding. The deposit moves
 436// from `locked` into `contested`; no coins move.
 437func Dispute(cur realm, id int64, reason string) {
 438	assertNoSend()
 439	d := mustAgreement(id)
 440	if cur.Previous().Address() != d.depositor {
 441		panic("depositor only")
 442	}
 443	if d.status != DepositClaimed {
 444		panic("agreement is " + d.status)
 445	}
 446	if d.arbiter == "" {
 447		panic("no arbiter was named; a claim cannot be disputed")
 448	}
 449	now := runtime.ChainHeight()
 450	if now >= d.disputeDeadline {
 451		panic("the dispute window closed at height " + itoa(d.disputeDeadline))
 452	}
 453	if len(reason) == 0 || len(reason) > MaxReasonLen {
 454		panic("reason must be 1.." + itoa(MaxReasonLen) + " bytes")
 455	}
 456	until, ok := addChecked(now, d.disputeWindow)
 457	if !ok {
 458		panic("arbiter-deadline height overflow")
 459	}
 460	locked -= d.amount
 461	contested += d.amount
 462	d.disputeReason = reason
 463	d.arbiterDeadline = until
 464	d.status = DepositDisputed
 465
 466	chain.Emit("Disputed",
 467		"id", itoa(id),
 468		"depositor", d.depositor.String(),
 469		"arbiter", d.arbiter.String(),
 470		"claim", itoa(d.claimAmount),
 471		"arbiterDeadline", itoa(until),
 472	)
 473}
 474
 475// --- arbiter ---
 476
 477// ResolveDispute rules on a disputed claim. Arbiter only. `awardToHolder` is in
 478// [0, the claimed amount] — the arbiter may reduce a claim or deny it outright
 479// but never award more than was claimed, which is itself capped by the ceiling
 480// the depositor consented to. No deadline is enforced here: a late ruling is
 481// better than a stranded deposit, and `LapseDispute` is the race that bounds it.
 482func ResolveDispute(cur realm, id, awardToHolder int64) {
 483	assertNoSend()
 484	d := mustAgreement(id)
 485	if cur.Previous().Address() != d.arbiter {
 486		panic("arbiter only")
 487	}
 488	if d.status != DepositDisputed {
 489		panic("agreement is " + d.status)
 490	}
 491	if awardToHolder < 0 || awardToHolder > d.claimAmount {
 492		panic("award must be 0.." + itoa(d.claimAmount) + Denom)
 493	}
 494	credited, fee := settle(d, awardToHolder, true)
 495	chain.Emit("Settled",
 496		"id", itoa(id),
 497		"route", "arbiter",
 498		"award", itoa(awardToHolder),
 499		"credited", itoa(credited),
 500		"fee", itoa(fee),
 501		"refund", itoa(d.amount-awardToHolder),
 502	)
 503}
 504
 505// --- permissionless valves: no terminal state depends on a party acting ---
 506
 507// ReleaseUnclaimed refunds the whole deposit because the holder never filed a
 508// claim before the window closed. Anyone may call it.
 509func ReleaseUnclaimed(cur realm, id int64) {
 510	assertNoSend()
 511	d := mustAgreement(id)
 512	if d.status != DepositFunded {
 513		panic("agreement is " + d.status)
 514	}
 515	if runtime.ChainHeight() < d.claimDeadline {
 516		panic("the claim window is open until height " + itoa(d.claimDeadline))
 517	}
 518	settle(d, 0, false)
 519	chain.Emit("Settled",
 520		"id", itoa(id),
 521		"route", "unclaimed",
 522		"award", "0",
 523		"refund", itoa(d.amount),
 524	)
 525}
 526
 527// SettleUndisputed settles at the claimed amount because the depositor neither
 528// accepted nor disputed before the dispute window closed. Anyone may call it.
 529func SettleUndisputed(cur realm, id int64) {
 530	assertNoSend()
 531	d := mustAgreement(id)
 532	if d.status != DepositClaimed {
 533		panic("agreement is " + d.status)
 534	}
 535	if runtime.ChainHeight() < d.disputeDeadline {
 536		panic("the dispute window is open until height " + itoa(d.disputeDeadline))
 537	}
 538	award := d.claimAmount
 539	credited, fee := settle(d, award, false)
 540	chain.Emit("Settled",
 541		"id", itoa(id),
 542		"route", "undisputed",
 543		"award", itoa(award),
 544		"credited", itoa(credited),
 545		"fee", itoa(fee),
 546		"refund", itoa(d.amount-award),
 547	)
 548}
 549
 550// LapseDispute refunds the whole deposit because the arbiter did not rule before
 551// their deadline. Anyone may call it. The award is 0, not the claim: an
 552// unsubstantiated claim fails. The holder chose the arbiter and bears the cost
 553// of that choice failing — which is also why a disappeared arbiter can never
 554// strand a deposit.
 555func LapseDispute(cur realm, id int64) {
 556	assertNoSend()
 557	d := mustAgreement(id)
 558	if d.status != DepositDisputed {
 559		panic("agreement is " + d.status)
 560	}
 561	if runtime.ChainHeight() < d.arbiterDeadline {
 562		panic("the arbiter has until height " + itoa(d.arbiterDeadline))
 563	}
 564	settle(d, 0, true)
 565	chain.Emit("Settled",
 566		"id", itoa(id),
 567		"route", "lapsed",
 568		"award", "0",
 569		"refund", itoa(d.amount),
 570	)
 571}
 572
 573// CancelUnfunded abandons an agreement the depositor never funded. Either party
 574// may cancel at any time — refusing to fund is the depositor's right and
 575// withdrawing the offer is the holder's — and ANYONE may cancel once the fund
 576// deadline has passed, so a dead offer cannot hold a slot forever. No coins have
 577// ever been held, so nothing moves.
 578func CancelUnfunded(cur realm, id int64) {
 579	assertNoSend()
 580	d := mustAgreement(id)
 581	if d.status != DepositOpen {
 582		panic("agreement is " + d.status)
 583	}
 584	caller := cur.Previous().Address()
 585	party := caller == d.holder || caller == d.depositor
 586	if !party && runtime.ChainHeight() < d.fundDeadline {
 587		panic("only the holder or the depositor may cancel before height " + itoa(d.fundDeadline))
 588	}
 589	d.status = DepositCancelled
 590	chain.Emit("Cancelled", "id", itoa(id), "by", caller.String())
 591}
 592
 593// --- claims (pull: refunds, awards and fees) ---
 594
 595// Claim sends amount ugnot of the caller's claimable balance — refunds and
 596// awards from settled agreements — to the caller.
 597func Claim(cur realm, amount int64) {
 598	assertNoSend()
 599	caller := cur.Previous().Address()
 600	if err := ledger.Withdraw(caller.String(), amount); err != nil {
 601		panic(err)
 602	}
 603	coinio.Payout(0, cur, caller, Denom, amount)
 604	chain.Emit("Claim", "to", caller.String(), "amount", itoa(amount))
 605}
 606
 607// ClaimAll sends the caller's entire claimable balance to the caller.
 608func ClaimAll(cur realm) {
 609	assertNoSend()
 610	caller := cur.Previous().Address()
 611	amount, err := ledger.WithdrawAll(caller.String())
 612	if err != nil {
 613		panic(err)
 614	}
 615	if amount == 0 {
 616		panic("nothing to claim")
 617	}
 618	coinio.Payout(0, cur, caller, Denom, amount)
 619	chain.Emit("Claim", "to", caller.String(), "amount", itoa(amount))
 620}
 621
 622// --- administration (two-step transfer, the house pattern) ---
 623
 624func SetFeeBps(cur realm, bps int64) {
 625	assertNoSend()
 626	if cur.Previous().Address() != admin {
 627		panic("admin only")
 628	}
 629	if bps < 0 || bps > MaxFeeBps {
 630		panic("fee must be 0.." + itoa(MaxFeeBps) + " bps")
 631	}
 632	feeBps = bps
 633	chain.Emit("FeeSet", "bps", itoa(bps))
 634}
 635
 636// SetFeeRecipient names the address that may withdraw the accrued fee pot.
 637//
 638// It deliberately does NOT enforce disjointness from agreement parties, in
 639// contrast to OpenAgreement, which refuses an arbiter equal to either party.
 640// The two rules are not comparable, and the asymmetry is the point:
 641//
 642//   - The arbiter rule is per-agreement and checked at the moment the
 643//     agreement is created, so it is an invariant of that agreement for life.
 644//   - The fee recipient is one global over an unbounded, growing set of
 645//     agreements. A check here could only test a snapshot, and the very next
 646//     OpenAgreement could name the sitting recipient as holder or depositor.
 647//     A guard that any later transaction can walk through is worse than no
 648//     guard: it reads as an invariant and is not one.
 649//
 650// Nothing is at stake in the overlap, which is why the honest answer is a
 651// documented omission rather than a decorative check. The fee is derived only
 652// from the holder's award inside ledger.MustDeposit, never from the refund, so
 653// a recipient who is also a holder collects the same coins by two routes
 654// instead of more coins. Solvency is unaffected in either direction:
 655// feeledger accounts user credit and the fee pot as separate buckets, both are
 656// inside ledger.Liabilities(), and SweepDenom's reserve is
 657// locked + contested + ledger.Liabilities() — so an overlapping recipient
 658// cannot make the swept surplus overstate what the realm is free to release.
 659func SetFeeRecipient(cur realm, recipient address) {
 660	assertNoSend()
 661	if cur.Previous().Address() != admin {
 662		panic("admin only")
 663	}
 664	if !recipient.IsValid() {
 665		panic("invalid recipient")
 666	}
 667	feeRecipient = recipient
 668	chain.Emit("FeeRecipientSet", "to", recipient.String())
 669}
 670
 671func WithdrawFees(cur realm) {
 672	assertNoSend()
 673	if cur.Previous().Address() != feeRecipient {
 674		panic("fee recipient only")
 675	}
 676	amount := ledger.WithdrawFees()
 677	if amount == 0 {
 678		panic("no fees accrued")
 679	}
 680	coinio.Payout(0, cur, feeRecipient, Denom, amount)
 681	chain.Emit("FeesWithdrawn", "to", feeRecipient.String(), "amount", itoa(amount))
 682}
 683
 684// SweepDenom recovers out-of-band coins to the fee recipient. For the ledger
 685// denom the reserve is locked + contested + Liabilities() — held deposits,
 686// contested deposits, claimable refunds, awards and the fee pot are all
 687// structurally unreachable.
 688func SweepDenom(cur realm, denom string) {
 689	assertNoSend()
 690	if cur.Previous().Address() != feeRecipient {
 691		panic("fee recipient only")
 692	}
 693	reserve := int64(0)
 694	if denom == Denom {
 695		reserve = locked + contested + ledger.Liabilities()
 696	}
 697	swept := coinio.Sweep(0, cur, feeRecipient, denom, reserve)
 698	chain.Emit("SurplusSwept", "to", feeRecipient.String(), "coins", itoa(swept)+denom)
 699}
 700
 701func TransferAdmin(cur realm, successor address) {
 702	assertNoSend()
 703	if cur.Previous().Address() != admin {
 704		panic("admin only")
 705	}
 706	if !successor.IsValid() {
 707		panic("invalid successor")
 708	}
 709	pendingAdmin = successor
 710	chain.Emit("AdminTransferStaged", "to", successor.String())
 711}
 712
 713func AcceptAdmin(cur realm) {
 714	assertNoSend()
 715	caller := cur.Previous().Address()
 716	if pendingAdmin == "" || caller != pendingAdmin {
 717		panic("no admin transfer staged for caller")
 718	}
 719	admin = pendingAdmin
 720	pendingAdmin = ""
 721	chain.Emit("AdminTransferred", "to", caller.String())
 722}
 723
 724// --- views (non-crossing: cross-realm readable) ---
 725
 726// AgreementInfo returns the whole agreement in one read, so a depositor
 727// deciding whether to fund does not have to compose nine calls.
 728func AgreementInfo(id int64) (holder, depositor, arbiter address, amount, forfeitCeiling, feeBps, termEnd, claimDeadline, disputeDeadline, arbiterDeadline, claimAmount, award int64, status string) {
 729	d := mustAgreement(id)
 730	return d.holder, d.depositor, d.arbiter, d.amount, d.forfeitCeiling, d.feeBps,
 731		d.termEnd, d.claimDeadline, d.disputeDeadline, d.arbiterDeadline,
 732		d.claimAmount, d.award, d.status
 733}
 734
 735func Memo(id int64) string          { return mustAgreement(id).memo }
 736func ClaimReason(id int64) string   { return mustAgreement(id).claimReason }
 737func DisputeReason(id int64) string { return mustAgreement(id).disputeReason }
 738
 739func Holder(id int64) address       { return mustAgreement(id).holder }
 740func Depositor(id int64) address    { return mustAgreement(id).depositor }
 741func Arbiter(id int64) address      { return mustAgreement(id).arbiter }
 742func Amount(id int64) int64         { return mustAgreement(id).amount }
 743func ForfeitCeiling(id int64) int64 { return mustAgreement(id).forfeitCeiling }
 744func FeeBpsOf(id int64) int64       { return mustAgreement(id).feeBps }
 745func Status(id int64) string        { return mustAgreement(id).status }
 746func ClaimAmount(id int64) int64    { return mustAgreement(id).claimAmount }
 747func Award(id int64) int64          { return mustAgreement(id).award }
 748func TermEnd(id int64) int64        { return mustAgreement(id).termEnd }
 749func ClaimDeadline(id int64) int64  { return mustAgreement(id).claimDeadline }
 750func FundDeadline(id int64) int64   { return mustAgreement(id).fundDeadline }
 751
 752// DisputeDeadlineOf is the last height at which the depositor may Dispute a
 753// standing claim; ArbiterDeadlineOf is the last height at which the arbiter's
 754// ruling pre-empts LapseDispute. Both are 0 until the relevant stage is live.
 755func DisputeDeadlineOf(id int64) int64 { return mustAgreement(id).disputeDeadline }
 756func ArbiterDeadlineOf(id int64) int64 { return mustAgreement(id).arbiterDeadline }
 757
 758func CreatedHeight(id int64) int64 { return mustAgreement(id).createdHeight }
 759func FundedHeight(id int64) int64  { return mustAgreement(id).fundedHeight }
 760func SettledHeight(id int64) int64 { return mustAgreement(id).settledHeight }
 761
 762// Refund is the amount the depositor gets back, known only once settled.
 763func Refund(id int64) int64 {
 764	d := mustAgreement(id)
 765	if d.status != DepositSettled {
 766		panic("agreement is " + d.status + "; the split is not decided")
 767	}
 768	return d.amount - d.award
 769}
 770
 771// MaxClaimable is the most the holder could still take out of this agreement.
 772// It is the ceiling while the deposit is funded, the filed claim once one
 773// stands, and 0 once the agreement is terminal.
 774func MaxClaimable(id int64) int64 {
 775	d := mustAgreement(id)
 776	switch d.status {
 777	case DepositOpen, DepositFunded:
 778		return d.forfeitCeiling
 779	case DepositClaimed, DepositDisputed:
 780		return d.claimAmount
 781	default:
 782		return 0
 783	}
 784}
 785
 786func IsSettled(id int64) bool { return mustAgreement(id).status == DepositSettled }
 787
 788// IsClaimable reports whether FileClaim would be accepted at this height.
 789func IsClaimable(id int64) bool {
 790	d := mustAgreement(id)
 791	now := runtime.ChainHeight()
 792	return d.status == DepositFunded && now >= d.termEnd && now < d.claimDeadline
 793}
 794
 795// IsDisputable reports whether Dispute would be accepted at this height.
 796func IsDisputable(id int64) bool {
 797	d := mustAgreement(id)
 798	return d.status == DepositClaimed && d.arbiter != "" && runtime.ChainHeight() < d.disputeDeadline
 799}
 800
 801// PendingValve names the permissionless call this agreement is ready for, or ""
 802// when none is. It is the one view a keeper needs.
 803func PendingValve(id int64) string {
 804	d := mustAgreement(id)
 805	now := runtime.ChainHeight()
 806	switch d.status {
 807	case DepositOpen:
 808		if now >= d.fundDeadline {
 809			return "CancelUnfunded"
 810		}
 811	case DepositFunded:
 812		if now >= d.claimDeadline {
 813			return "ReleaseUnclaimed"
 814		}
 815	case DepositClaimed:
 816		if now >= d.disputeDeadline {
 817			return "SettleUndisputed"
 818		}
 819	case DepositDisputed:
 820		if now >= d.arbiterDeadline {
 821			return "LapseDispute"
 822		}
 823	}
 824	return ""
 825}
 826
 827func HasAgreement(id int64) bool { return agreements.Get(padID(id)) != nil }
 828
 829func AgreementsOf(holder address) int64 {
 830	v := holderNum.Get(holder.String())
 831	if v == nil {
 832		return 0
 833	}
 834	return *v.(*int64)
 835}
 836
 837func Admin() address              { return admin }
 838func PendingAdmin() address       { return pendingAdmin }
 839func FeeRecipient() address       { return feeRecipient }
 840func FeeBps() int64               { return feeBps }
 841func NumAgreements() int64        { return nextID }
 842func ClaimableOf(a address) int64 { return ledger.BalanceOf(a.String()) }
 843func FeesAccrued() int64          { return ledger.FeesAccrued() }
 844func Liabilities() int64          { return ledger.Liabilities() }
 845func Locked() int64               { return locked }
 846func Contested() int64            { return contested }
 847func Held() int64                 { return coinio.HeldAt(self, Denom) }
 848func Address() address            { return self }
 849func Height() int64               { return runtime.ChainHeight() }
 850
 851// --- internals ---
 852
 853// settle credits the split and moves the deposit out of its custody bucket. The
 854// caller has already established the route's authority and timing; `award` is in
 855// [0, amount] and `fromContested` says which bucket the deposit sits in.
 856//
 857// Conservation, in delta form: Held is unchanged; locked OR contested falls by
 858// amount; Liabilities rises by award + (amount-award) == amount. So
 859// Held == locked + contested + Liabilities is preserved exactly.
 860//
 861// The fee is charged ONLY on the award. The refund is deposited at fee 0, so a
 862// fully-refunded deposit returns to the depositor in full.
 863func settle(d *agreement, award int64, fromContested bool) (credited, fee int64) {
 864	if award < 0 || award > d.amount {
 865		// Unreachable: every route bounds the award by claimAmount, itself
 866		// bounded by forfeitCeiling <= amount. Kept as a solvency backstop.
 867		panic("award out of range")
 868	}
 869	if fromContested {
 870		contested -= d.amount
 871	} else {
 872		locked -= d.amount
 873	}
 874	if award > 0 {
 875		credited, fee = ledger.MustDeposit(d.holder.String(), award, d.feeBps)
 876	}
 877	if refund := d.amount - award; refund > 0 {
 878		ledger.MustDeposit(d.depositor.String(), refund, 0)
 879	}
 880	d.award = award
 881	d.settledHeight = runtime.ChainHeight()
 882	d.status = DepositSettled
 883	return credited, fee
 884}
 885
 886func mustAgreement(id int64) *agreement {
 887	v := agreements.Get(padID(id))
 888	if v == nil {
 889		panic("unknown agreement " + itoa(id))
 890	}
 891	return v.(*agreement)
 892}
 893
 894func assertNoSend() {
 895	if !unsafe.OriginSend().IsZero() {
 896		panic("this function does not accept coins")
 897	}
 898}
 899
 900func counter(tree *avl.Tree, a address) *int64 {
 901	k := a.String()
 902	if v := tree.Get(k); v != nil {
 903		return v.(*int64)
 904	}
 905	n := int64(0)
 906	tree.Set(k, &n)
 907	return &n
 908}
 909
 910func addChecked(a, b int64) (int64, bool) {
 911	s := a + b
 912	if (b > 0 && s < a) || (b < 0 && s > a) {
 913		return 0, false
 914	}
 915	return s, true
 916}
 917
 918func padID(id int64) string {
 919	s := strconv.FormatInt(id, 10)
 920	for len(s) < 12 {
 921		s = "0" + s
 922	}
 923	return s
 924}
 925
 926func itoa(n int64) string { return strconv.FormatInt(n, 10) }
 927
 928func addrStr(a address) string {
 929	if a == "" {
 930		return "(none)"
 931	}
 932	return a.String()
 933}
 934
 935func hasPrefix(s, p string) bool { return len(s) >= len(p) && s[:len(p)] == p }
 936
 937// --- Render ---
 938
 939func Render(path string) string {
 940	switch {
 941	case path == "":
 942		return renderHome()
 943	case hasPrefix(path, "agreement/"):
 944		return renderAgreement(path[len("agreement/"):])
 945	default:
 946		return "unknown path; try \"\" or \"agreement/<id>\"\n"
 947	}
 948}
 949
 950func renderHome() string {
 951	out := "# deposits — refundable security deposits with condition-gated forfeiture\n\n"
 952	out += "Agreements: " + itoa(nextID) + " · held deposits: " + itoa(locked) + Denom +
 953		" · contested: " + itoa(contested) + Denom +
 954		" · claimable+fees: " + itoa(ledger.Liabilities()) + Denom +
 955		" · fees accrued: " + itoa(ledger.FeesAccrued()) + Denom + "\n\n"
 956	out += "A holder offers an agreement with a **forfeit ceiling**; the depositor consents by funding it. " +
 957		"At the end of the term the holder may claim up to the ceiling, the depositor may accept or dispute, " +
 958		"and whatever is not awarded is refunded. Settlements are pull-claimable, never pushed.\n\n"
 959	out += "| id | holder | depositor | amount | ceiling | claim | status |\n|---|---|---|---|---|---|---|\n"
 960	shown := int64(0)
 961	for id := nextID - 1; id >= 0 && shown < RenderLimit; id-- {
 962		v := agreements.Get(padID(id))
 963		if v == nil {
 964			continue
 965		}
 966		d := v.(*agreement)
 967		out += "| " + itoa(d.id) + " | " + sanitize.TableCell(d.holder.String()) + " | " +
 968			sanitize.TableCell(d.depositor.String()) + " | " + itoa(d.amount) + Denom + " | " +
 969			itoa(d.forfeitCeiling) + Denom + " | " + itoa(d.claimAmount) + Denom + " | " +
 970			d.status + " |\n"
 971		shown++
 972	}
 973	if nextID > shown {
 974		out += "\n(" + itoa(shown) + " most recent of " + itoa(nextID) + ")\n"
 975	}
 976	return out
 977}
 978
 979func renderAgreement(idStr string) string {
 980	id, err := strconv.ParseInt(idStr, 10, 64)
 981	if err != nil {
 982		return "bad agreement id\n"
 983	}
 984	v := agreements.Get(padID(id))
 985	if v == nil {
 986		return "unknown agreement " + idStr + "\n"
 987	}
 988	d := v.(*agreement)
 989	now := runtime.ChainHeight()
 990	// These four slots carry an own-package status constant and bech32 addresses,
 991	// so sanitize.InlineText is the IDENTITY on every reachable input and this is
 992	// not an injection repair. It is here because renderHome wraps the same two
 993	// addresses (as sanitize.TableCell, the table-context analogue) and an
 994	// asymmetry between two renderers over one data set is how the wrapped side
 995	// gets deleted later to "match". The wrap is what survives a refactor that
 996	// puts non-constant data in a slot; tools/test_deposits_sabotage.sh footer
 997	// non-case (e) records that no fixture can distinguish it today.
 998	out := "# agreement " + itoa(d.id) + " — " + sanitize.InlineText(d.status) + "\n\n"
 999	out += "- holder: " + sanitize.InlineText(d.holder.String()) + "\n"
1000	out += "- depositor: " + sanitize.InlineText(d.depositor.String()) + "\n"
1001	out += "- arbiter: " + sanitize.InlineText(addrStr(d.arbiter))
1002	if d.arbiter == "" {
1003		out += " — **a claim cannot be disputed**"
1004	}
1005	out += "\n"
1006	if d.memo != "" {
1007		out += "- memo: " + sanitize.InlineText(d.memo) + "\n"
1008	}
1009	out += "- deposit: " + itoa(d.amount) + Denom + " · forfeit ceiling: " + itoa(d.forfeitCeiling) +
1010		Denom + " · fee on the award: " + itoa(d.feeBps) + " bps\n"
1011	out += "- term " + itoa(d.termBlocks) + " blocks · claim window " + itoa(d.claimWindow) +
1012		" · dispute window " + itoa(d.disputeWindow) + " · opened at height " + itoa(d.createdHeight) + "\n"
1013
1014	switch d.status {
1015	case DepositOpen:
1016		out += "- awaiting funding by the depositor until height " + itoa(d.fundDeadline) + "\n"
1017	case DepositFunded:
1018		if now < d.termEnd {
1019			out += "- term running · ends at height " + itoa(d.termEnd) +
1020				" (" + itoa(d.termEnd-now) + " blocks)\n"
1021		} else if now < d.claimDeadline {
1022			out += "- claim window OPEN until height " + itoa(d.claimDeadline) +
1023				" · the holder may claim up to " + itoa(d.forfeitCeiling) + Denom + "\n"
1024		} else {
1025			out += "- no claim was filed · anyone may call ReleaseUnclaimed for a full refund\n"
1026		}
1027	case DepositClaimed:
1028		out += "- claim: " + itoa(d.claimAmount) + Denom + " — " + sanitize.InlineText(d.claimReason) + "\n"
1029		if now < d.disputeDeadline {
1030			out += "- the depositor may accept or dispute until height " + itoa(d.disputeDeadline) + "\n"
1031		} else {
1032			out += "- dispute window closed · anyone may call SettleUndisputed\n"
1033		}
1034	case DepositDisputed:
1035		out += "- claim: " + itoa(d.claimAmount) + Denom + " — " + sanitize.InlineText(d.claimReason) + "\n"
1036		out += "- disputed: " + sanitize.InlineText(d.disputeReason) + "\n"
1037		if now < d.arbiterDeadline {
1038			out += "- the arbiter rules by height " + itoa(d.arbiterDeadline) + "\n"
1039		} else {
1040			out += "- the arbiter did not rule · anyone may call LapseDispute for a full refund\n"
1041		}
1042	case DepositSettled:
1043		out += "- settled at height " + itoa(d.settledHeight) + " · awarded to the holder " +
1044			itoa(d.award) + Denom + " · refunded to the depositor " + itoa(d.amount-d.award) + Denom + "\n"
1045		out += "- both sides are pull-claimable with Claim / ClaimAll\n"
1046	case DepositCancelled:
1047		out += "- cancelled before funding · no coins were ever held\n"
1048	}
1049	return out
1050}