invoices.gno
36.00 Kb · 919 lines
1// Package invoices is on-chain accounts receivable: an issuer records an
2// obligation, and the named payer discharges it later, in parts.
3//
4// # The one idea
5//
6// Every payment realm in this portfolio, and every escrow and vesting
7// package the discovery gate found on either live chain, is PREFUNDED.
8// service_market escrows the exact price at purchase. subscriptions
9// prepays a whole period. payroll, vesting, fee_split and revshare push
10// money that already arrived. In all of them the coins exist before the
11// claim on them does.
12//
13// An invoice inverts that. It is UNFUNDED AT CREATION: the obligation is
14// recorded first, and the money may never arrive at all.
15//
16// That one inversion is the whole realm, and it is visible in the
17// arithmetic rather than only in the prose. payroll's invariant carries a
18// pool term because an employer funds first:
19//
20// payroll: Held() == TotalPooled() + Liabilities() + Surplus()
21// invoices: Held() == Liabilities() + Surplus()
22//
23// There is no pool here, and no reserve, because at the moment an invoice
24// is issued this realm holds nothing on its behalf and promises nothing.
25// Issuing costs the issuer a storage deposit and nothing else.
26//
27// # What being unfunded forces
28//
29// Two features that would be decoration on a prefunded rail are load-
30// bearing here, and they are the reason this is a realm rather than a
31// field on an existing one:
32//
33// - PARTIAL SETTLEMENT. When the money is already in escrow, "pay" is a
34// release of a known amount and partiality is meaningless. When it is
35// not, a payer who can send 60% today is the ordinary case. Pay
36// accepts any amount up to the outstanding balance, repeatedly.
37// - AN OVERDUE STATE. A prefunded claim cannot be late. An unfunded one
38// can, so lateness is a state other realms can read and act on. That
39// readable state is the integration surface this realm exists to
40// provide, in the same way subscriptions exists to provide Entitled.
41//
42// # No escrow, no acceptance, no arbitration
43//
44// An invoice is billed for work already done, so there is nothing for this
45// realm to hold back. Money paid is credited to the issuer's ledger
46// balance in the same transaction. There is no acceptance window and no
47// release step — those belong to service_market, whose model is the
48// opposite one.
49//
50// Consequently this realm DOES NOT arbitrate. A payer who disputes a bill
51// simply does not pay it, and the invoice goes overdue. That is the
52// mechanism off chain too, and inventing a dispute path here would make
53// this a worse copy of service_market rather than a complement to it.
54//
55// # Payment is pull, in the direction that cannot fail
56//
57// Pay credits the issuer's ledger balance; the issuer later calls Claim.
58// The push alternative — paying the issuer inside the payer's own
59// transaction — would let the issuer's address decide whether the PAYER's
60// settlement succeeds. An issuer that is a realm which panics on receipt,
61// or is simply unable to accept, would make its own invoices unpayable and
62// the failure would land on the payer. Pull puts every failure on the
63// party who owns it.
64//
65// # Anyone may issue an invoice naming anyone. Read this before trusting a total.
66//
67// There is no permission to bill someone, because requiring one would mean
68// a registry of who may invoice whom, which is a different application.
69// The honest consequence is stated here rather than discovered later:
70//
71// AN UNPAID INVOICE IS AN UNVERIFIED CLAIM BY ITS ISSUER.
72//
73// So this realm deliberately exposes NO global "what does this address
74// owe" aggregate. Such a number would be the sum of every stranger's
75// self-reported assertion, and reading it as a credit signal is the error
76// the getter's existence would invite. What it exposes instead is
77// ISSUER-SCOPED: OutstandingFrom and OverdueFrom answer "what does this
78// payer owe THIS issuer", which is the question a consumer realm can
79// actually act on, because a consumer gating its own service on its own
80// unpaid bills is trusting only itself.
81//
82// Two bounds back that up mechanically. Issuing costs a storage deposit
83// paid by the ISSUER, so volume is not free to the party who benefits from
84// it; and MaxRecordsPerIssuer caps one issuer's STORED records, which is an
85// availability bound on that issuer's own namespace and no one else's.
86// Neither bound makes an unpaid claim true, and they are not presented as
87// doing so.
88//
89// Both bounds are PER ISSUER, and the global total is not bounded. Sybil
90// issuers multiply it: N funded addresses yield N*MaxRecordsPerIssuer
91// records, and the per-record deposit paid by each is the only thing making
92// that expensive. Two consequences follow, stated here rather than
93// discovered:
94//
95// - A payer named on an invoice CANNOT remove it. Void is issuer-only,
96// because forgiving a debt is the issuer's to do, and Release refuses an
97// open record; so a stranger's open invoice naming you persists until
98// that stranger voids it. What absorbs this is the design above rather
99// than a bound: no global aggregate reads such a record, and the
100// issuer-scoped getters a consumer realm actually calls stay gas-bounded
101// at MaxRecordsPerIssuer, so a victim's reads are never charged for an
102// attacker's volume. The residual harm is an off-chain reader's, and the
103// unverified-claim warning above is what it is for.
104// - An issuer record is never reclaimed. Release decrements its count but
105// does not delete the issuers entry, because `issued` and `invoiced` are
106// LIFETIME figures that IssuedOf reports, and deleting the entry would
107// reset them to zero and make that getter lie. Global state therefore
108// grows monotonically. Keeping an honest counter is worth more than
109// reclaiming one AVL node.
110//
111// The cap counts STORED records rather than OPEN ones, and the difference
112// is load-bearing rather than a detail. OutstandingFrom, OverdueFrom and
113// HasOverdueFrom — the getters a consumer realm calls, and therefore the
114// ones whose gas cost is someone else's problem — iterate an issuer's whole
115// prefix, settled and voided records included, because a settled record is
116// not removed until Release. A cap on open invoices alone would bound the
117// obligations and leave that iteration unbounded: an issuer could issue and
118// void repeatedly, keeping `open` at zero while their prefix grew without
119// limit, until a consumer's read ran out of gas. Capping what is stored
120// bounds both, since open can never exceed stored.
121//
122// Finding "every invoice naming me" is therefore an off-chain indexer's
123// job: on chain it would be an unbounded iteration over every issuer that
124// has ever billed anyone, and the answer would be untrustworthy anyway.
125//
126// # STORAGE
127//
128// An issuer record is paid for by that issuer's first Issue; an invoice
129// record by the Issue that creates it. Release removes a finished invoice
130// and the chain refunds the freed deposit to the transaction that frees
131// it, which is the standing incentive to tidy up a record that is no
132// longer anyone's business.
133//
134// # What this realm does NOT do
135//
136// - It does not verify that work happened, value it, or arbitrate.
137// - It holds ugnot and only ugnot.
138// - It has no admin. The deploying namespace receives protocol fees and
139// swept surplus and has no other privilege: no setter, no pause, no
140// ability to void an invoice or touch a balance.
141package invoices
142
143import (
144 "chain"
145 "chain/runtime"
146 "chain/runtime/unsafe"
147 "strconv"
148 "strings"
149
150 "gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/coinio"
151 "gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/feeledger"
152 "gno.land/p/nt/avl/v0"
153 "gno.land/p/nt/markdown/sanitize/v0"
154)
155
156// RealmPath is this realm's own path, used to build Render links.
157const RealmPath = "/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/invoices"
158
159// Denom is the only asset this realm holds.
160const Denom = "ugnot"
161
162// MaxFeeBps is the hard ceiling on any fee this realm could ever charge,
163// enforced by the feeledger the realm is constructed with — a feeBps above
164// it is refused by the primitive's own validation, not by a check in this
165// file. 500 bps = 5%.
166const MaxFeeBps = int64(500)
167
168// SettlementFeeBps is charged on each payment, on the gross, floor-rounded
169// in the ISSUER's favour: 100 bps = 1%. It matches the most conservative
170// live fee in this portfolio (fee_split, crowdfund, budgets, payroll).
171//
172// The ISSUER bears it, not the payer: a payment of N reduces the
173// outstanding balance by exactly N, and the issuer is credited N minus the
174// fee. A payer's debt must be discharged by what the payer sent, or the
175// arithmetic of "paid in full" would depend on this realm's fee schedule.
176//
177// It is a compile-time constant with no setter, so an invoice's economics
178// cannot be changed after it is issued.
179//
180// The fee is floored PER PAYMENT, so splitting a payment can lower the total
181// fee. Each payment boundary forfeits at most 1 ugnot, and MinPayment bounds
182// how many boundaries an invoice admits. MEASURED on onyx-1, not reasoned:
183// 10,990 paid at once charges 109, while ten payments of 1,099 charge 100.
184// The beneficiary is the ISSUER, who is credited the difference and does not
185// control the payment schedule; the payer gains nothing, because the
186// outstanding balance falls by the gross either way. Gas for one extra
187// payment costs more than the 1 ugnot it saves, so this is priced out rather
188// than prevented, and a colluding issuer/payer pair would spend more to
189// exploit it than they recovered.
190//
191// A final settlement below the fee denominator pays zero fee for that same
192// reason and is NOT a separate case: floor(99/100) is 0 whether or not
193// MinPayment's settle-exactly clause admitted it, so that clause forfeits
194// nothing on its own.
195const SettlementFeeBps = int64(100)
196
197// FeeCollector receives withdrawn fees and swept surplus. Compile-time
198// constant: there is no setter and no transfer path. It is the deploying
199// namespace's address — stated plainly: the operator of this realm.
200//
201// NOTE FOR ANY RE-NAMESPACED PORT: this is the address-as-data hazard that
202// a namespace substitution rewrites silently while every byte-comparison
203// still reports the tree faithful, because the rewrite IS the intended
204// transformation. Re-state it deliberately per chain; never let it be
205// derived.
206const FeeCollector = address("g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3")
207
208// MinInvoiceAmount is the smallest invoice that may be issued. Below the
209// fee denominator a 1% fee floors to zero, so a smaller invoice would be
210// fee-free by accident rather than by design.
211const MinInvoiceAmount = int64(1_000)
212
213// MinPayment keeps dust settlements out. It is NOT a floor on the final
214// payment: a payment is accepted when it is at least MinPayment OR when it
215// settles the invoice exactly. Without that second clause an invoice with
216// 300 ugnot outstanding could never be closed, and "unpayable remainder"
217// is a worse failure than dust.
218const MinPayment = int64(1_000)
219
220// MaxRecordsPerIssuer caps the invoice records one issuer may have STORED
221// at once — open, settled and voided alike. It is what makes the
222// issuer-scoped getters gas-bounded; see the package doc for why the cap
223// counts stored records and not open ones.
224//
225// Issue is permissionless, so this is an AVAILABILITY BOUND and it is
226// documented rather than hidden: an issuer can exhaust their OWN capacity
227// and no one else's, because the bound is per-issuer. The cost of filling
228// it is gas plus a storage deposit on every record.
229//
230// Hitting it is self-service to clear: Release is permissionless and the
231// chain refunds the freed deposit to whoever calls it, so an issuer at the
232// cap can always release their own finished records, and a stranger has a
233// standing incentive to do it for them.
234const MaxRecordsPerIssuer = 64
235
236// MaxMemoLen bounds the free-text memo, in bytes. The memo is rendered,
237// so it is sanitized at the render boundary, never at the write boundary —
238// storing what the issuer actually wrote keeps the record honest.
239const MaxMemoLen = 256
240
241// Invoice statuses.
242const (
243 StatusOpen = "open"
244 StatusSettled = "settled"
245 StatusVoided = "voided"
246)
247
248type invoice struct {
249 id int64
250 issuer address
251 payer address
252
253 // amount is the gross obligation. It never changes: forgiving part of
254 // a bill is a Void of the whole remainder, not a rewrite of history.
255 amount int64
256
257 // paid is the cumulative gross the payer has sent. It only ever
258 // increases, and amount-paid is the outstanding balance.
259 paid int64
260
261 // dueHeight is the height at or before which the invoice should be
262 // settled. Equal to the issue height means "due on receipt".
263 dueHeight int64
264
265 issuedAt int64
266 status string
267 memo string
268}
269
270type issuerRec struct {
271 // records is how many invoice records this issuer currently has
272 // STORED, of any status. It is the figure MaxRecordsPerIssuer caps,
273 // and therefore the one that bounds every issuer-scoped getter: it
274 // rises on Issue and falls only on Release.
275 records int
276
277 // open is how many of those are still open. Informational — a
278 // settled or voided record still occupies a slot until Release, so
279 // this never gates anything.
280 open int
281
282 issued int64
283 invoiced int64
284}
285
286var (
287 self address
288
289 // invoices holds one record per invoice, keyed issuer|paddedID so an
290 // issuer's book is a bounded prefix iteration. The public handle is
291 // therefore (issuer, id), the same two-part shape payroll uses.
292 invoices = avl.NewTree() // "issuer|%019d" -> *invoice
293
294 // issuers holds one record per address that has ever issued.
295 issuers = avl.NewTree() // address string -> *issuerRec
296
297 // ledger holds every issuer's settled-but-unclaimed balance and the
298 // fee pot. It is global across invoices on purpose: an issuer billing
299 // twenty payers has one balance and one Claim.
300 ledger = feeledger.MustNew(MaxFeeBps)
301
302 // nextID is a monotonic counter. IDs are never reused, so a released
303 // invoice's id can never be confused with a live one.
304 nextID int64 = 1
305)
306
307func init() {
308 self = unsafe.CurrentRealm().Address()
309}
310
311// rejectStraySend aborts when coins are attached to a non-payable call.
312// This realm holds funds, so a stray send would become sweepable surplus,
313// silently converting a user's coins into operator funds.
314func rejectStraySend(cur realm) {
315 if !cur.IsCurrent() {
316 // Every call site forwards a crossing entrypoint's own live cur,
317 // so this cannot fire today; it exists so a future refactor that
318 // hands this guard a stale realm value fails closed, not open
319 // (coinio names IsCurrent as the guard secondary realm parameters
320 // require, and this helper follows that discipline).
321 panic("invoices: realm capability is not current")
322 }
323 if cur.Previous().IsUserCall() && len(unsafe.OriginSend()) > 0 {
324 panic("this entrypoint does not accept coins")
325 }
326}
327
328// --- issuing ---
329
330// Issue records an obligation of `amount` owed by `payer` to the caller,
331// to be settled by `dueHeight`. It moves no coins and reserves nothing.
332// Returns the invoice id.
333//
334// dueHeight must not be in the past. A backdated due height would create
335// an invoice that is overdue in the transaction that issued it, which is a
336// claim about a deadline that was never offered.
337func Issue(cur realm, payer address, amount, dueHeight int64, memo string) int64 {
338 rejectStraySend(cur)
339
340 // The lowercase clause is load-bearing, and it is carried from budgets'
341 // Grant and payroll's Hire (measured on this toolchain, not argued):
342 // address.IsValid() defers to crypto.AddressFromBech32, whose decoder
343 // lowercases the input BEFORE verifying the checksum, so it returns
344 // true for the all-uppercase spelling of a valid address.
345 //
346 // THE SEVERITY HERE IS THE MILDER OF THE TWO THIS PORTFOLIO HAS
347 // MEASURED, and saying which matters. In payroll an uppercase employee
348 // is a PERMANENT LOSS: Accrue credits a balance nobody can authenticate
349 // as. Here it is a BRICKED RECORD, as in budgets: Pay authenticates the
350 // caller against the stored payer, so a non-canonical payer makes the
351 // invoice unpayable by everyone, including the person it names. No coins
352 // move, nothing is lost but the issuer's own storage deposit and slot.
353 // The guard is still worth its line — a record that can never be
354 // satisfied and can never be distinguished from a real debt is exactly
355 // the untrustworthy total the package doc warns about — but it is not
356 // the fund-loss case and must not be cited as one.
357 //
358 // The check is complete as well as necessary: that decoder rejects
359 // mixed case outright, so a valid address has exactly two spellings and
360 // refusing every non-lower one leaves precisely the canonical one.
361 if !payer.IsValid() || string(payer) != strings.ToLower(string(payer)) {
362 panic("payer is not a valid address in canonical (lowercase) form")
363 }
364 if amount < MinInvoiceAmount {
365 panic("amount is below MinInvoiceAmount")
366 }
367 if len(memo) > MaxMemoLen {
368 panic("memo exceeds MaxMemoLen")
369 }
370 now := runtime.ChainHeight()
371 if dueHeight < now {
372 panic("dueHeight is in the past")
373 }
374
375 issuer := cur.Previous().Address()
376 if issuer == payer {
377 panic("an issuer cannot invoice themselves")
378 }
379 key := issuer.String()
380 r := getIssuer(key)
381 if r == nil {
382 r = &issuerRec{}
383 issuers.Set(key, r)
384 }
385 if r.records >= MaxRecordsPerIssuer {
386 panic("issuer has too many stored invoices")
387 }
388 invoiced, ok := checkedAdd(r.invoiced, amount)
389 if !ok {
390 panic("issuer lifetime total would overflow")
391 }
392
393 id := nextID
394 nextID++
395 invoices.Set(invKey(issuer, id), &invoice{
396 id: id,
397 issuer: issuer,
398 payer: payer,
399 amount: amount,
400 paid: 0,
401 dueHeight: dueHeight,
402 issuedAt: now,
403 status: StatusOpen,
404 memo: memo,
405 })
406 r.records++
407 r.open++
408 r.issued++
409 r.invoiced = invoiced
410
411 chain.Emit("Issued", "id", itoa(id), "issuer", key,
412 "payer", payer.String(), "amount", itoa(amount),
413 "dueHeight", itoa(dueHeight))
414 return id
415}
416
417// --- settling ---
418
419// Pay settles part or all of an invoice with the attached coins. The named
420// payer only. Direct EOA calls with -send only (the receipt-guaranteed
421// shape); exactly one positive ugnot coin.
422//
423// The attached amount must not exceed the outstanding balance. Accepting
424// an overpayment would turn a payment into a donation the payer did not
425// choose to make and this realm has no path to return, so it is refused
426// and the payer keeps their coins.
427//
428// Returns the outstanding balance remaining after this payment.
429func Pay(cur realm, issuer address, id int64) int64 {
430 // Receive first and once, per coinio's envelope contract.
431 from, amount := coinio.Receive(0, cur, Denom)
432
433 inv := mustInvoice(issuer, id)
434 if inv.status != StatusOpen {
435 panic("invoice is not open")
436 }
437 if from != inv.payer {
438 panic("only the named payer may pay this invoice")
439 }
440 outstanding := inv.amount - inv.paid
441 if amount > outstanding {
442 panic("payment exceeds the outstanding balance")
443 }
444 if amount < MinPayment && amount != outstanding {
445 panic("payment is below MinPayment and does not settle the invoice")
446 }
447
448 inv.paid += amount
449 remaining := inv.amount - inv.paid
450 if remaining == 0 {
451 inv.status = StatusSettled
452 mustDecOpen(issuer)
453 }
454 credited, fee := ledger.MustDeposit(issuer.String(), amount, SettlementFeeBps)
455
456 chain.Emit("Paid", "id", itoa(id), "issuer", issuer.String(),
457 "payer", from.String(), "amount", itoa(amount),
458 "credited", itoa(credited), "fee", itoa(fee),
459 "paid", itoa(inv.paid), "outstanding", itoa(remaining),
460 "status", inv.status)
461 return remaining
462}
463
464// Void cancels the unpaid remainder of an invoice. The issuer only — this
465// is an issuer forgiving a debt, and nobody else may forgive it for them.
466//
467// It does NOT reverse payments already made. Coins the payer sent are
468// already credited to the issuer's ledger balance and may already have
469// been claimed; there is no mechanism on chain to take them back, and
470// pretending otherwise in the status would be worse than saying so. A
471// voided invoice therefore keeps its `paid` figure, which is what makes
472// the record readable afterwards.
473//
474// Voiding does not remove the record. Release does that.
475func Void(cur realm, id int64) {
476 rejectStraySend(cur)
477 issuer := cur.Previous().Address()
478 inv := mustInvoice(issuer, id)
479 if inv.status != StatusOpen {
480 panic("invoice is not open")
481 }
482 inv.status = StatusVoided
483 mustDecOpen(issuer)
484 chain.Emit("Voided", "id", itoa(id), "issuer", issuer.String(),
485 "payer", inv.payer.String(), "paid", itoa(inv.paid),
486 "forgiven", itoa(inv.amount-inv.paid))
487}
488
489// Release removes a finished invoice and frees its storage. Permissionless:
490// the chain refunds the freed storage deposit to the caller, which is the
491// standing incentive to tidy up a record that is not your own.
492//
493// It refuses an open invoice, which is what makes it safe to let a stranger
494// call: a Release can never destroy an outstanding obligation. An issuer
495// who wants a record gone must Void it first, and that leaves an event.
496//
497// Releasing frees the issuer's slot against MaxRecordsPerIssuer. That is
498// the only way a slot is ever freed — settling and voiding do not free
499// one, because the record is still stored and still walked by every
500// issuer-scoped getter.
501func Release(cur realm, issuer address, id int64) {
502 rejectStraySend(cur)
503 inv := mustInvoice(issuer, id)
504 if inv.status == StatusOpen {
505 panic("invoice is still open")
506 }
507 invoices.Remove(invKey(issuer, id))
508 r := getIssuer(issuer.String())
509 if r == nil || r.records == 0 {
510 // Unreachable, and a panic rather than a mask on purpose. See
511 // mustDecOpen for the reasoning; the stakes are higher here
512 // because `records` is what the cap reads, so masking would cost
513 // the issuer a slot permanently and silently.
514 panic("invoices: issuer record count desynchronized from the invoice tree")
515 }
516 r.records--
517 chain.Emit("Released", "id", itoa(id), "issuer", issuer.String(),
518 "status", inv.status, "paid", itoa(inv.paid))
519}
520
521// --- claiming ---
522
523// Claim pays the caller their whole settled balance. The issuer only —
524// there is no "claim on behalf of", because that would let a third party
525// choose the moment someone else's coins move.
526func Claim(cur realm) int64 {
527 rejectStraySend(cur)
528 who := cur.Previous().Address()
529 key := who.String()
530 amount := ledger.BalanceOf(key)
531 if amount == 0 {
532 panic("no balance to claim")
533 }
534 // Debit first, per coinio's ordering contract: a panic inside coinio
535 // aborts the transaction and reverts this debit.
536 ledger.MustWithdraw(key, amount)
537 coinio.Payout(0, cur, who, Denom, amount)
538 chain.Emit("Claimed", "account", key, "amount", itoa(amount))
539 return amount
540}
541
542// WithdrawFees moves the accrued fee pot to FeeCollector. Anyone may call
543// it; the destination is a compile-time constant, so the caller cannot
544// influence where the coins go.
545func WithdrawFees(cur realm) int64 {
546 rejectStraySend(cur)
547 fees := ledger.WithdrawFees()
548 if fees == 0 {
549 panic("no fees accrued")
550 }
551 coinio.Payout(0, cur, FeeCollector, Denom, fees)
552 chain.Emit("FeesWithdrawn", "to", FeeCollector.String(), "amount", itoa(fees))
553 return fees
554}
555
556// SweepSurplus sends coins held beyond every obligation to FeeCollector.
557// The reserve passed to coinio is the realm's full liability, so a sweep
558// can never reach a coin an issuer is owed.
559func SweepSurplus(cur realm) int64 {
560 rejectStraySend(cur)
561 swept := coinio.Sweep(0, cur, FeeCollector, Denom, Liabilities())
562 chain.Emit("Swept", "denom", Denom, "to", FeeCollector.String(),
563 "amount", itoa(swept))
564 return swept
565}
566
567// --- the overdue predicate: this realm's integration surface ---
568
569// isOverdue is a pure function of an invoice and the clock, which is what
570// makes it unit-testable without a chain.
571//
572// Overdue means: still open, still owing, and the due height has PASSED.
573// Strictly passed — at exactly dueHeight the invoice is due but not yet
574// late, which is what lets dueHeight == issue height mean "due on receipt"
575// without being born late.
576func isOverdue(inv *invoice, now int64) bool {
577 return inv.status == StatusOpen && inv.paid < inv.amount && now > inv.dueHeight
578}
579
580// IsOverdue reports whether one invoice is overdue right now.
581func IsOverdue(issuer address, id int64) bool {
582 return isOverdue(mustInvoice(issuer, id), runtime.ChainHeight())
583}
584
585// OutstandingFrom is the total a payer still owes THIS issuer across the
586// issuer's open invoices.
587//
588// The iteration is bounded by MaxRecordsPerIssuer, NOT by the number of
589// open invoices: it walks the issuer's whole stored prefix and skips the
590// records that are not open. That is why the cap counts stored records —
591// see the package doc.
592//
593// This is the issuer-scoped shape the package doc argues for: a consumer
594// realm gating its own service on its own unpaid bills is trusting only
595// itself. There is deliberately no global per-payer total — see the
596// package doc, "Anyone may issue an invoice naming anyone".
597func OutstandingFrom(issuer, payer address) int64 {
598 var sum int64
599 eachOf(issuer, func(inv *invoice) bool {
600 if inv.payer == payer && inv.status == StatusOpen {
601 sum += inv.amount - inv.paid
602 }
603 return false
604 })
605 return sum
606}
607
608// OverdueFrom is OutstandingFrom restricted to invoices whose due height
609// has passed. A consumer realm that wants to withhold service from a late
610// payer reads this.
611func OverdueFrom(issuer, payer address) int64 {
612 now := runtime.ChainHeight()
613 var sum int64
614 eachOf(issuer, func(inv *invoice) bool {
615 if inv.payer == payer && isOverdue(inv, now) {
616 sum += inv.amount - inv.paid
617 }
618 return false
619 })
620 return sum
621}
622
623// HasOverdueFrom is OverdueFrom as a predicate, for a consumer that only
624// needs the boolean. It stops at the first hit rather than summing.
625func HasOverdueFrom(issuer, payer address) bool {
626 now := runtime.ChainHeight()
627 found := false
628 eachOf(issuer, func(inv *invoice) bool {
629 if inv.payer == payer && isOverdue(inv, now) {
630 found = true
631 return true
632 }
633 return false
634 })
635 return found
636}
637
638// --- read-only accessors ---
639
640// InvoiceInfo is the whole record, for a caller that wants one read.
641func InvoiceInfo(issuer address, id int64) (payer address, amount, paid, dueHeight, issuedAt int64, status string) {
642 inv := mustInvoice(issuer, id)
643 return inv.payer, inv.amount, inv.paid, inv.dueHeight, inv.issuedAt, inv.status
644}
645
646// Outstanding is what is still owed on one invoice. Zero for a settled or
647// voided one — a voided invoice owes nothing by definition, which is the
648// point of voiding it.
649func Outstanding(issuer address, id int64) int64 {
650 inv := mustInvoice(issuer, id)
651 if inv.status != StatusOpen {
652 return 0
653 }
654 return inv.amount - inv.paid
655}
656
657// Memo is an invoice's free-text memo, exactly as the issuer wrote it. It
658// is UNSANITIZED and caller-supplied: a consumer that renders it owes it
659// the same treatment Render gives it here.
660func Memo(issuer address, id int64) string { return mustInvoice(issuer, id).memo }
661
662// StatusOf is an invoice's status.
663func StatusOf(issuer address, id int64) string { return mustInvoice(issuer, id).status }
664
665// HasInvoice reports whether a record exists, without panicking on
666// absence — every other invoice accessor panics, which makes them unusable
667// as an existence probe.
668func HasInvoice(issuer address, id int64) bool {
669 return invoices.Get(invKey(issuer, id)) != nil
670}
671
672// OpenOf is the number of open invoices an issuer holds.
673func OpenOf(issuer address) int {
674 if r := getIssuer(issuer.String()); r != nil {
675 return r.open
676 }
677 return 0
678}
679
680// RecordsOf is the number of invoice records an issuer currently has
681// stored, of any status. This — not OpenOf — is the figure capped by
682// MaxRecordsPerIssuer, so it is the one an issuer near the cap needs.
683func RecordsOf(issuer address) int {
684 if r := getIssuer(issuer.String()); r != nil {
685 return r.records
686 }
687 return 0
688}
689
690// IssuedOf is how many invoices an address has ever issued, and their
691// total face value. Note the tense: both are lifetime figures and neither
692// falls when an invoice is settled, voided or released.
693func IssuedOf(issuer address) (count, invoiced int64) {
694 if r := getIssuer(issuer.String()); r != nil {
695 return r.issued, r.invoiced
696 }
697 return 0, 0
698}
699
700// BalanceOf is an issuer's settled, unclaimed balance.
701func BalanceOf(issuer address) int64 { return ledger.BalanceOf(issuer.String()) }
702
703// Records is the number of invoice records currently stored, open or not.
704// It falls on Release, so it is a storage figure rather than a count of
705// invoices that have existed.
706func Records() int { return invoices.Size() }
707
708// Issuers is the number of addresses that have ever issued an invoice.
709func Issuers() int { return issuers.Size() }
710
711// NextID is the id the next Issue will allocate. Exposed so a caller can
712// tell a never-issued id from a released one.
713func NextID() int64 { return nextID }
714
715// SettledTotal is the sum of every issuer's unclaimed balance.
716func SettledTotal() int64 { return ledger.UsersTotal() }
717
718// FeesAccrued is the fee pot awaiting WithdrawFees.
719func FeesAccrued() int64 { return ledger.FeesAccrued() }
720
721// Liabilities is every coin this realm owes to anyone: the ledger's own
722// total, which is issuer balances plus the fee pot.
723//
724// There is no pool term, and its ABSENCE is the unfunded property this
725// realm exists to model — see the package doc. Do not "fix" this to match
726// payroll's shape.
727func Liabilities() int64 { return ledger.Liabilities() }
728
729// Held is the realm's actual ugnot balance, read from the chain.
730func Held() int64 { return coinio.HeldAt(self, Denom) }
731
732// Surplus is coins held beyond every obligation — what SweepSurplus would
733// move. It is zero when every coin arrived through an entrypoint.
734func Surplus() int64 {
735 h, l := Held(), Liabilities()
736 if h <= l {
737 return 0
738 }
739 return h - l
740}
741
742// Address is this realm's own address.
743func Address() address { return self }
744
745// Height is the current chain height, exposed so a caller can compute due
746// heights against the same clock this realm uses.
747func Height() int64 { return runtime.ChainHeight() }
748
749// --- internals ---
750
751// invKey is issuer|paddedID. The id is left-padded to 19 digits — the
752// width of MaxInt64 — so that lexicographic order over an issuer's prefix
753// is numeric order over their invoice ids. An unpadded decimal would sort
754// "10" before "9" and make a prefix scan return them out of order.
755func invKey(issuer address, id int64) string {
756 return issuer.String() + "|" + padID(id)
757}
758
759func padID(id int64) string {
760 s := strconv.FormatInt(id, 10)
761 for len(s) < 19 {
762 s = "0" + s
763 }
764 return s
765}
766
767// eachOf iterates one issuer's invoice records in id order. The callback
768// returns true to stop, matching avl's own convention.
769func eachOf(issuer address, fn func(*invoice) bool) {
770 prefix := issuer.String() + "|"
771 invoices.Iterate(prefix, prefix+"\xff", func(_ string, v any) bool {
772 return fn(v.(*invoice))
773 })
774}
775
776func getIssuer(key string) *issuerRec {
777 v := issuers.Get(key)
778 if v == nil {
779 return nil
780 }
781 return v.(*issuerRec)
782}
783
784func mustInvoice(issuer address, id int64) *invoice {
785 v := invoices.Get(invKey(issuer, id))
786 if v == nil {
787 panic("unknown invoice")
788 }
789 return v.(*invoice)
790}
791
792// mustDecOpen decrements an issuer's open count by one and REFUSES rather
793// than masking if it cannot.
794//
795// Both call sites — settlement in Pay and forgiveness in Void — have already
796// proved the invoice exists and was StatusOpen, so neither branch below can
797// fire. The decrement is reachable only through a status transition out of
798// StatusOpen, each invoice makes that transition at most once (both guards
799// require StatusOpen and both set a terminal status), and Issue increments
800// `open` for every record it stores. So an open invoice contributed exactly
801// one to this counter and has not yet taken it back.
802//
803// The earlier spelling was `if r := getIssuer(...); r != nil && r.open > 0`,
804// which reads as prudence and is the opposite. Those clauses cannot prevent
805// the inconsistency — the status has already been written by the time we get
806// here — they can only decline to notice it, and then COMMIT it. A panic
807// aborts the whole transaction and reverts the status write too, which
808// leaves the realm consistent; masking leaves it inconsistent and silent,
809// and the counter it corrupts is the one MaxRecordsPerIssuer reads.
810//
811// So the guard exists for a future edit, not for today: a path that retires
812// an invoice without coming through here fails loudly on the next one
813// instead of quietly leaking a slot. A check that can never fire and a
814// check that always passes are the same artifact from a reader's seat; the
815// difference is what each does when it is wrong.
816func mustDecOpen(issuer address) {
817 r := getIssuer(issuer.String())
818 if r == nil || r.open == 0 {
819 panic("invoices: issuer open count desynchronized from the invoice tree")
820 }
821 r.open--
822}
823
824func checkedAdd(a, b int64) (int64, bool) {
825 c := a + b
826 if (b > 0 && c < a) || (b < 0 && c > a) {
827 return 0, false
828 }
829 return c, true
830}
831
832func itoa(n int64) string { return strconv.FormatInt(n, 10) }
833
834// --- render ---
835
836func Render(path string) string {
837 var b strings.Builder
838 b.WriteString("# invoices\n\n")
839 b.WriteString("On-chain accounts receivable. An invoice is unfunded at ")
840 b.WriteString("creation: the obligation is recorded first, and the ")
841 b.WriteString("money may never arrive.\n\n")
842
843 if path == "" {
844 b.WriteString("## Totals\n\n")
845 b.WriteString("| field | value |\n|---|---|\n")
846 b.WriteString("| issuers | " + itoa(int64(Issuers())) + " |\n")
847 b.WriteString("| stored records | " + itoa(int64(Records())) + " |\n")
848 b.WriteString("| settled, unclaimed | " + itoa(SettledTotal()) + " " + Denom + " |\n")
849 b.WriteString("| fees accrued | " + itoa(FeesAccrued()) + " " + Denom + " |\n")
850 b.WriteString("| held | " + itoa(Held()) + " " + Denom + " |\n")
851 b.WriteString("| surplus | " + itoa(Surplus()) + " " + Denom + " |\n\n")
852 b.WriteString("> Anyone may issue an invoice naming anyone, so an ")
853 b.WriteString("unpaid invoice is an unverified claim by its issuer. ")
854 b.WriteString("There is deliberately no global \"what does this ")
855 b.WriteString("address owe\" total; read OutstandingFrom, which is ")
856 b.WriteString("issuer-scoped.\n\n")
857 b.WriteString("Append an issuer address to this path to see their book.\n")
858 return b.String()
859 }
860
861 who := sanitize.InlineText(path)
862 b.WriteString("## Book of " + who + "\n\n")
863 r := getIssuer(path)
864 if r == nil {
865 b.WriteString("No issuer record at that address.\n")
866 return b.String()
867 }
868 b.WriteString("Open: " + itoa(int64(r.open)) + ", ")
869 b.WriteString("stored: " + itoa(int64(r.records)) + "/" +
870 itoa(int64(MaxRecordsPerIssuer)) + ", ")
871 b.WriteString("issued all time: " + itoa(r.issued) + ", ")
872 b.WriteString("face value all time: " + itoa(r.invoiced) + " " + Denom + "\n\n")
873
874 now := runtime.ChainHeight()
875 b.WriteString("| id | payer | amount | paid | due | status | memo |\n")
876 b.WriteString("|---|---|---|---|---|---|---|\n")
877 prefix := path + "|"
878 invoices.Iterate(prefix, prefix+"\xff", func(_ string, v any) bool {
879 inv := v.(*invoice)
880 state := inv.status
881 if isOverdue(inv, now) {
882 state = "OVERDUE"
883 }
884 b.WriteString("| " + itoa(inv.id))
885 b.WriteString(" | " + sanitize.TableCell(inv.payer.String()))
886 b.WriteString(" | " + itoa(inv.amount))
887 b.WriteString(" | " + itoa(inv.paid))
888 b.WriteString(" | " + itoa(inv.dueHeight))
889 b.WriteString(" | " + state)
890 b.WriteString(" | " + cell(inv.memo) + " |\n")
891 return false
892 })
893 return b.String()
894}
895
896// cell prepares caller-supplied text for a GFM table cell, truncating it
897// for display first.
898//
899// TableCell rather than InlineText, and the slot is the reason: these
900// bytes land between two `|` delimiters, and an unescaped `|` in a memo
901// would open a new column and shift every column to its right. InlineText
902// does not escape the pipe. The payer cell above uses the same helper for
903// the same slot even though a bech32 address cannot contain one — matching
904// the helper to the slot is the rule that survives a later edit, whereas
905// "this particular value happens to be safe" is a fact about today's
906// charset.
907//
908// TRUNCATE BEFORE ESCAPING, and on a RUNE boundary. Escaping first and
909// cutting afterwards can slice a `\|` in half and leave the cell ending in
910// a bare backslash; cutting on a byte boundary can split a multi-byte rune
911// and emit invalid UTF-8. Neither is something the sanitizer is asked to
912// repair, because by the time it sees the string the damage is upstream.
913func cell(s string) string {
914 const maxRunes = 48
915 if r := []rune(s); len(r) > maxRunes {
916 s = string(r[:maxRunes]) + "…"
917 }
918 return sanitize.TableCell(s)
919}