Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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}