netting.gno
50.46 Kb · 1389 lines
1// Realm netting is a multilateral obligation clearing register in GNOT.
2// A convenor opens a netting set and admits members; each member
3// declares, against other members, what it owes them; closing the set
4// computes every member's NET position — what it is owed minus what it
5// owes — and only those net amounts move. Net debtors pay in, net
6// creditors withdraw, and a graph of G gross obligations collapses to at
7// most N-1 transfers.
8//
9// WHY THIS EXISTS (see DISCOVERY.md): across 641 live packages on
10// gnoland-1 and 637 on onyx-1, thirteen chains mirrored in
11// gnolang/tx-exports, and GitHub-wide `extension:gno`, no package
12// records BILATERAL obligations between pairs of parties, computes net
13// positions, or compresses gross obligations into net transfers. The two
14// closest artifacts were read in full — mainnet
15// p/moul/x/daily/pullpayment/v0 and r/moul/x/daily/splitter/v1 — and
16// both fail on the same structural point: their ledgers are keyed by
17// PAYEE ALONE, so there is no payer axis, no directed graph, and nothing
18// to net. Both are ports of Solidity's canonical primitives, and both
19// ports kept the one-dimensional ledger.
20//
21// THE DELTA IS STATED AT ITS REAL SIZE AND NO LARGER. Multilateral
22// netting is not a new idea — it is CLS, ACH, Sardex, the Romanian
23// Ministry of Economy's national setoff, and a literature of graph
24// algorithms (DISCOVERY.md § out-of-language prior art). What is new
25// here is a Gno implementation, with this portfolio's live-validated
26// custody discipline. Nothing more is claimed.
27//
28// WHAT NETTING IS WORTH, HONESTLY: compression requires MUTUALITY. A
29// star graph — every member owing one hub and the hub owing nobody —
30// nets to itself, and the gross and net figures are identical. This
31// realm therefore REPORTS the gross and net terms (Compression) and
32// never asserts a saving. Read the two pairs of numbers; the ratio is
33// whatever the members' obligations make it.
34//
35// COMPOSITION: coin movement is delegated to coinio, fee arithmetic and
36// the fee pot to feeledger, the declaration window and exactly-once
37// settlement authorization to duebook, ordered storage to p/nt/avl/v0,
38// and free-text render output to the ecosystem sanitizer
39// p/nt/markdown/sanitize/v0. This realm owns only the netting
40// arithmetic, the set state machine, and the conservation bookkeeping.
41//
42// A NETTING SET HOLDS NO COINS WHILE IT IS OPEN. An obligation is a
43// DECLARATION, not a transfer: Declare moves no value and locks none.
44// Coins enter only at Settle, after the set has closed and net positions
45// are frozen. This is why Cancel and Lapse need no refund path at all —
46// there is nothing held to refund — and it is the sharpest difference
47// between this realm and the escrow family the discovery gate found.
48//
49// NET POSITIONS ARE MAINTAINED INCREMENTALLY, NOT COMPUTED AT CLOSE.
50// Each Declare applies -amount to the debtor and +amount to the creditor
51// in the same transaction, so the vector of net positions sums to zero
52// by construction at every height. Close asserts that sum is zero anyway
53// (netsSumToZero) before a single coin can be asked for. The assertion
54// is unreachable through the public API and is kept because it is the
55// one invariant whose violation would mint or destroy value.
56//
57// THE SETTLEMENT IS ONE DEFERRAL. Open schedules exactly one duebook
58// deferral per set — owner: the convenor, due: the declaration deadline,
59// expiry: the end of the closing window — and the three transitions out
60// of [open] are the three ways that deferral can be consumed:
61//
62// Close -> book.Claim (due, not expired; ANYONE)
63// Cancel -> book.Cancel (convenor renounces, while open)
64// Lapse -> book.Expire (anyone, once the closing window is over)
65//
66// Because duebook consumes a deferral before returning and never reuses
67// an ID, a set can leave [open] AT MOST ONCE, structurally. The book's
68// open-deferral cap is likewise this realm's cap on open sets.
69//
70// LIFECYCLE (stored states in brackets):
71//
72// Open (anyone) : opens [open]; caller is the convenor;
73// schedules the deferral.
74// Admit (convenor) : adds a member, while [open]. Admission
75// obligates nobody: it only makes an
76// address a legal endpoint of a declaration.
77// Declare (member) : "I owe this member X". Caller is ALWAYS
78// the debtor, so a declaration can only ever
79// increase the CALLER's own net debit. No
80// coins move.
81// Close (ANYONE) : height in [deadline, closeEnd). Freezes
82// every net position. netValue == 0 (a fully
83// circular graph) settles on the spot with
84// no coins ever held -> [settled]. Otherwise
85// -> [settling] with a pay-in window.
86// Settle (net debtor,+send): exactly the debtor's own net debit, once,
87// while [settling] and before settleEnd. The
88// last payment that completes the set
89// credits every net creditor -> [settled].
90// Abandon (ANYONE) : [settling], settleEnd reached, still
91// short. Every debtor who paid gets exactly
92// what it paid back, FEE-FREE -> [abandoned].
93// Cancel (convenor) : while [open] -> [cancelled]. No coins are
94// held, so nothing is refunded.
95// Lapse (ANYONE) : [open] and closeEnd reached -> [lapsed].
96// The permissionless valve: a set nobody
97// closes cannot hold its duebook slot
98// forever.
99// Withdraw (any creditor) : the caller's whole credit balance, in
100// coins. Pull-only; this realm never pushes.
101// Prune (ANYONE) : removes a terminal set holding no coins.
102// The freed storage deposit is refunded by
103// the chain to the CALLER, which is the
104// incentive to call it.
105// WithdrawFees (ANYONE) : pays the accrued fee pot to the
106// compile-time FeeCollector.
107// SweepSurplus (ANYONE) : out-of-band coins to the FeeCollector,
108// never touching tracked liabilities.
109//
110// ALL-OR-NOTHING, AND WHAT IT COSTS: a net creditor is credited only
111// once EVERY net debtor has paid in full. One defaulting debtor
112// therefore denies every creditor in that set — and the compensation is
113// that no creditor is ever credited a ugnot the realm does not already
114// hold, and every debtor who paid gets its money back fee-free. A
115// partial-settlement design would pay some creditors from other
116// debtors' money and need a rule for who; there is no such rule here
117// because there is no such case.
118//
119// THE FEE, precisely: feeBps is snapshotted at Open from the
120// compile-time SettleFeeBps, so a set's terms are fixed when it is
121// created and visible in SetInfo from that moment. It is charged ONCE,
122// at the moment a set becomes [settled], on each net creditor's credit,
123// with feeledger's floor rounding (rounding favors the creditor). The
124// ledger is constructed with the compile-time MaxFeeBps cap, so a fee
125// above the cap is refused by the primitive, not by a check in this
126// file. There is no fee on declaring, no fee on settling, and NO FEE ON
127// ANY ABANDON REFUND.
128//
129// THERE IS NO ADMIN. The fee rate, the fee collector, every bound and
130// every window are compile-time constants; no address can change terms,
131// pause the realm, or touch a set it does not convene. The convenor's
132// only powers are admitting members and cancelling its own set while it
133// is open — neither of which can move a coin. The deployer has no
134// privilege of any kind after deployment.
135//
136// MONETARY INVARIANT (conservation): let H be the ugnot held at this
137// realm's address, P the sum of coins paid in for sets still [settling]
138// (tracked O(1) as totalPending), L the ledger's liabilities (unclaimed
139// creditor credits, unclaimed abandon refunds, and the fee pot), and
140// S >= 0 the out-of-band surplus:
141//
142// H == P + L + S
143//
144// Every transition moves value between exactly two terms inside one
145// transaction: Settle raises H and P together (coinio.Receive is the
146// receipt-guaranteed shape); completing a set moves netValue out of P
147// and into L, split into creditor credits and the fee; Abandon moves
148// paidIn out of P and into L fee-free; Withdraw and WithdrawFees debit L
149// before the identical amount leaves H (checks-effects-interactions);
150// any panic aborts the whole transaction; this realm never issues or
151// removes coins.
152//
153// ONLY GNOT IS ACCEPTED: Settle rejects any envelope that is not exactly
154// one positive ugnot coin (coinio.Receive). Every other entrypoint
155// rejects attached coins outright rather than converting them into
156// sweepable surplus.
157//
158// AUTHORIZATION: every identity is derived from the crossing
159// entrypoint's cur.Previous().Address(). No function takes a caller
160// identity as a parameter, so declaring a debt as another member,
161// settling another member's net debit, or withdrawing another
162// creditor's balance is impossible by construction.
163//
164// STORAGE: a set record is paid for by the convenor's transaction
165// deposit, a member record by the convenor's Admit, and a pair record by
166// the declaring debtor's own transaction. Completing or abandoning a set
167// drops nothing; Prune removes the whole terminal record, and the chain
168// refunds the freed deposit to the transaction that frees it.
169package netting
170
171import (
172 "chain"
173 "chain/runtime"
174 "chain/runtime/unsafe"
175 "strconv"
176
177 "gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/coinio"
178 "gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/duebook"
179 "gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/feeledger"
180 "gno.land/p/nt/avl/v0"
181 "gno.land/p/nt/markdown/sanitize/v0"
182)
183
184// RealmPath is this realm's own path, used to build Render links.
185const RealmPath = "/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/netting"
186
187// Denom is the only asset this realm holds.
188const Denom = "ugnot"
189
190// MaxFeeBps is the hard ceiling on any fee this realm could ever charge,
191// enforced by the feeledger the realm is constructed with — a feeBps
192// above it is refused by the primitive's own validation, not by a check
193// in this file. 500 bps = 5%.
194const MaxFeeBps = int64(500)
195
196// SettleFeeBps is the fee actually snapshotted into every set at Open:
197// 100 bps = 1%, charged once, when the set becomes settled, on each net
198// creditor's credit, floor-rounded in the creditor's favor. 1% is the
199// most conservative live fee in this portfolio (fee_split precedent) and
200// is what crowdfund charges.
201const SettleFeeBps = int64(100)
202
203// FeeCollector receives withdrawn fees and swept surplus. Compile-time
204// constant: there is no setter and no transfer path. It is the deploying
205// namespace's address — stated plainly: the operator of this realm.
206const FeeCollector = address("g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3")
207
208// MinObligation keeps dust declarations out of the graph: 0.001 GNOT.
209// Declaring costs no coins, so the only thing a dust declaration wastes
210// is the declarer's own storage deposit — but a graph full of 1-ugnot
211// edges makes the compression figures meaningless, which is the output
212// this realm exists to produce.
213const MinObligation = int64(1_000)
214
215// MaxMembers caps one set's membership. Close iterates the member tree
216// twice (once to assert the nets sum to zero, once to freeze positions),
217// and completing a set iterates it once more to credit creditors, so
218// this is the bound that keeps every O(M) pass inside a transaction's
219// gas. 32 is deliberately modest: a clearing set is a counterparty
220// group, not a population.
221const MaxMembers = 32
222
223// MaxPairs caps the DISTINCT ordered (debtor, creditor) pairs one set
224// may hold. Declarations themselves are unbounded in count — a repeat
225// declaration between an existing pair accumulates into that pair's
226// record and allocates nothing — so this bounds storage, not activity.
227// MaxMembers*(MaxMembers-1) == 992 is the complete digraph; 256 is a
228// deliberate fraction of it.
229const MaxPairs = 256
230
231const (
232 // MinDeclareBlocks and MaxDeclareBlocks bound the declaration
233 // window, in blocks, and are enforced by the duebook's own Schedule
234 // validation rather than by a check in this file.
235 MinDeclareBlocks = int64(1_200)
236 MaxDeclareBlocks = int64(1_300_000)
237)
238
239// CloseWindowBlocks is how long after the deadline a set may still be
240// closed. Past it, Close is impossible (the deferral has expired) and
241// Lapse is the only way out — which strands no money, because an open
242// set holds none.
243const CloseWindowBlocks = int64(650_000)
244
245// SettleWindowBlocks is how long net debtors have to pay in after a set
246// closes. Past it, Abandon refunds every payer fee-free.
247const SettleWindowBlocks = int64(650_000)
248
249// MaxOpenSets caps simultaneously open sets, via the duebook's own
250// open-deferral cap. The AVAILABILITY BOUND this implies is stated
251// rather than hidden: when the book is full, Open fails until a set is
252// closed, cancelled or lapsed. Settling and terminal sets do not occupy
253// a slot — their deferral is already consumed.
254const MaxOpenSets = 1024
255
256// MaxOpenPerConvenor bounds how much of the global cap one convenor may
257// occupy (permission_registry R1 precedent: an uncapped per-principal
258// share of a global cap is a griefing vector, not a feature).
259const MaxOpenPerConvenor = 64
260
261const (
262 // MaxLabelLen and MaxMemoLen bound the free text a set carries. Both
263 // are sanitized before they reach Render.
264 MaxLabelLen = 100
265 MaxMemoLen = 256
266)
267
268// MaxRenderRows bounds the list length on the render pages.
269const MaxRenderRows = 50
270
271// set states. "open" and "settling" are the two non-terminal states;
272// "settling" is the only state in which this realm holds coins for a
273// set.
274const (
275 stateOpen = "open"
276 stateSettling = "settling"
277 stateSettled = "settled"
278 stateAbandoned = "abandoned"
279 stateCancelled = "cancelled"
280 stateLapsed = "lapsed"
281)
282
283// member is one party to a netting set.
284//
285// net is maintained incrementally by Declare and is the whole of the
286// netting arithmetic: positive means the member is owed that much on
287// balance, negative means it owes that much. debit and credit are
288// FROZEN AT CLOSE from net, so that the settlement figures cannot move
289// after coins have been asked for.
290type member struct {
291 addr address
292 net int64 // + owed to them, - they owe; live while [open]
293 debit int64 // frozen at Close: -net when net < 0, else 0
294 credit int64 // frozen at Close: net when net > 0, else 0
295 paid int64 // coins received from this member during [settling]
296
297 grossOut int64 // gross declared BY them (for the compression view)
298 grossIn int64 // gross declared TOWARD them
299}
300
301type set struct {
302 id int64
303 convenor address
304 label string
305 memo string
306 feeBps int64 // snapshotted at Open
307 createdAt int64
308 deadline int64 // createdAt + declareBlocks; declaring while height < deadline
309 closeEnd int64 // deadline + CloseWindowBlocks; == deferral ExpiresAt
310 settleEnd int64 // set at Close; 0 while [open]
311 defID uint64
312 state string
313
314 members *avl.Tree // member address string -> *member
315 pairs *avl.Tree // pairKey(debtor, creditor) -> int64 (> 0)
316 pairCount int
317
318 grossCount int64 // declarations accepted
319 grossValue int64 // sum of every declared amount
320
321 // frozen at Close.
322 netTransferCount int64 // members with a non-zero net position
323 netValue int64 // total net debits == total net credits
324 closedAt int64
325
326 paidIn int64 // coins currently held for this set; 0 unless [settling]
327}
328
329var (
330 self address
331
332 sets = avl.NewTree() // padID(id) -> *set
333 nextID int64
334
335 // openByConvenor tracks each convenor's still-open sets:
336 // address string -> int64 count (> 0).
337 openByConvenor = avl.NewTree()
338
339 // totalPending is the P term of H == P + L + S: the sum of paidIn
340 // across every set, maintained O(1) at every transition. Only a
341 // [settling] set can have a non-zero paidIn.
342 totalPending int64
343
344 // lifetime counters, rendered for operators.
345 lifetimeSets int64 // sets that reached [settled]
346 lifetimeGrossValue int64 // declared value across every closed set
347 lifetimeNetValue int64 // net value across every closed set
348 lifetimeSettledIn int64 // coins paid in by net debtors
349 lifetimeCredited int64 // credited to net creditors, net of fee
350 lifetimeRefunded int64 // returned by Abandon, fee-free
351 lifetimeWithdrawn int64 // paid out of the ledger to creditors
352
353 // ledger carries BOTH the net-creditor credits and the abandon
354 // refunds, which is what collapses two liability buckets into one
355 // tracked Liabilities() term. It also owns the fee cap and the fee
356 // pot.
357 ledger = feeledger.MustNew(MaxFeeBps)
358
359 // book holds one open deferral per open set. The declaration-window
360 // bounds are the primitive's own validation: a window outside
361 // [MinDeclareBlocks, MaxDeclareBlocks] is refused by Schedule.
362 book = duebook.MustNew(MinDeclareBlocks, MaxDeclareBlocks, MaxOpenSets)
363)
364
365func init() {
366 self = unsafe.CurrentRealm().Address()
367}
368
369// rejectStraySend aborts when coins are attached to a non-payable call.
370// This realm holds funds, so a stray send would become sweepable
371// surplus, silently converting a user's coins into operator funds.
372// Aborting reverts the transfer instead. Guarded on IsUserCall, not
373// IsUser, because a MsgRun ephemeral can consume the OriginSend envelope
374// before forwarding control; for a realm-routed call the envelope lands
375// at the intermediary, so there is nothing here to reject and the guard
376// deliberately fails open (crowdfund and treasury_board document the
377// same scope).
378func rejectStraySend(cur realm) {
379 if !cur.IsCurrent() {
380 // Every call site forwards a crossing entrypoint's own live cur,
381 // so this cannot fire today; it exists so a future refactor that
382 // hands this guard a stale realm value fails closed, not open
383 // (coinio names IsCurrent as the guard secondary realm
384 // parameters require, and this helper follows that discipline).
385 panic("netting: realm capability is not current")
386 }
387 if cur.Previous().IsUserCall() && len(unsafe.OriginSend()) > 0 {
388 panic("this entrypoint does not accept coins")
389 }
390}
391
392// --- set lifecycle ---
393
394// Open convenes a netting set and returns its id. Anyone may open one;
395// the caller becomes the convenor. The fee terms (SettleFeeBps at this
396// moment — a compile-time constant, so always SettleFeeBps) are
397// snapshotted into the set and are fixed from here on. declareBlocks is
398// the length of the declaration window and must be within
399// [MinDeclareBlocks, MaxDeclareBlocks] — enforced by the duebook.
400//
401// The convenor is NOT automatically a member. A convenor that wants to
402// net its own obligations must Admit itself, which keeps "who runs the
403// set" and "who is party to it" separate concepts.
404func Open(cur realm, label, memo string, declareBlocks int64) int64 {
405 rejectStraySend(cur)
406 convenor := cur.Previous().Address()
407
408 if label == "" {
409 panic("label must not be empty")
410 }
411 if len(label) > MaxLabelLen {
412 panic("label exceeds " + strconv.Itoa(MaxLabelLen) + " bytes")
413 }
414 if len(memo) > MaxMemoLen {
415 panic("memo exceeds " + strconv.Itoa(MaxMemoLen) + " bytes")
416 }
417
418 key := convenor.String()
419 if convenorOpen(key) >= MaxOpenPerConvenor {
420 panic("convenor already has " + strconv.Itoa(MaxOpenPerConvenor) +
421 " open sets")
422 }
423
424 now := runtime.ChainHeight()
425 id := nextID + 1
426
427 // The deferral is the settlement authorization: due at the
428 // declaration deadline, expiring when the closing window ends.
429 // Window bounds and the global open-set cap are enforced here by the
430 // primitive.
431 //
432 // nextID advances only AFTER the deferral exists. On chain the
433 // distinction is invisible — a panic reverts the whole transaction —
434 // but it keeps "an id was issued" and "a set exists" from diverging
435 // under any future refactor that recovers instead of aborting, and it
436 // is what lets the test suite assert an exact id after a refused Open.
437 defID := book.MustSchedule(key, itoa(id), now, declareBlocks, CloseWindowBlocks)
438 nextID = id
439
440 s := &set{
441 id: id,
442 convenor: convenor,
443 label: label,
444 memo: memo,
445 feeBps: SettleFeeBps,
446 createdAt: now,
447 deadline: now + declareBlocks,
448 closeEnd: now + declareBlocks + CloseWindowBlocks,
449 defID: defID,
450 state: stateOpen,
451 members: avl.NewTree(),
452 pairs: avl.NewTree(),
453 }
454 sets.Set(padID(id), s)
455 bumpConvenor(key, 1)
456
457 chain.Emit("SetOpened", "id", itoa(id), "convenor", key,
458 "deadline", itoa(s.deadline), "closeEnd", itoa(s.closeEnd))
459 return id
460}
461
462// Admit adds an address to a set's membership while the set is open.
463// Convenor only.
464//
465// ADMISSION OBLIGATES NOBODY. Being a member only makes an address a
466// legal endpoint of a declaration — it cannot be made a debtor by
467// anyone but itself (see Declare). What admission does control is the
468// griefing surface: without it, any address could fill a set to MaxPairs
469// with declarations naming strangers, and the convenor could not form
470// the group it meant to form.
471func Admit(cur realm, id int64, addr address) {
472 rejectStraySend(cur)
473 s := mustOpen(id)
474 if cur.Previous().Address() != s.convenor {
475 panic("convenor only")
476 }
477 if addr == "" {
478 panic("member address must not be empty")
479 }
480 key := addr.String()
481 if s.members.Has(key) {
482 panic("already a member")
483 }
484 if s.members.Size() >= MaxMembers {
485 panic("set already has " + strconv.Itoa(MaxMembers) + " members")
486 }
487 s.members.Set(key, &member{addr: addr})
488
489 chain.Emit("Admitted", "id", itoa(s.id), "member", key,
490 "members", strconv.Itoa(s.members.Size()))
491}
492
493// Declare records that the CALLER owes a fellow member `amount` ugnot,
494// while the set is open. No coins move and none are locked: an
495// obligation is a declaration, and the only value this realm ever holds
496// arrives at Settle.
497//
498// The caller is ALWAYS the debtor. That is the whole authorization
499// model here and it is worth saying plainly: a declaration can only
500// increase the caller's own net debit and some other member's net
501// credit, so no address can be made to owe anything by anyone else.
502// Declaring repeatedly toward the same creditor accumulates into one
503// pair record and allocates no new storage.
504func Declare(cur realm, id int64, creditor address, amount int64) {
505 rejectStraySend(cur)
506 debtorAddr := cur.Previous().Address()
507 s := mustOpen(id)
508 if runtime.ChainHeight() >= s.deadline {
509 panic("declaration window is closed")
510 }
511 if amount < MinObligation {
512 panic("declare at least " + itoa(MinObligation) + Denom)
513 }
514 if creditor == debtorAddr {
515 panic("cannot owe yourself")
516 }
517
518 dKey := debtorAddr.String()
519 cKey := creditor.String()
520 d := memberOf(s, dKey)
521 if d == nil {
522 panic("caller is not a member of this set")
523 }
524 c := memberOf(s, cKey)
525 if c == nil {
526 panic("creditor is not a member of this set")
527 }
528
529 pk := pairKey(dKey, cKey)
530 prior := pairAmount(s, pk)
531 newPair, ok := checkedAdd(prior, amount)
532 if !ok {
533 panic("pair obligation would overflow")
534 }
535 if prior == 0 && s.pairCount >= MaxPairs {
536 panic("set already has " + strconv.Itoa(MaxPairs) + " obligation pairs")
537 }
538
539 // -amount on the debtor, +amount on the creditor, in one transaction.
540 // checkedAdd handles a negative addend, and amount is strictly
541 // positive here, so -amount can never be math.MinInt64.
542 newDebtorNet, ok := checkedAdd(d.net, -amount)
543 if !ok {
544 panic("debtor net position would overflow")
545 }
546 newCreditorNet, ok := checkedAdd(c.net, amount)
547 if !ok {
548 panic("creditor net position would overflow")
549 }
550 newGrossOut, ok := checkedAdd(d.grossOut, amount)
551 if !ok {
552 panic("gross outgoing would overflow")
553 }
554 newGrossIn, ok := checkedAdd(c.grossIn, amount)
555 if !ok {
556 panic("gross incoming would overflow")
557 }
558 newGrossValue, ok := checkedAdd(s.grossValue, amount)
559 if !ok {
560 panic("set gross value would overflow")
561 }
562 newGrossCount, ok := checkedAdd(s.grossCount, 1)
563 if !ok {
564 panic("set gross count would overflow")
565 }
566
567 if prior == 0 {
568 s.pairCount++
569 }
570 s.pairs.Set(pk, newPair)
571 d.net = newDebtorNet
572 c.net = newCreditorNet
573 d.grossOut = newGrossOut
574 c.grossIn = newGrossIn
575 s.grossValue = newGrossValue
576 s.grossCount = newGrossCount
577
578 chain.Emit("Declared", "id", itoa(s.id), "debtor", dKey,
579 "creditor", cKey, "amount", itoa(amount),
580 "pairTotal", itoa(newPair))
581}
582
583// Close freezes every net position in a set and opens the pay-in window.
584// Anyone may call it, from the declaration deadline until the closing
585// window ends; the duebook Claim consumes the set's deferral, so this
586// can succeed at most once per set, ever.
587//
588// A set whose net value is ZERO — every member's obligations exactly
589// cancelled, which is what a fully circular graph produces — settles on
590// the spot. No coins are ever asked for, held, or paid, and the gross
591// value is extinguished in one transaction. That case is the whole
592// point of the realm, so it is the fast path rather than a special one.
593func Close(cur realm, id int64) {
594 rejectStraySend(cur)
595 s := mustOpen(id)
596 now := runtime.ChainHeight()
597
598 // Claim enforces due-and-not-expired and consumes the deferral. The
599 // height checks live in the primitive, not here.
600 book.MustClaim(s.defID, now)
601
602 // The nets sum to zero by construction (Declare applies -a and +a in
603 // one transaction), so this cannot fire through the public API. It is
604 // asserted anyway, BEFORE any coin is asked for, because a violation
605 // would mean the realm is about to mint or destroy value — the one
606 // invariant where failing closed is worth an O(M) pass.
607 if sum := netsSumToZero(s); sum != 0 {
608 panic("netting: net positions do not sum to zero (" + itoa(sum) + ")")
609 }
610
611 var debits, credits, transfers int64
612 s.members.Iterate("", "", func(_ string, v any) bool {
613 m := v.(*member)
614 switch {
615 case m.net < 0:
616 m.debit = -m.net
617 d, ok := checkedAdd(debits, m.debit)
618 if !ok {
619 panic("total net debits would overflow")
620 }
621 debits = d
622 transfers++
623 case m.net > 0:
624 m.credit = m.net
625 c, ok := checkedAdd(credits, m.credit)
626 if !ok {
627 panic("total net credits would overflow")
628 }
629 credits = c
630 transfers++
631 }
632 return false
633 })
634 if debits != credits {
635 panic("netting: net debits and credits disagree")
636 }
637
638 s.netValue = debits
639 s.netTransferCount = transfers
640 s.closedAt = now
641
642 if gv, ok := checkedAdd(lifetimeGrossValue, s.grossValue); ok {
643 lifetimeGrossValue = gv
644 } else {
645 panic("lifetime gross value would overflow")
646 }
647 if nv, ok := checkedAdd(lifetimeNetValue, s.netValue); ok {
648 lifetimeNetValue = nv
649 } else {
650 panic("lifetime net value would overflow")
651 }
652
653 bumpConvenor(s.convenor.String(), -1)
654
655 if debits == 0 {
656 // Fully netted. Nothing to pay, nothing to credit, no window.
657 s.state = stateSettled
658 if n, ok := checkedAdd(lifetimeSets, 1); ok {
659 lifetimeSets = n
660 } else {
661 panic("lifetime sets would overflow")
662 }
663 chain.Emit("SetClosed", "id", itoa(s.id), "state", s.state,
664 "grossCount", itoa(s.grossCount), "grossValue", itoa(s.grossValue),
665 "netTransfers", itoa(transfers), "netValue", "0")
666 return
667 }
668
669 s.state = stateSettling
670 s.settleEnd = now + SettleWindowBlocks
671 chain.Emit("SetClosed", "id", itoa(s.id), "state", s.state,
672 "grossCount", itoa(s.grossCount), "grossValue", itoa(s.grossValue),
673 "netTransfers", itoa(transfers), "netValue", itoa(s.netValue),
674 "settleEnd", itoa(s.settleEnd))
675}
676
677// Settle pays in the caller's own net debit, in full, once. The attached
678// envelope must be exactly the caller's frozen net debit — not more, not
679// less, and not in instalments. Partial payment is refused rather than
680// accommodated: it would leave the set holding money that belongs to
681// neither the payer nor any creditor, and a rule for who gets it first.
682//
683// The payment that completes the set credits every net creditor in the
684// same transaction and the set becomes settled.
685func Settle(cur realm, id int64) {
686 payer, amount := coinio.Receive(0, cur, Denom)
687 s := mustGet(id)
688 if s.state != stateSettling {
689 panic("set is not settling")
690 }
691 if runtime.ChainHeight() >= s.settleEnd {
692 panic("settlement window is closed")
693 }
694
695 key := payer.String()
696 m := memberOf(s, key)
697 if m == nil {
698 panic("caller is not a member of this set")
699 }
700 if m.debit == 0 {
701 panic("caller is not a net debtor")
702 }
703 if m.paid != 0 {
704 panic("caller has already settled")
705 }
706 if amount != m.debit {
707 panic("send exactly " + itoa(m.debit) + Denom)
708 }
709
710 newPaidIn, ok := checkedAdd(s.paidIn, amount)
711 if !ok {
712 panic("set pay-in would overflow")
713 }
714 newPending, ok := checkedAdd(totalPending, amount)
715 if !ok {
716 panic("total pending would overflow")
717 }
718 newLifetime, ok := checkedAdd(lifetimeSettledIn, amount)
719 if !ok {
720 panic("lifetime settled-in would overflow")
721 }
722
723 m.paid = amount
724 s.paidIn = newPaidIn
725 totalPending = newPending
726 lifetimeSettledIn = newLifetime
727
728 chain.Emit("Settled", "id", itoa(s.id), "debtor", key,
729 "amount", itoa(amount), "paidIn", itoa(s.paidIn),
730 "netValue", itoa(s.netValue))
731
732 if s.paidIn == s.netValue {
733 completeSet(s)
734 }
735}
736
737// completeSet converts a fully-funded set's pay-ins into creditor
738// credits and marks it settled. It is reached only from Settle, only
739// when paidIn == netValue, and it is where the fee is charged.
740//
741// The ORDER is the conservation argument: paidIn and totalPending are
742// debited by exactly netValue, and the identical netValue is deposited
743// into the ledger as credits plus fee. feeledger.Deposit credits
744// amount-fee and accrues fee, so the two sides agree to the ugnot.
745func completeSet(s *set) {
746 moved := s.netValue
747 s.paidIn = 0
748 totalPending -= moved
749 s.state = stateSettled
750
751 var credited int64
752 s.members.Iterate("", "", func(_ string, v any) bool {
753 m := v.(*member)
754 if m.credit == 0 {
755 return false
756 }
757 got, _ := ledger.MustDeposit(m.addr.String(), m.credit, s.feeBps)
758 c, ok := checkedAdd(credited, got)
759 if !ok {
760 panic("credited total would overflow")
761 }
762 credited = c
763 return false
764 })
765
766 if n, ok := checkedAdd(lifetimeCredited, credited); ok {
767 lifetimeCredited = n
768 } else {
769 panic("lifetime credited would overflow")
770 }
771 if n, ok := checkedAdd(lifetimeSets, 1); ok {
772 lifetimeSets = n
773 } else {
774 panic("lifetime sets would overflow")
775 }
776
777 chain.Emit("SetSettled", "id", itoa(s.id), "netValue", itoa(moved),
778 "credited", itoa(credited), "feeBps", itoa(s.feeBps))
779}
780
781// Abandon ends a set that reached its settlement deadline still short,
782// and credits every debtor who paid exactly what it paid, FEE-FREE.
783// Anyone may call it: the outcome is fully determined by the set's own
784// state, so there is nothing for a caller to steer.
785//
786// Net creditors receive nothing on this path, and that is the
787// all-or-nothing guarantee working rather than failing: they were never
788// credited, so there is no clawback, and no creditor was ever paid from
789// another debtor's money.
790func Abandon(cur realm, id int64) {
791 rejectStraySend(cur)
792 s := mustGet(id)
793 if s.state != stateSettling {
794 panic("set is not settling")
795 }
796 if runtime.ChainHeight() < s.settleEnd {
797 panic("settlement window is still open")
798 }
799 if s.paidIn == s.netValue {
800 // Unreachable: Settle completes the set in the same transaction
801 // that makes paidIn equal netValue, so a fully-funded set is
802 // never [settling]. Kept so that a future change which defers
803 // completion fails closed instead of refunding a settled set.
804 panic("set is fully funded")
805 }
806
807 returned := s.paidIn
808 s.paidIn = 0
809 totalPending -= returned
810 s.state = stateAbandoned
811
812 var refunded int64
813 s.members.Iterate("", "", func(_ string, v any) bool {
814 m := v.(*member)
815 if m.paid == 0 {
816 return false
817 }
818 // Fee-free, deliberately: the refund path never charges.
819 got, _ := ledger.MustDeposit(m.addr.String(), m.paid, 0)
820 r, ok := checkedAdd(refunded, got)
821 if !ok {
822 panic("refunded total would overflow")
823 }
824 refunded = r
825 return false
826 })
827 if refunded != returned {
828 panic("netting: abandon refunds disagree with coins held")
829 }
830 if n, ok := checkedAdd(lifetimeRefunded, refunded); ok {
831 lifetimeRefunded = n
832 } else {
833 panic("lifetime refunded would overflow")
834 }
835
836 chain.Emit("SetAbandoned", "id", itoa(s.id), "shortfall",
837 itoa(s.netValue-returned), "refunded", itoa(refunded))
838}
839
840// Cancel ends an open set. Convenor only, any time while the set is
841// open. No refund path is needed and none exists: an open set holds no
842// coins, because a declaration is not a transfer. What members lose is
843// the storage deposit and gas they spent declaring, which is stated
844// plainly rather than hidden — a convenor can waste a member's gas but
845// can never touch a member's money.
846func Cancel(cur realm, id int64) {
847 rejectStraySend(cur)
848 s := mustOpen(id)
849 if cur.Previous().Address() != s.convenor {
850 panic("convenor only")
851 }
852 book.MustCancel(s.defID, s.convenor.String())
853 s.state = stateCancelled
854 bumpConvenor(s.convenor.String(), -1)
855
856 chain.Emit("SetCancelled", "id", itoa(s.id),
857 "grossCount", itoa(s.grossCount), "grossValue", itoa(s.grossValue))
858}
859
860// Lapse ends an open set whose closing window has passed. Anyone may
861// call it. This is the permissionless valve: a set nobody closes cannot
862// hold its duebook slot forever, and because an open set holds no coins
863// there is nothing stranded by the outcome.
864func Lapse(cur realm, id int64) {
865 rejectStraySend(cur)
866 s := mustOpen(id)
867 book.MustExpire(s.defID, runtime.ChainHeight())
868 s.state = stateLapsed
869 bumpConvenor(s.convenor.String(), -1)
870
871 chain.Emit("SetLapsed", "id", itoa(s.id),
872 "grossCount", itoa(s.grossCount), "grossValue", itoa(s.grossValue))
873}
874
875// --- creditor withdrawal ---
876
877// Withdraw pays the caller its entire credit balance in coins and
878// returns the amount. Pull-only: this realm never pushes a payout, so a
879// creditor that cannot receive coins can never block a settlement.
880// Credits arrive from a settled set (net of that set's fee) and from an
881// abandoned set's refund (fee-free); both land in the same balance.
882func Withdraw(cur realm) int64 {
883 rejectStraySend(cur)
884 caller := cur.Previous().Address()
885 key := caller.String()
886
887 amount, err := ledger.WithdrawAll(key)
888 if err != nil {
889 panic(err)
890 }
891 if amount == 0 {
892 panic("no credit to withdraw")
893 }
894 if n, ok := checkedAdd(lifetimeWithdrawn, amount); ok {
895 lifetimeWithdrawn = n
896 } else {
897 panic("lifetime withdrawn would overflow")
898 }
899
900 coinio.Payout(0, cur, caller, Denom, amount)
901 chain.Emit("Withdrawn", "to", key, "amount", itoa(amount))
902 return amount
903}
904
905// Prune removes a terminal set that holds no coins, reclaiming its
906// storage. Anyone may call it: the chain refunds the freed storage
907// deposit to the pruning transaction, which is the incentive. Credits
908// already in the ledger are unaffected — they are a per-address balance,
909// not part of the set record.
910func Prune(cur realm, id int64) {
911 rejectStraySend(cur)
912 s := mustGet(id)
913 if s.state == stateOpen || s.state == stateSettling {
914 panic("set is not terminal")
915 }
916 if s.paidIn != 0 {
917 panic("set still holds coins")
918 }
919 sets.Remove(padID(s.id))
920 chain.Emit("Pruned", "id", itoa(s.id), "by",
921 cur.Previous().Address().String())
922}
923
924// --- fees and surplus ---
925
926// WithdrawFees pays the entire accrued fee pot to the compile-time
927// FeeCollector. Anyone may call it: the destination is fixed, so there
928// is nothing for a caller to steer.
929func WithdrawFees(cur realm) int64 {
930 rejectStraySend(cur)
931 fees := ledger.WithdrawFees()
932 if fees == 0 {
933 panic("no fees accrued")
934 }
935 coinio.Payout(0, cur, FeeCollector, Denom, fees)
936 chain.Emit("FeesWithdrawn", "to", FeeCollector.String(), "amount", itoa(fees))
937 return fees
938}
939
940// SweepSurplus sends out-of-band coins to the FeeCollector: for the pot
941// denom, everything above tracked liabilities; for any foreign denom,
942// the full balance. Pending pay-ins, creditor credits and the fee pot
943// are untouchable by construction (coinio.Sweep refuses to dip below the
944// reserve). Anyone may call it.
945func SweepSurplus(cur realm, denom string) int64 {
946 rejectStraySend(cur)
947 reserve := int64(0)
948 if denom == Denom {
949 reserve = Liabilities()
950 }
951 swept := coinio.Sweep(0, cur, FeeCollector, denom, reserve)
952 chain.Emit("Swept", "denom", denom, "to", FeeCollector.String(),
953 "amount", itoa(swept))
954 return swept
955}
956
957// --- views ---
958
959// SetInfo returns a set's fixed terms and live tallies.
960func SetInfo(id int64) (convenor address, feeBps, createdAt, deadline,
961 closeEnd, settleEnd int64, state string, members int, pairs int,
962 paidIn int64) {
963 s := mustGet(id)
964 return s.convenor, s.feeBps, s.createdAt, s.deadline, s.closeEnd,
965 s.settleEnd, s.state, s.members.Size(), s.pairCount, s.paidIn
966}
967
968// Compression returns the four numbers the product exists to produce:
969// how many obligations were declared and for how much, against how many
970// transfers the netting leaves and for how much. The RATIO IS NOT
971// RETURNED and is not asserted anywhere — divide them if you want it,
972// and note that netValue can legitimately be zero (a fully circular
973// graph, the best possible case), which is exactly why no ratio is
974// computed here.
975//
976// While a set is still open the net figures are zero because they are
977// frozen at Close, not derived on read.
978func Compression(id int64) (grossCount, netTransferCount, grossValue, netValue int64) {
979 s := mustGet(id)
980 return s.grossCount, s.netTransferCount, s.grossValue, s.netValue
981}
982
983// SetLabel returns a set's label, verbatim and unsanitized. Render
984// sanitizes; a caller reading the raw value gets the raw value.
985func SetLabel(id int64) string { return mustGet(id).label }
986
987// SetMemo returns a set's memo, verbatim and unsanitized.
988func SetMemo(id int64) string { return mustGet(id).memo }
989
990// Status returns a set's stored state.
991func Status(id int64) string { return mustGet(id).state }
992
993// IsMember reports whether an address is party to a set.
994func IsMember(id int64, addr address) bool {
995 return memberOf(mustGet(id), addr.String()) != nil
996}
997
998// NetOf returns a member's live net position: positive when it is owed
999// that much on balance, negative when it owes that much. It is live
1000// while the set is open and frozen thereafter. A non-member reads 0,
1001// which is also a member's value when its obligations cancel exactly —
1002// use IsMember to tell the two apart.
1003func NetOf(id int64, addr address) int64 {
1004 if m := memberOf(mustGet(id), addr.String()); m != nil {
1005 return m.net
1006 }
1007 return 0
1008}
1009
1010// PositionOf returns a member's frozen settlement figures: what it must
1011// pay in, what it is owed, and what it has actually paid. All three are
1012// zero until the set is closed.
1013func PositionOf(id int64, addr address) (debit, credit, paid int64) {
1014 if m := memberOf(mustGet(id), addr.String()); m != nil {
1015 return m.debit, m.credit, m.paid
1016 }
1017 return 0, 0, 0
1018}
1019
1020// GrossOf returns a member's gross declared flows: what it has declared
1021// it owes, and what others have declared they owe it. These are the
1022// uncompressed figures, kept so a reader can see what netting removed.
1023func GrossOf(id int64, addr address) (out, in int64) {
1024 if m := memberOf(mustGet(id), addr.String()); m != nil {
1025 return m.grossOut, m.grossIn
1026 }
1027 return 0, 0
1028}
1029
1030// Obligation returns the cumulative amount debtor has declared it owes
1031// creditor in a set. Direction matters: Obligation(id, a, b) and
1032// Obligation(id, b, a) are different edges and both may be non-zero —
1033// the graph is directed, and netting them is Close's job, not storage's.
1034func Obligation(id int64, debtor, creditor address) int64 {
1035 s := mustGet(id)
1036 return pairAmount(s, pairKey(debtor.String(), creditor.String()))
1037}
1038
1039// MemberCount returns how many addresses are party to a set.
1040func MemberCount(id int64) int { return mustGet(id).members.Size() }
1041
1042// NumSets returns how many sets have ever been opened. It is the id
1043// high-water mark, so it does not decrease when a set is pruned.
1044func NumSets() int64 { return nextID }
1045
1046// OpenSets returns how many sets are currently open, via the duebook's
1047// own open-deferral count.
1048func OpenSets() int64 { return int64(book.OpenCount()) }
1049
1050// OpenOf returns how many open sets an address convenes.
1051func OpenOf(addr address) int64 { return convenorOpen(addr.String()) }
1052
1053// BalanceOf returns an address's withdrawable credit.
1054func BalanceOf(addr address) int64 { return ledger.BalanceOf(addr.String()) }
1055
1056// TotalPending is the P term of the conservation invariant: coins paid
1057// in for sets that have not yet settled or been abandoned.
1058func TotalPending() int64 { return totalPending }
1059
1060// FeesAccrued is the fee pot, withdrawable only to the FeeCollector.
1061func FeesAccrued() int64 { return ledger.FeesAccrued() }
1062
1063// Liabilities is everything this realm owes anyone: pending pay-ins plus
1064// unclaimed credits plus the fee pot.
1065func Liabilities() int64 { return totalPending + ledger.Liabilities() }
1066
1067// Held is the realm's actual ugnot balance.
1068func Held() int64 { return coinio.HeldAt(self, Denom) }
1069
1070// Surplus is Held minus Liabilities: out-of-band coins, sweepable to the
1071// FeeCollector. It is never negative in correct operation.
1072func Surplus() int64 { return Held() - Liabilities() }
1073
1074// Lifetime returns the realm's cumulative counters: sets settled, gross
1075// and net value across every closed set, coins paid in, credited to
1076// creditors, refunded by Abandon, and withdrawn.
1077func Lifetime() (sets, grossValue, netValue, settledIn, credited,
1078 refunded, withdrawn int64) {
1079 return lifetimeSets, lifetimeGrossValue, lifetimeNetValue,
1080 lifetimeSettledIn, lifetimeCredited, lifetimeRefunded,
1081 lifetimeWithdrawn
1082}
1083
1084// Address returns this realm's own address, which is where coins paid in
1085// are held.
1086func Address() address { return self }
1087
1088// Height returns the chain height this realm sees, so a caller can
1089// compare it against a deadline without a second query.
1090func Height() int64 { return runtime.ChainHeight() }
1091
1092// --- internals ---
1093
1094// mustGet resolves a set id or aborts.
1095//
1096// avl/v0's Get returns ONE value — the stored value, or nil when the key
1097// is absent — so absence is detected by the type assertion rather than
1098// by a second boolean return. That is sound here, and sound only because
1099// this realm never stores a nil under any key in any of its trees: the
1100// package's own doc warns that a key holding nil is indistinguishable
1101// from a missing key, which would turn the assertion's ok from "the key
1102// is present" into "the key is present AND well-typed". Every Set call
1103// in this file stores a non-nil *set, *member or int64.
1104func mustGet(id int64) *set {
1105 s, ok := sets.Get(padID(id)).(*set)
1106 if !ok {
1107 panic("no such set")
1108 }
1109 return s
1110}
1111
1112func mustOpen(id int64) *set {
1113 s := mustGet(id)
1114 if s.state != stateOpen {
1115 panic("set is not open")
1116 }
1117 return s
1118}
1119
1120func memberOf(s *set, key string) *member {
1121 if m, ok := s.members.Get(key).(*member); ok {
1122 return m
1123 }
1124 return nil
1125}
1126
1127func pairAmount(s *set, pk string) int64 {
1128 if n, ok := s.pairs.Get(pk).(int64); ok {
1129 return n
1130 }
1131 return 0
1132}
1133
1134// pairKey builds the storage key for a directed edge. The separator is a
1135// newline, which no bech32 address can contain, so two different
1136// (debtor, creditor) pairs can never collide into one key.
1137func pairKey(debtor, creditor string) string { return debtor + "\n" + creditor }
1138
1139// netsSumToZero returns the sum of every member's net position, which
1140// must be zero. It is a separate O(M) pass rather than folded into
1141// Close's freezing loop on purpose: the assertion has to hold over the
1142// WHOLE membership before any position is frozen, and a single loop
1143// would freeze the early members before it could know about the late
1144// ones.
1145func netsSumToZero(s *set) int64 {
1146 var sum int64
1147 s.members.Iterate("", "", func(_ string, v any) bool {
1148 n, ok := checkedAdd(sum, v.(*member).net)
1149 if !ok {
1150 panic("net position sum would overflow")
1151 }
1152 sum = n
1153 return false
1154 })
1155 return sum
1156}
1157
1158func convenorOpen(key string) int64 {
1159 if n, ok := openByConvenor.Get(key).(int64); ok {
1160 return n
1161 }
1162 return 0
1163}
1164
1165func bumpConvenor(key string, delta int64) {
1166 n := convenorOpen(key) + delta
1167 switch {
1168 case n < 0:
1169 panic("netting: convenor open count went negative")
1170 case n == 0:
1171 openByConvenor.Remove(key)
1172 default:
1173 openByConvenor.Set(key, n)
1174 }
1175}
1176
1177// checkedAdd adds two int64s and reports whether the result is exact.
1178//
1179// STATED LIMIT, because it is the kind that survives a copy-paste into a
1180// context where it is wrong: the sign test cannot see the one overflow
1181// where the sum does not move past the addend. checkedAdd(0, MinInt64)
1182// returns (MinInt64, true) — correct, since 0+MinInt64 is exactly
1183// MinInt64 — but so does checkedAdd(a, MinInt64) for any a >= 0, where
1184// the true sum is representable only when a == 0. Nothing here can reach
1185// it: every addend this realm passes is either positive or the negation
1186// of a value at or above MinObligation, so MinInt64 is never an operand
1187// and no accumulator starts at one. The audit raised it as an open
1188// question and the answer is "unreachable, and worth a comment if this
1189// helper is ever reused" — so this is the comment, and a caller that can
1190// supply MinInt64 needs a different helper rather than this one.
1191func checkedAdd(a, b int64) (int64, bool) {
1192 sum := a + b
1193 if (b > 0 && sum < a) || (b < 0 && sum > a) {
1194 return 0, false
1195 }
1196 return sum, true
1197}
1198
1199func itoa(n int64) string { return strconv.FormatInt(n, 10) }
1200
1201// bps renders a basis-point figure as a human percentage, exactly.
1202func bps(n int64) string {
1203 whole := n / 100
1204 frac := n % 100
1205 if frac == 0 {
1206 return itoa(whole) + "%"
1207 }
1208 if frac%10 == 0 {
1209 return itoa(whole) + "." + itoa(frac/10) + "%"
1210 }
1211 pad := ""
1212 if frac < 10 {
1213 pad = "0"
1214 }
1215 return itoa(whole) + "." + pad + itoa(frac) + "%"
1216}
1217
1218const zeros = "00000000000000000000"
1219
1220func padID(id int64) string {
1221 s := strconv.FormatInt(id, 10)
1222 const width = 20
1223 if len(s) >= width {
1224 return s
1225 }
1226 return zeros[:width-len(s)] + s
1227}
1228
1229func shortAddr(a string) string {
1230 if len(a) <= 14 {
1231 return a
1232 }
1233 return a[:8] + "…" + a[len(a)-4:]
1234}
1235
1236// --- render ---
1237
1238// Render serves the index at "" and a set page at "<id>".
1239func Render(path string) string {
1240 if path == "" {
1241 return renderIndex()
1242 }
1243 id, err := strconv.ParseInt(path, 10, 64)
1244 if err != nil {
1245 return "# netting\n\nNot a set id: `" + sanitize.InlineText(path) + "`\n"
1246 }
1247 s, ok := sets.Get(padID(id)).(*set)
1248 if !ok {
1249 return "# netting\n\nNo set `" + itoa(id) + "`.\n"
1250 }
1251 return renderSet(s)
1252}
1253
1254func renderIndex() string {
1255 out := "# netting\n\nMultilateral obligation clearing. Members declare what they owe each other; closing a set nets those obligations and only the net amounts move.\n\n"
1256 out += "| | |\n|---|---|\n"
1257 out += "| sets opened | " + itoa(nextID) + " |\n"
1258 out += "| open now | " + itoa(OpenSets()) + " |\n"
1259 out += "| settle fee | " + bps(SettleFeeBps) + " |\n"
1260 out += "| pending pay-ins | " + itoa(totalPending) + " " + Denom + " |\n"
1261 out += "| unclaimed credit | " + itoa(ledger.Liabilities()-ledger.FeesAccrued()) + " " + Denom + " |\n"
1262 out += "| fee pot | " + itoa(ledger.FeesAccrued()) + " " + Denom + " |\n"
1263 out += "| held | " + itoa(Held()) + " " + Denom + " |\n\n"
1264
1265 gross := lifetimeGrossValue
1266 net := lifetimeNetValue
1267 out += "## Lifetime compression\n\n"
1268 out += "Across every closed set: **" + itoa(gross) + " " + Denom +
1269 "** of declared obligations required **" + itoa(net) + " " + Denom +
1270 "** of transfers.\n\n"
1271 out += "> Netting compresses only where obligations are mutual. A star graph — everyone owing one hub — nets to itself, and these two numbers are equal. Both figures are reported; no saving is claimed and no ratio is computed.\n\n"
1272
1273 out += "## Sets\n\n"
1274 if sets.Size() == 0 {
1275 return out + "_none yet._\n"
1276 }
1277 out += "| id | label | state | members | gross | net |\n|---|---|---|---|---|---|\n"
1278 rows := 0
1279 sets.ReverseIterate("", "", func(_ string, v any) bool {
1280 if rows >= MaxRenderRows {
1281 return true
1282 }
1283 s := v.(*set)
1284 out += "| [" + itoa(s.id) + "](" + RealmPath + ":" + itoa(s.id) + ") | " +
1285 sanitize.InlineText(s.label) + " | " + s.state + " | " +
1286 strconv.Itoa(s.members.Size()) + " | " + itoa(s.grossValue) + " | " +
1287 itoa(s.netValue) + " |\n"
1288 rows++
1289 return false
1290 })
1291 if sets.Size() > rows {
1292 out += "\n_showing " + strconv.Itoa(rows) + " of " +
1293 strconv.Itoa(sets.Size()) + " stored sets._\n"
1294 }
1295 return out
1296}
1297
1298func renderSet(s *set) string {
1299 out := "# netting set " + itoa(s.id) + "\n\n## " +
1300 sanitize.InlineText(s.label) + "\n\n"
1301 if s.memo != "" {
1302 out += sanitize.InlineText(s.memo) + "\n\n"
1303 }
1304 out += "| | |\n|---|---|\n"
1305 out += "| state | **" + s.state + "** |\n"
1306 out += "| convenor | `" + shortAddr(s.convenor.String()) + "` |\n"
1307 out += "| fee | " + bps(s.feeBps) + " |\n"
1308 out += "| declare until | height " + itoa(s.deadline) + " |\n"
1309 out += "| close until | height " + itoa(s.closeEnd) + " |\n"
1310 if s.settleEnd != 0 {
1311 out += "| settle until | height " + itoa(s.settleEnd) + " |\n"
1312 }
1313 out += "| now | height " + itoa(runtime.ChainHeight()) + " |\n"
1314 if s.state == stateSettling {
1315 out += "| paid in | " + itoa(s.paidIn) + " of " + itoa(s.netValue) +
1316 " " + Denom + " |\n"
1317 }
1318 out += "\n## Compression\n\n"
1319 out += "| | count | value |\n|---|---|---|\n"
1320 out += "| gross obligations | " + itoa(s.grossCount) + " | " +
1321 itoa(s.grossValue) + " " + Denom + " |\n"
1322 out += "| net transfers | " + itoa(s.netTransferCount) + " | " +
1323 itoa(s.netValue) + " " + Denom + " |\n\n"
1324 if s.state == stateOpen {
1325 out += "_Net figures are frozen at Close and read zero until then._\n\n"
1326 } else if s.netValue == 0 && s.grossValue > 0 {
1327 out += "**Fully netted: " + itoa(s.grossValue) + " " + Denom +
1328 " of obligations cleared with no transfer at all.**\n\n"
1329 }
1330
1331 out += "## Members\n\n"
1332 if s.members.Size() == 0 {
1333 out += "_none admitted._\n\n"
1334 } else {
1335 out += "| member | owes (gross) | owed (gross) | net | pay in | receive | paid |\n"
1336 out += "|---|---|---|---|---|---|---|\n"
1337 rows := 0
1338 s.members.Iterate("", "", func(k string, v any) bool {
1339 if rows >= MaxRenderRows {
1340 return true
1341 }
1342 m := v.(*member)
1343 out += "| `" + shortAddr(k) + "` | " + itoa(m.grossOut) + " | " +
1344 itoa(m.grossIn) + " | " + itoa(m.net) + " | " +
1345 itoa(m.debit) + " | " + itoa(m.credit) + " | " +
1346 itoa(m.paid) + " |\n"
1347 rows++
1348 return false
1349 })
1350 if s.members.Size() > rows {
1351 out += "\n_showing " + strconv.Itoa(rows) + " of " +
1352 strconv.Itoa(s.members.Size()) + " members._\n"
1353 }
1354 out += "\n"
1355 }
1356
1357 out += "## Obligations\n\n"
1358 if s.pairs.Size() == 0 {
1359 return out + "_none declared._\n"
1360 }
1361 out += "| debtor | creditor | amount |\n|---|---|---|\n"
1362 rows := 0
1363 s.pairs.Iterate("", "", func(k string, v any) bool {
1364 if rows >= MaxRenderRows {
1365 return true
1366 }
1367 debtor, creditor := splitPairKey(k)
1368 out += "| `" + shortAddr(debtor) + "` | `" + shortAddr(creditor) +
1369 "` | " + itoa(v.(int64)) + " |\n"
1370 rows++
1371 return false
1372 })
1373 if s.pairs.Size() > rows {
1374 out += "\n_showing " + strconv.Itoa(rows) + " of " +
1375 strconv.Itoa(s.pairs.Size()) + " obligation pairs._\n"
1376 }
1377 return out
1378}
1379
1380// splitPairKey reverses pairKey for render. The separator cannot appear
1381// inside an address, so the first occurrence is the only one.
1382func splitPairKey(pk string) (debtor, creditor string) {
1383 for i := 0; i < len(pk); i++ {
1384 if pk[i] == '\n' {
1385 return pk[:i], pk[i+1:]
1386 }
1387 }
1388 return pk, ""
1389}