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 }