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.

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 }