loans.gno
20.60 Kb · 637 lines
1// Realm loans is an unsecured term loan with a fixed installment schedule and
2// a flat finance charge, holding real ugnot. A LENDER opens a loan to a named
3// BORROWER by attaching the principal, which the realm disburses to the
4// borrower in the same transaction; the borrower REPAYS over a fixed number of
5// installments; each repayment credits the lender's pull-claimable balance,
6// the protocol fee splitting off at the ledger. A loan whose installments go
7// unpaid past a grace window is permissionlessly DEFAULT-able — a recorded
8// state, not a seizure, because v1 is unsecured.
9//
10// It is the one financial primitive this portfolio never had: CREDIT. The
11// accounting realms RECORD obligations (r/invoices, r/receivables — p/aging's
12// own doc lists "invoices, loans, dues") and the disbursement realms pay a
13// KNOWN TOTAL out on a schedule (r/vesting, r/payroll, r/budgets). A loan is
14// the asymmetric opposite: principal OUT once, a LARGER total back over time,
15// plus the finance charge and the default state a disbursement realm has no
16// use for. See [onyx/r/loans/DISCOVERY.md].
17//
18// # Custody — the conservation invariant is the product
19//
20// This is the first custodial realm the factory has selected since the
21// publishers (r/latefees, r/cashflow), so the monetary invariant governs it
22// exactly as it does r/subscriptions and r/meters:
23//
24// Held == Σ lender-claimable + fee pot (== feeledger.Liabilities())
25//
26// The principal is NEVER held across a transaction: [Open] receives it and
27// pays it out to the borrower in the same call, so the only coins this realm
28// ever holds are repayments awaiting the lender's [Claim]. A stuck or
29// abandoned loan therefore strands no principal — it was disbursed — and the
30// only value at risk is the lender's expected return, which is the real nature
31// of unsecured credit and is disclosed, not engineered away.
32//
33// # The finance charge is FLAT in v1, and that is a scope decision
34//
35// The total repayable is `principal + floor(principal × rateBps / 10000)` — a
36// flat charge over the whole term, NOT compounding and NOT accruing per block.
37// Per-installment is `ceil(total / installments)` with the rounding remainder
38// on the FINAL installment, so the installments sum to exactly the total (the
39// cross-foot). Compounding interest and a tiered-by-duration rate (which
40// p/tiers could price) are a v2: pricing them in v1 would add an accrual
41// surface whose bugs are the ones lending audits most often find.
42//
43// # Default is a state, not a seizure — there is no collateral in v1
44//
45// A borrower past the grace window who has not repaid in full is Default-able
46// by anyone (like the sibling realms' valves). It freezes the loan and records
47// the shortfall; no coins move, because there is nothing to seize. Secured
48// lending is a v2.
49//
50// REALM-CALLER CAVEAT, inherited from the siblings: coinio's receipt guard
51// admits only EOA payers, so the LENDER (who funds Open) and any REPAYER (who
52// funds Repay) are EOAs. The BORROWER is a parameter — coinio.Payout delivers
53// the principal to any valid address, including a realm — but a realm borrower
54// could never Repay (Repay is payable, EOA-only), so naming one is the
55// lender's own footgun (an un-repayable gift), not a realm or third-party
56// hazard. assertNoSend reads the ORIGIN envelope, so every non-payable
57// function refuses any transaction that attached coins anywhere.
58package loans
59
60import (
61 "chain"
62 "chain/runtime"
63 "chain/runtime/unsafe"
64 "strconv"
65
66 "gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/coinio"
67 "gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/feeledger"
68 "gno.land/p/nt/avl/v0"
69 "gno.land/p/nt/markdown/sanitize/v0"
70)
71
72// Denom is the only asset this realm accepts.
73const Denom = "ugnot"
74
75// MaxFeeBps is the hard protocol-fee cap: 1000 bps = 10%.
76const MaxFeeBps = int64(1000)
77
78// Loan status values. Repaid and Defaulted are terminal.
79const (
80 LoanActive = "active"
81 LoanRepaid = "repaid"
82 LoanDefaulted = "defaulted"
83)
84
85// Input bounds.
86const (
87 // MinPrincipal keeps a loan from being dust; MaxPrincipal keeps
88 // principal × rateBps and the total far from int64 overflow (the
89 // checked arithmetic still guards it). The lender must ATTACH the
90 // principal, so the real ceiling is their balance.
91 MinPrincipal = int64(1_000)
92 MaxPrincipal = int64(1_000_000_000_000) // 1,000,000 GNOT
93
94 // MaxRateBps caps the flat finance charge at 100% of principal over the
95 // whole term. A 0 rate is a legitimate interest-free loan.
96 MaxRateBps = int64(10_000)
97
98 // Installment-count and period bounds. The period bounds match the
99 // sibling realms: the floor keeps repayment off a per-block treadmill,
100 // the ceiling keeps schedule arithmetic far from overflow.
101 MinInstallments = int64(1)
102 MaxInstallments = int64(60)
103 MinPeriodBlocks = int64(10)
104 MaxPeriodBlocks = int64(7_500_000)
105
106 // MaxLoansPerLender bounds one lender's stored loans; terminal loans do
107 // NOT free the slot, because a Repaid/Defaulted loan is a permanent
108 // record a borrower and auditor may need to read.
109 MaxLoansPerLender = 50
110
111 // RenderLimit bounds every rendered list — an unbounded Render was
112 // YELLOW in three prior audits.
113 RenderLimit = 20
114)
115
116type loan struct {
117 id int64
118 lender address
119 borrower address
120 principal int64
121 total int64 // principal + finance charge, the sum repayable
122 installments int64
123 periodBlocks int64
124 feeBps int64 // snapshotted at Open, split off each repayment
125 startHeight int64 // disbursement height; installment k due at start + k·period
126 repaid int64 // cumulative repaid toward total
127 status string
128}
129
130var (
131 admin address
132 pendingAdmin address
133 feeRecipient address
134 feeBps int64 // snapshotted into NEW loans
135
136 self address
137 nextID int64
138
139 loans = avl.NewTree() // padID(id) -> *loan
140 lenderNum = avl.NewTree() // lender.String() -> *int64, stored loans
141 ledger = feeledger.MustNew(MaxFeeBps)
142)
143
144func init() {
145 admin = unsafe.OriginCaller()
146 feeRecipient = admin
147 self = unsafe.CurrentRealm().Address()
148}
149
150// --- lender side ---
151
152// Open funds a loan: the caller (the lender) attaches the principal, which is
153// disbursed to borrower in this same transaction, and the loan is recorded
154// with a flat finance charge of rateBps of the principal, repayable over
155// `installments` installments of `periodBlocks` blocks each. The current
156// protocol fee is snapshotted in. Returns the loan id.
157func Open(cur realm, borrower address, rateBps, installments, periodBlocks int64) int64 {
158 lender, principal := coinio.Receive(0, cur, Denom)
159 if !borrower.IsValid() {
160 panic("invalid borrower")
161 }
162 if borrower == lender {
163 panic("lender and borrower must differ")
164 }
165 if principal < MinPrincipal || principal > MaxPrincipal {
166 panic("principal must be " + itoa(MinPrincipal) + ".." + itoa(MaxPrincipal) + Denom)
167 }
168 if rateBps < 0 || rateBps > MaxRateBps {
169 panic("rateBps must be 0.." + itoa(MaxRateBps))
170 }
171 if installments < MinInstallments || installments > MaxInstallments {
172 panic("installments must be " + itoa(MinInstallments) + ".." + itoa(MaxInstallments))
173 }
174 if periodBlocks < MinPeriodBlocks || periodBlocks > MaxPeriodBlocks {
175 panic("periodBlocks must be " + itoa(MinPeriodBlocks) + ".." + itoa(MaxPeriodBlocks))
176 }
177 charge, ok := mulDivChecked(principal, rateBps, 10_000)
178 if !ok {
179 panic("finance charge overflow")
180 }
181 total, ok := addChecked(principal, charge)
182 if !ok {
183 panic("total overflow")
184 }
185
186 n := counter(lenderNum, lender)
187 if *n >= MaxLoansPerLender {
188 panic("per-lender loan limit reached")
189 }
190
191 // Disburse the principal to the borrower in this same transaction, so the
192 // realm holds nothing from it. coinio.Payout panics on failure, reverting
193 // the receipt above with it.
194 coinio.Payout(0, cur, borrower, Denom, principal)
195
196 id := nextID
197 nextID++
198 loans.Set(padID(id), &loan{
199 id: id,
200 lender: lender,
201 borrower: borrower,
202 principal: principal,
203 total: total,
204 installments: installments,
205 periodBlocks: periodBlocks,
206 feeBps: feeBps,
207 startHeight: runtime.ChainHeight(),
208 status: LoanActive,
209 })
210 *n++
211
212 chain.Emit("LoanOpened",
213 "loanId", itoa(id),
214 "lender", lender.String(),
215 "borrower", borrower.String(),
216 "principal", itoa(principal),
217 "total", itoa(total),
218 "installments", itoa(installments),
219 "feeBps", itoa(feeBps),
220 )
221 return id
222}
223
224// Claim sends amount ugnot of the caller's claimable balance (repayments
225// collected on their loans, net of the protocol fee) back to the caller.
226func Claim(cur realm, amount int64) {
227 assertNoSend()
228 caller := cur.Previous().Address()
229 if err := ledger.Withdraw(caller.String(), amount); err != nil {
230 panic(err)
231 }
232 coinio.Payout(0, cur, caller, Denom, amount)
233 chain.Emit("Claim", "to", caller.String(), "amount", itoa(amount))
234}
235
236// ClaimAll sends the caller's entire claimable balance back to the caller.
237func ClaimAll(cur realm) {
238 assertNoSend()
239 caller := cur.Previous().Address()
240 amount, err := ledger.WithdrawAll(caller.String())
241 if err != nil {
242 panic(err)
243 }
244 if amount == 0 {
245 panic("nothing to claim")
246 }
247 coinio.Payout(0, cur, caller, Denom, amount)
248 chain.Emit("Claim", "to", caller.String(), "amount", itoa(amount))
249}
250
251// --- borrower side ---
252
253// Repay pays toward the caller's own loan. The caller must be the loan's
254// borrower; the attached amount must be positive and must not exceed the
255// outstanding balance. The payment credits the lender's claimable balance (the
256// protocol fee splitting off at the ledger); when the cumulative repaid equals
257// the total, the loan is Repaid. Repaying ahead of schedule is allowed.
258func Repay(cur realm, loanID int64) {
259 payer, amount := coinio.Receive(0, cur, Denom)
260 l := mustLoan(loanID)
261 if payer != l.borrower {
262 panic("only the borrower may repay this loan")
263 }
264 if l.status != LoanActive {
265 panic("loan is " + l.status)
266 }
267 if amount <= 0 {
268 panic("attach a positive repayment")
269 }
270 outstanding := l.total - l.repaid
271 if amount > outstanding {
272 panic("repayment " + itoa(amount) + " exceeds the outstanding balance " + itoa(outstanding))
273 }
274
275 credited, fee := ledger.MustDeposit(l.lender.String(), amount, l.feeBps)
276 l.repaid += amount
277 if l.repaid == l.total {
278 l.status = LoanRepaid
279 }
280
281 chain.Emit("Repaid",
282 "loanId", itoa(loanID),
283 "borrower", l.borrower.String(),
284 "amount", itoa(amount),
285 "repaidTotal", itoa(l.repaid),
286 "credited", itoa(credited),
287 "fee", itoa(fee),
288 "status", l.status,
289 )
290}
291
292// Default marks an active loan defaulted once it is past its grace window —
293// the final installment's due height plus one more period — and not repaid in
294// full. Permissionless by design: no loan's terminal state may depend on
295// either party acting. No coins move; the shortfall is recorded.
296func Default(cur realm, loanID int64) {
297 assertNoSend()
298 l := mustLoan(loanID)
299 if l.status != LoanActive {
300 panic("loan is " + l.status)
301 }
302 if runtime.ChainHeight() < defaultableAt(l) {
303 panic("not defaultable until height " + itoa(defaultableAt(l)))
304 }
305 l.status = LoanDefaulted
306 chain.Emit("Defaulted",
307 "loanId", itoa(loanID),
308 "lender", l.lender.String(),
309 "borrower", l.borrower.String(),
310 "shortfall", itoa(l.total-l.repaid),
311 )
312}
313
314// --- administration (two-step transfer, the house pattern) ---
315
316func SetFeeBps(cur realm, bps int64) {
317 assertNoSend()
318 if cur.Previous().Address() != admin {
319 panic("admin only")
320 }
321 if bps < 0 || bps > MaxFeeBps {
322 panic("fee must be 0.." + itoa(MaxFeeBps) + " bps")
323 }
324 feeBps = bps
325 chain.Emit("FeeSet", "bps", itoa(bps))
326}
327
328func SetFeeRecipient(cur realm, recipient address) {
329 assertNoSend()
330 if cur.Previous().Address() != admin {
331 panic("admin only")
332 }
333 if !recipient.IsValid() {
334 panic("invalid recipient")
335 }
336 feeRecipient = recipient
337 chain.Emit("FeeRecipientSet", "to", recipient.String())
338}
339
340func WithdrawFees(cur realm) {
341 assertNoSend()
342 if cur.Previous().Address() != feeRecipient {
343 panic("fee recipient only")
344 }
345 amount := ledger.WithdrawFees()
346 if amount == 0 {
347 panic("no fees accrued")
348 }
349 coinio.Payout(0, cur, feeRecipient, Denom, amount)
350 chain.Emit("FeesWithdrawn", "to", feeRecipient.String(), "amount", itoa(amount))
351}
352
353// SweepDenom recovers out-of-band coins to the fee recipient. For the ledger
354// denom the reserve is Liabilities() — lender balances and the fee pot are
355// structurally unreachable.
356func SweepDenom(cur realm, denom string) {
357 assertNoSend()
358 if cur.Previous().Address() != feeRecipient {
359 panic("fee recipient only")
360 }
361 reserve := int64(0)
362 if denom == Denom {
363 reserve = ledger.Liabilities()
364 }
365 swept := coinio.Sweep(0, cur, feeRecipient, denom, reserve)
366 chain.Emit("SurplusSwept", "to", feeRecipient.String(), "coins", itoa(swept)+denom)
367}
368
369func TransferAdmin(cur realm, successor address) {
370 assertNoSend()
371 if cur.Previous().Address() != admin {
372 panic("admin only")
373 }
374 if !successor.IsValid() {
375 panic("invalid successor")
376 }
377 pendingAdmin = successor
378 chain.Emit("AdminTransferStaged", "to", successor.String())
379}
380
381func AcceptAdmin(cur realm) {
382 assertNoSend()
383 caller := cur.Previous().Address()
384 if pendingAdmin == "" || caller != pendingAdmin {
385 panic("no admin transfer staged for caller")
386 }
387 admin = pendingAdmin
388 pendingAdmin = ""
389 chain.Emit("AdminTransferred", "to", caller.String())
390}
391
392// --- views (non-crossing: cross-realm readable) ---
393
394// LoanInfo returns a loan's terms and state.
395func LoanInfo(loanID int64) (lender, borrower address, principal, total, repaid, installments, periodBlocks, startHeight int64, status string) {
396 l := mustLoan(loanID)
397 return l.lender, l.borrower, l.principal, l.total, l.repaid, l.installments, l.periodBlocks, l.startHeight, l.status
398}
399
400// InstallmentAmount is the per-installment amount: ceil(total/installments),
401// with the rounding remainder carried on the FINAL installment so the
402// installments sum to exactly the total.
403func InstallmentAmount(loanID int64) int64 {
404 l := mustLoan(loanID)
405 return ceilDiv(l.total, l.installments)
406}
407
408// Outstanding is total − repaid.
409func Outstanding(loanID int64) int64 {
410 l := mustLoan(loanID)
411 return l.total - l.repaid
412}
413
414// ScheduledDue is the cumulative amount that should have been repaid by the
415// current height: the sum of every installment whose due height has passed,
416// capped at the total. Installment k (1-based) is due at start + k·period.
417func ScheduledDue(loanID int64) int64 {
418 l := mustLoan(loanID)
419 return scheduledDue(l, runtime.ChainHeight())
420}
421
422// AmountDueNow is how far behind schedule the borrower is right now:
423// ScheduledDue − repaid, floored at zero (a borrower who has prepaid is not
424// behind).
425func AmountDueNow(loanID int64) int64 {
426 l := mustLoan(loanID)
427 due := scheduledDue(l, runtime.ChainHeight()) - l.repaid
428 if due < 0 {
429 return 0
430 }
431 return due
432}
433
434// DefaultableAt is the height at or after which an unpaid loan may be
435// Defaulted: the final installment's due height plus one grace period.
436func DefaultableAt(loanID int64) int64 { return defaultableAt(mustLoan(loanID)) }
437
438// IsDefaultable reports whether the loan may be Defaulted right now.
439func IsDefaultable(loanID int64) bool {
440 l := mustLoan(loanID)
441 return l.status == LoanActive && runtime.ChainHeight() >= defaultableAt(l) && l.repaid < l.total
442}
443
444func HasLoan(loanID int64) bool { return loans.Get(padID(loanID)) != nil }
445
446func LoansOf(lender address) int64 {
447 v := lenderNum.Get(lender.String())
448 if v == nil {
449 return 0
450 }
451 return *v.(*int64)
452}
453
454func Admin() address { return admin }
455func PendingAdmin() address { return pendingAdmin }
456func FeeRecipient() address { return feeRecipient }
457func FeeBps() int64 { return feeBps }
458func NumLoans() int64 { return nextID }
459func ClaimableOf(a address) int64 { return ledger.BalanceOf(a.String()) }
460func FeesAccrued() int64 { return ledger.FeesAccrued() }
461func Liabilities() int64 { return ledger.Liabilities() }
462func Held() int64 { return coinio.HeldAt(self, Denom) }
463func Address() address { return self }
464func Height() int64 { return runtime.ChainHeight() }
465
466// --- internals ---
467
468// scheduledDue is the cumulative amount owed by `now`: k installments' worth
469// where k is how many installment due-heights have passed, capped at total.
470func scheduledDue(l *loan, now int64) int64 {
471 if now < l.startHeight {
472 return 0
473 }
474 elapsed := (now - l.startHeight) / l.periodBlocks // installments whose due height passed
475 if elapsed >= l.installments {
476 return l.total
477 }
478 if elapsed <= 0 {
479 return 0
480 }
481 // k installments of ceil(total/N) each, but never more than the total.
482 per := ceilDiv(l.total, l.installments)
483 due, ok := mulChecked(per, elapsed)
484 if !ok || due > l.total {
485 return l.total
486 }
487 return due
488}
489
490// defaultableAt is the final installment's due height plus one grace period.
491func defaultableAt(l *loan) int64 {
492 return l.startHeight + (l.installments+1)*l.periodBlocks
493}
494
495func mustLoan(id int64) *loan {
496 v := loans.Get(padID(id))
497 if v == nil {
498 panic("unknown loan " + itoa(id))
499 }
500 return v.(*loan)
501}
502
503func assertNoSend() {
504 if !unsafe.OriginSend().IsZero() {
505 panic("this function does not accept coins")
506 }
507}
508
509func counter(tree *avl.Tree, a address) *int64 {
510 k := a.String()
511 if v := tree.Get(k); v != nil {
512 return v.(*int64)
513 }
514 n := int64(0)
515 tree.Set(k, &n)
516 return &n
517}
518
519// ceilDiv returns ceil(a/b) for positive b; a,b assumed non-negative and b>0
520// (installments >= MinInstallments >= 1).
521func ceilDiv(a, b int64) int64 {
522 if a <= 0 {
523 return 0
524 }
525 return (a + b - 1) / b
526}
527
528func addChecked(a, b int64) (int64, bool) {
529 s := a + b
530 if (b > 0 && s < a) || (b < 0 && s > a) {
531 return 0, false
532 }
533 return s, true
534}
535
536func mulChecked(a, b int64) (int64, bool) {
537 if a == 0 || b == 0 {
538 return 0, true
539 }
540 p := a * b
541 if p/b != a {
542 return 0, false
543 }
544 return p, true
545}
546
547// mulDivChecked returns floor(a*b/d) without overflowing on the intermediate
548// product, by dividing before multiplying where it can and checking the
549// product otherwise. Here d is 10000 and a,b are bounded, so the simple
550// checked product suffices; the helper names the intent and guards it.
551func mulDivChecked(a, b, d int64) (int64, bool) {
552 p, ok := mulChecked(a, b)
553 if !ok {
554 return 0, false
555 }
556 return p / d, true
557}
558
559func padID(id int64) string {
560 s := strconv.FormatInt(id, 10)
561 for len(s) < 12 {
562 s = "0" + s
563 }
564 return s
565}
566
567func itoa(n int64) string { return strconv.FormatInt(n, 10) }
568
569// --- Render ---
570
571func Render(path string) string {
572 switch {
573 case path == "":
574 return renderHome()
575 case hasPrefix(path, "loan/"):
576 return renderLoan(path[len("loan/"):])
577 default:
578 return "unknown path; try \"\" or \"loan/<id>\"\n"
579 }
580}
581
582func renderHome() string {
583 out := "# loans — unsecured term loans with a flat finance charge\n\n"
584 out += "Loans: " + itoa(nextID) + " · claimable+fees held: " + itoa(ledger.Liabilities()) + Denom +
585 " · fees accrued: " + itoa(ledger.FeesAccrued()) + Denom + "\n\n"
586 out += "The realm never holds principal (it is disbursed at Open); the coins it holds are repayments awaiting Claim.\n\n"
587 out += "| id | lender | borrower | principal | total | repaid | status |\n|---|---|---|---|---|---|---|\n"
588 shown := int64(0)
589 for id := nextID - 1; id >= 0 && shown < RenderLimit; id-- {
590 v := loans.Get(padID(id))
591 if v == nil {
592 continue
593 }
594 l := v.(*loan)
595 out += "| " + itoa(l.id) + " | " + sanitize.TableCell(l.lender.String()) + " | " +
596 sanitize.TableCell(l.borrower.String()) + " | " + itoa(l.principal) + Denom + " | " +
597 itoa(l.total) + Denom + " | " + itoa(l.repaid) + Denom + " | " + l.status + " |\n"
598 shown++
599 }
600 if nextID > shown {
601 out += "\n(" + itoa(shown) + " most recent of " + itoa(nextID) + ")\n"
602 }
603 return out
604}
605
606func renderLoan(idStr string) string {
607 id, err := strconv.ParseInt(idStr, 10, 64)
608 if err != nil {
609 return "bad loan id\n"
610 }
611 v := loans.Get(padID(id))
612 if v == nil {
613 return "unknown loan " + idStr + "\n"
614 }
615 l := v.(*loan)
616 now := runtime.ChainHeight()
617 out := "# loan " + itoa(l.id) + "\n\n"
618 out += "- lender: " + l.lender.String() + "\n"
619 out += "- borrower: " + l.borrower.String() + "\n"
620 out += "- principal " + itoa(l.principal) + Denom + ", total repayable " + itoa(l.total) + Denom +
621 " (finance charge " + itoa(l.total-l.principal) + Denom + ")\n"
622 out += "- " + itoa(l.installments) + " installments of ~" + itoa(ceilDiv(l.total, l.installments)) +
623 Denom + " every " + itoa(l.periodBlocks) + " blocks, from height " + itoa(l.startHeight) + "\n"
624 out += "- repaid " + itoa(l.repaid) + Denom + " · outstanding " + itoa(l.total-l.repaid) + Denom +
625 " · status " + l.status + "\n"
626 if l.status == LoanActive {
627 behind := scheduledDue(l, now) - l.repaid
628 if behind < 0 {
629 behind = 0
630 }
631 out += "- scheduled-due by now " + itoa(scheduledDue(l, now)) + Denom + " · behind " + itoa(behind) +
632 Denom + " · defaultable at height " + itoa(defaultableAt(l)) + "\n"
633 }
634 return out
635}
636
637func hasPrefix(s, p string) bool { return len(s) >= len(p) && s[:len(p)] == p }