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}