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.

latefees.gno

16.61 Kb · 470 lines
  1// Realm latefees publishes a graduated late-fee penalty on an overdue invoice
  2// held in the live [r/invoices]. A creditor (an invoice issuer) declares a
  3// late-fee SCHEDULE once — a [p/tiers] schedule over how many blocks an
  4// invoice is overdue — and the realm prices the penalty as a function of that
  5// overdue duration, escalating by tier. The penalty can be read live
  6// ([PenaltyNow]) or frozen into a sequence-numbered ASSESSMENT the creditor
  7// can point a payer to.
  8//
  9// It is the fourth consumer of [p/tiers] and the one that exercises its
 10// "progressive fee" reading over TIME rather than quantity: [r/meters] prices
 11// usage, [r/payslips] prices withholding, and this prices lateness. It is the
 12// second consumer of [r/invoices] after [r/receivables], and reads the exact
 13// integration surface that realm's header advertises — [invoices.InvoiceInfo],
 14// [invoices.IsOverdue], [invoices.HasInvoice] — all non-crossing views, no
 15// struct slice crossing a realm boundary.
 16//
 17// # It is a PUBLISHER, not a collector
 18//
 19// [r/invoices] is live and immutable, so this realm CANNOT add a charge to an
 20// invoice there, and it holds no coins of its own — no banker, no deposit, no
 21// payout, no withdrawal path. A published penalty is a verifiable CLAIM backed
 22// by the invoice's own facts and the creditor's declared schedule, exactly as
 23// [r/receivables] publishes a loss provision and [r/cashflow] a statement. It
 24// compels nothing; a debtor who ignores a late fee is in the same position as
 25// one who ignores the invoice. A custodial variant that collects the fee is a
 26// separate, larger product and is deliberately out of scope.
 27//
 28// # The pricing axis, and the one it is not
 29//
 30// The base handed to [p/tiers] is the OVERDUE DURATION in blocks
 31// (`now - dueHeight`), and each tier's rate is a ugnot accrual per RateScale
 32// blocks. So the penalty ESCALATES WITH TIME: the longer an invoice is
 33// overdue, the higher the marginal accrual. [p/tiers]' rate cap makes
 34// `penalty <= overdueBlocks` an arithmetic fact — the fee can never exceed one
 35// ugnot per overdue block, a natural ceiling.
 36//
 37// It is NOT a percentage of the outstanding amount. [p/tiers] prices one
 38// amount against breakpoints — a single dimension — and a fee that is both
 39// time-escalating AND balance-scaled is two dimensions it cannot express in
 40// one call. The creditor scales the ugnot accrual to their debt magnitudes
 41// through the schedule; the assessment records the outstanding beside the
 42// penalty so a reader can compute an effective rate. Composing a balance-
 43// scaled fee as a single Charge call would be a plausible, wrong number.
 44//
 45// # The trust boundary, inherited twice over
 46//
 47// A penalty is a self-report of a self-report of a self-report: the issuer set
 48// the invoice amount in [r/invoices], the due height there, AND the late-fee
 49// schedule here. This realm computes faithfully from those; it does not
 50// validate that any of them is fair. A creditor may only assess their OWN
 51// invoices — [Assess] derives the issuer from `cur.Previous().Address()` and
 52// reads [invoices.InvoiceInfo] under that key, which returns only invoices
 53// that address issued.
 54package latefees
 55
 56import (
 57	"chain"
 58	"chain/runtime"
 59	"chain/runtime/unsafe"
 60	"strconv"
 61
 62	"gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/tiers"
 63	"gno.land/p/nt/avl/v0"
 64	"gno.land/p/nt/markdown/sanitize/v0"
 65	"gno.land/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/invoices"
 66)
 67
 68// Denom is the asset the invoices this realm prices are denominated in, and
 69// the unit a penalty is expressed in. This realm never holds it.
 70const Denom = "ugnot"
 71
 72// InvoicesPath pins the realm this one reads, by value, so a reader of a
 73// published assessment can see which invoices it priced.
 74const InvoicesPath = invoices.RealmPath
 75
 76// RenderLimit bounds every rendered list — an unbounded Render was YELLOW in
 77// three prior audits.
 78const RenderLimit = 20
 79
 80// MaxAssessmentsPerIssuer bounds one creditor's stored assessment records, so
 81// one address cannot grow this realm's state without bound. It counts STORED
 82// records, the invoices-realm discipline.
 83const MaxAssessmentsPerIssuer = 256
 84
 85type assessment struct {
 86	seq         int64
 87	issuer      address // the creditor who froze it; the invoice's issuer
 88	invoiceID   int64
 89	payer       address
 90	amount      int64 // invoice face, snapshotted from r/invoices
 91	paid        int64 // snapshotted
 92	outstanding int64 // amount - paid at assessment time
 93	dueHeight   int64
 94	assessedAt  int64 // chain height the penalty was priced at
 95	overdueBy   int64 // assessedAt - dueHeight, the base priced
 96	penalty     int64 // Charge(overdueBy) under the snapshotted schedule
 97	edges       []int64
 98	rates       []int64
 99}
100
101var (
102	self      address // this realm's address, captured at deploy
103	nextSeq   int64
104	schedules = avl.NewTree() // issuer.String()      -> tiers.Schedule (by value)
105	bySeq     = avl.NewTree() // padID(seq)            -> *assessment
106	issuerNum = avl.NewTree() // issuer.String()       -> *int64, stored assessments
107)
108
109func init() {
110	self = unsafe.CurrentRealm().Address()
111}
112
113// --- creditor side ---
114
115// SetSchedule declares or replaces the caller's late-fee schedule: a graduated
116// accrual over OVERDUE BLOCKS, built from comma-separated int64 lists where
117// len(rates) == len(edges)+1 and each rate is a ugnot accrual per RateScale
118// blocks (0..10000). Edges are block counts — e.g. "216000,648000" is roughly
119// ten and thirty days at ~4s blocks. A single rate with no edges is a flat
120// accrual. Replacing a schedule does not touch already-frozen assessments,
121// which snapshot the schedule that priced them.
122func SetSchedule(cur realm, edges, rates string) int {
123	assertNoSend()
124	issuer := cur.Previous().Address()
125	s, err := tiers.New(
126		parseInt64List(edges, tiers.MaxTiers-1, "edge"),
127		parseInt64List(rates, tiers.MaxTiers, "rate"),
128	)
129	if err != nil {
130		panic("schedule: " + err.Error())
131	}
132	schedules.Set(issuer.String(), s)
133	chain.Emit("ScheduleSet", "issuer", issuer.String(), "tiers", itoa(int64(s.NumTiers())))
134	return s.NumTiers()
135}
136
137// Assess freezes a late-fee penalty on one of the caller's own overdue
138// invoices and returns its sequence number. The caller must be the invoice's
139// issuer (the creditor), must have declared a schedule, and the invoice must
140// be overdue right now. The invoice's facts and the current schedule are
141// snapshotted, so the assessment is a stable claim even as the invoice keeps
142// accruing or is later paid.
143func Assess(cur realm, invoiceID int64) int64 {
144	assertNoSend()
145	issuer := cur.Previous().Address()
146	if !invoices.HasInvoice(issuer, invoiceID) {
147		panic("no invoice " + itoa(invoiceID) + " issued by the caller")
148	}
149	sv := schedules.Get(issuer.String())
150	if sv == nil {
151		panic("declare a late-fee schedule first with SetSchedule")
152	}
153	if !invoices.IsOverdue(issuer, invoiceID) {
154		panic("invoice " + itoa(invoiceID) + " is not overdue")
155	}
156	n := counter(issuerNum, issuer)
157	if *n >= MaxAssessmentsPerIssuer {
158		panic("per-issuer assessment limit reached")
159	}
160
161	payer, amount, paid, dueHeight, _, _ := invoices.InvoiceInfo(issuer, invoiceID)
162	now := runtime.ChainHeight()
163	overdueBy := now - dueHeight // > 0: IsOverdue required now > dueHeight
164	sched := sv.(tiers.Schedule)
165	penalty := charge(sched, overdueBy)
166
167	seq := nextSeq
168	nextSeq++
169	bySeq.Set(padID(seq), &assessment{
170		seq:         seq,
171		issuer:      issuer,
172		invoiceID:   invoiceID,
173		payer:       payer,
174		amount:      amount,
175		paid:        paid,
176		outstanding: amount - paid,
177		dueHeight:   dueHeight,
178		assessedAt:  now,
179		overdueBy:   overdueBy,
180		penalty:     penalty,
181		edges:       sched.Edges(),
182		rates:       sched.Rates(),
183	})
184	*n++
185
186	chain.Emit("Assessed",
187		"seq", itoa(seq),
188		"issuer", issuer.String(),
189		"invoiceId", itoa(invoiceID),
190		"payer", payer.String(),
191		"overdueBy", itoa(overdueBy),
192		"outstanding", itoa(amount-paid),
193		"penalty", itoa(penalty),
194	)
195	return seq
196}
197
198// --- views (non-crossing: cross-realm readable) ---
199
200// PenaltyNow is the penalty an issuer's invoice has accrued as of the current
201// height, under the issuer's CURRENT schedule. It is 0 when the invoice is not
202// overdue — including once it is paid or voided, because [r/invoices] does not
203// expose when a payment landed, so accrual is only computable while the
204// invoice is still open and late. A frozen [Assess] is how a claim survives
205// the invoice being paid. Panics only if the issuer has no schedule or the
206// invoice does not exist.
207func PenaltyNow(issuer address, invoiceID int64) int64 {
208	if !invoices.HasInvoice(issuer, invoiceID) {
209		panic("no invoice " + itoa(invoiceID) + " issued by " + issuer.String())
210	}
211	sv := schedules.Get(issuer.String())
212	if sv == nil {
213		panic("issuer has no late-fee schedule")
214	}
215	if !invoices.IsOverdue(issuer, invoiceID) {
216		return 0
217	}
218	_, _, _, dueHeight, _, _ := invoices.InvoiceInfo(issuer, invoiceID)
219	return charge(sv.(tiers.Schedule), runtime.ChainHeight()-dueHeight)
220}
221
222// QuotePenalty prices a hypothetical overdue duration against an issuer's
223// current schedule, without reading any invoice — the preview a creditor uses
224// to tune a schedule. Panics if the issuer has no schedule or overdueBlocks is
225// negative.
226func QuotePenalty(issuer address, overdueBlocks int64) int64 {
227	sv := schedules.Get(issuer.String())
228	if sv == nil {
229		panic("issuer has no late-fee schedule")
230	}
231	if overdueBlocks < 0 {
232		panic("overdueBlocks must be non-negative")
233	}
234	return charge(sv.(tiers.Schedule), overdueBlocks)
235}
236
237// HasSchedule reports whether an issuer has declared a late-fee schedule.
238func HasSchedule(issuer address) bool { return schedules.Get(issuer.String()) != nil }
239
240// ScheduleOf returns an issuer's current schedule as its edge and rate slices,
241// or two empty slices if none is declared.
242func ScheduleOf(issuer address) (edges, rates []int64) {
243	sv := schedules.Get(issuer.String())
244	if sv == nil {
245		return nil, nil
246	}
247	s := sv.(tiers.Schedule)
248	return s.Edges(), s.Rates()
249}
250
251// AssessmentInfo returns a frozen assessment's fields. Panics on an unknown
252// sequence.
253func AssessmentInfo(seq int64) (issuer, payer address, invoiceID, outstanding, overdueBy, penalty, assessedAt int64) {
254	a := mustAssessment(seq)
255	// invoiceID/outstanding/overdueBy/penalty/assessedAt are int64; issuer and
256	// payer are addresses, returned first.
257	return a.issuer, a.payer, a.invoiceID, a.outstanding, a.overdueBy, a.penalty, a.assessedAt
258}
259
260// AssessmentSchedule returns the schedule snapshot an assessment was priced
261// under — the edges and rates frozen with it, which may differ from the
262// issuer's current schedule.
263func AssessmentSchedule(seq int64) (edges, rates []int64) {
264	a := mustAssessment(seq)
265	return a.edges, a.rates
266}
267
268// HasAssessment reports whether a sequence number names a frozen assessment.
269func HasAssessment(seq int64) bool { return bySeq.Get(padID(seq)) != nil }
270
271// AssessmentsOf is the number of assessments an issuer has frozen.
272func AssessmentsOf(issuer address) int64 {
273	v := issuerNum.Get(issuer.String())
274	if v == nil {
275		return 0
276	}
277	return *v.(*int64)
278}
279
280func NumAssessments() int64 { return nextSeq }
281func Invoices() string      { return InvoicesPath }
282func Address() address      { return self }
283func Height() int64         { return runtime.ChainHeight() }
284
285// --- internals ---
286
287// charge prices an overdue duration against a schedule, surfacing tiers' own
288// error as a panic rather than a silent zero. A zero or negative base prices
289// to zero (tiers refuses a negative amount, so it is guarded here).
290func charge(s tiers.Schedule, overdueBy int64) int64 {
291	if overdueBy <= 0 {
292		return 0
293	}
294	c, err := s.Charge(overdueBy)
295	if err != nil {
296		panic("charge: " + err.Error())
297	}
298	return c
299}
300
301func mustAssessment(seq int64) *assessment {
302	v := bySeq.Get(padID(seq))
303	if v == nil {
304		panic("unknown assessment " + itoa(seq))
305	}
306	return v.(*assessment)
307}
308
309// assertNoSend refuses coins on every crossing entrypoint. This realm holds
310// nothing, so every one of its writes is non-payable. Reads the ORIGIN
311// envelope, so a transaction attaching coins anywhere is refused.
312func assertNoSend() {
313	if !unsafe.OriginSend().IsZero() {
314		panic("this function does not accept coins")
315	}
316}
317
318func counter(tree *avl.Tree, a address) *int64 {
319	k := a.String()
320	if v := tree.Get(k); v != nil {
321		return v.(*int64)
322	}
323	n := int64(0)
324	tree.Set(k, &n)
325	return &n
326}
327
328// parseInt64List parses a comma-separated int64 list, bounding the element
329// count BEFORE materialising values so a long string dies on count, not
330// memory (the r/receivables bounding argument). "" is the empty list.
331func parseInt64List(s string, max int, what string) []int64 {
332	if s == "" {
333		return nil
334	}
335	var out []int64
336	start := 0
337	for i := 0; i <= len(s); i++ {
338		if i < len(s) && s[i] != ',' {
339			continue
340		}
341		if len(out) >= max {
342			panic("too many " + what + "s: at most " + strconv.Itoa(max))
343		}
344		field := s[start:i]
345		start = i + 1
346		n, err := strconv.ParseInt(trimSpaces(field), 10, 64)
347		if err != nil {
348			panic("bad " + what + " " + field + ": " + err.Error())
349		}
350		out = append(out, n)
351	}
352	return out
353}
354
355func trimSpaces(s string) string {
356	i, j := 0, len(s)
357	for i < j && s[i] == ' ' {
358		i++
359	}
360	for j > i && s[j-1] == ' ' {
361		j--
362	}
363	return s[i:j]
364}
365
366func padID(id int64) string {
367	s := strconv.FormatInt(id, 10)
368	for len(s) < 12 {
369		s = "0" + s
370	}
371	return s
372}
373
374func itoa(n int64) string { return strconv.FormatInt(n, 10) }
375
376// --- Render ---
377
378// Render shows the catalog at "", an assessment at "seq/<n>", and an issuer's
379// schedule at "issuer/<addr>". Every user-supplied value rendered between
380// table pipes is escaped with sanitize.TableCell; addresses carry no markdown
381// metacharacter but are passed through InlineText for the same reason the
382// sibling realms do.
383func Render(path string) string {
384	switch {
385	case path == "":
386		return renderHome()
387	case hasPrefix(path, "seq/"):
388		return renderAssessment(path[len("seq/"):])
389	case hasPrefix(path, "issuer/"):
390		return renderIssuer(path[len("issuer/"):])
391	default:
392		return "unknown path; try \"\", \"seq/<n>\" or \"issuer/<address>\"\n"
393	}
394}
395
396func renderHome() string {
397	out := "# latefees — graduated late-fee penalties over " + sanitize.InlineText(InvoicesPath) + "\n\n"
398	out += "Assessments: " + itoa(nextSeq) + ". A penalty is a published CLAIM, not a collected charge; this realm holds no coins.\n\n"
399	out += "| seq | issuer | invoice | overdue (blocks) | outstanding | penalty |\n|---|---|---|---|---|---|\n"
400	shown := int64(0)
401	// Newest-first: the most recent assessments are the ones a reader wants,
402	// and oldest-first under a cap lets early seqs own the page forever
403	// (r/meters audit finding Y2).
404	for seq := nextSeq - 1; seq >= 0 && shown < RenderLimit; seq-- {
405		v := bySeq.Get(padID(seq))
406		if v == nil {
407			continue
408		}
409		a := v.(*assessment)
410		out += "| " + itoa(a.seq) + " | " + sanitize.TableCell(a.issuer.String()) + " | " +
411			itoa(a.invoiceID) + " | " + itoa(a.overdueBy) + " | " + itoa(a.outstanding) + Denom +
412			" | " + itoa(a.penalty) + Denom + " |\n"
413		shown++
414	}
415	if nextSeq > shown {
416		out += "\n(" + itoa(shown) + " most recent of " + itoa(nextSeq) + ")\n"
417	}
418	return out
419}
420
421func renderAssessment(seqStr string) string {
422	seq, err := strconv.ParseInt(seqStr, 10, 64)
423	if err != nil {
424		return "bad sequence\n"
425	}
426	v := bySeq.Get(padID(seq))
427	if v == nil {
428		return "unknown assessment " + seqStr + "\n"
429	}
430	a := v.(*assessment)
431	out := "# assessment " + itoa(a.seq) + "\n\n"
432	out += "- issuer (creditor): " + a.issuer.String() + "\n"
433	out += "- payer (debtor): " + a.payer.String() + "\n"
434	out += "- invoice " + itoa(a.invoiceID) + " in " + sanitize.InlineText(InvoicesPath) + "\n"
435	out += "- face " + itoa(a.amount) + Denom + ", paid " + itoa(a.paid) + Denom +
436		", outstanding " + itoa(a.outstanding) + Denom + "\n"
437	out += "- due at height " + itoa(a.dueHeight) + ", assessed at " + itoa(a.assessedAt) +
438		" (" + itoa(a.overdueBy) + " blocks overdue)\n"
439	out += "- **penalty: " + itoa(a.penalty) + Denom + "**, priced on the overdue duration\n\n"
440	out += renderSchedule(a.edges, a.rates)
441	return out
442}
443
444func renderIssuer(addr string) string {
445	issuer := address(addr)
446	sv := schedules.Get(addr)
447	if sv == nil {
448		return "issuer " + sanitize.InlineText(addr) + " has no late-fee schedule\n"
449	}
450	s := sv.(tiers.Schedule)
451	out := "# late-fee schedule for " + sanitize.InlineText(addr) + "\n\n"
452	out += "Frozen assessments by this issuer: " + itoa(AssessmentsOf(issuer)) + "\n\n"
453	out += renderSchedule(s.Edges(), s.Rates())
454	return out
455}
456
457func renderSchedule(edges, rates []int64) string {
458	out := "Schedule over overdue blocks (ugnot accrued per 10000 blocks, by tier):\n\n"
459	out += "| from (blocks overdue) | rate (/10000) |\n|---|---|\n"
460	lo := int64(0)
461	for i, r := range rates {
462		out += "| " + itoa(lo) + " | " + itoa(r) + " |\n"
463		if i < len(edges) {
464			lo = edges[i]
465		}
466	}
467	return out
468}
469
470func hasPrefix(s, p string) bool { return len(s) >= len(p) && s[:len(p)] == p }