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

vesting.gno

44.65 Kb · 1226 lines
   1// Realm vesting escrows native GNOT for a named beneficiary and releases
   2// it in DISCRETE, INDIVIDUALLY DATED TRANCHES — each payable exactly once,
   3// at or after its own due height.
   4//
   5// # DISCRETE, NOT LINEAR — read this first
   6//
   7// Everywhere else in this ecosystem "vesting" means CONTINUOUS LINEAR
   8// ACCRUAL: a formula of the form amount * elapsed / duration, evaluated
   9// whenever someone asks. This realm does no such arithmetic. A funder
  10// states an explicit list of (delay, amount) pairs at funding time, and
  11// nothing accrues between them: a tranche is worth zero until its due
  12// height and its full amount from that height on.
  13//
  14// That is a deliberate choice, not a simplification:
  15//
  16//   - It is the shape real schedules actually have — cliffs, quarterly
  17//     tranches, milestone payments, a lump sum on a date.
  18//   - It has no `amount * elapsed` term, so the int64 overflow class that
  19//     has already shipped a NEGATIVE vested amount in two independent
  20//     ecosystem packages cannot occur here: that MULTIPLICATIVE class is
  21//     designed out rather than guarded against. The additive sums that do
  22//     exist are guarded, not designed out — Fund checks the tranche total
  23//     and the escrow total with checkedAdd, each with its own panic,
  24//     before any state changes. Both halves are true and neither replaces
  25//     the other.
  26//   - It maps exactly onto duebook, whose exactly-once Claim is what makes
  27//     a double release structurally impossible rather than merely checked.
  28//
  29// There is no default curve to be surprised by: every tranche is stated
  30// explicitly at creation, or the schedule does not exist.
  31//
  32// # COMPOSITION
  33//
  34// This realm owns only the schedule state machine. Everything else is
  35// delegated to primitives already audited and deployed:
  36//
  37//	duebook   — the entire schedule: due heights, and exactly-once release
  38//	coinio    — coin custody: escrow-in, payout, surplus sweep
  39//	feeledger — claimable balances and the fee pot
  40//	sanitize  — funder-supplied free text, before it reaches markdown
  41//
  42// It is duebook's FIRST PRODUCTIVE CONSUMER. duebook_demo is the
  43// falsification experiment and holds no coins by design; this realm is the
  44// first to put value behind the exactly-once claim.
  45//
  46// # LIFECYCLE
  47//
  48//	Fund (EOA + -send)      : one transaction creates the schedule, escrows
  49//	                          the exact total, and registers every tranche
  50//	                          in duebook. There is no draft state and no
  51//	                          half-funded schedule.
  52//	Release (ANYONE)        : once a tranche is due, anyone may release it.
  53//	                          The value is credited to the RECORDED
  54//	                          BENEFICIARY, never to the caller.
  55//	Revoke (funder only)    : returns a NOT-YET-DUE tranche to the funder,
  56//	                          and only if the schedule was created
  57//	                          revocable. A due tranche can never be revoked.
  58//	Withdraw / WithdrawAll  : pays out the caller's own claimable balance.
  59//
  60// # WHY NOTHING CAN BE TRAPPED, AND WHY THERE IS NO EXPIRY
  61//
  62// Tranches are scheduled with duebook's ttl == 0, so they NEVER expire.
  63// Vested value cannot be lost by a beneficiary who is slow, offline, or
  64// gone. The sibling realms in this portfolio use a permissionless expiry
  65// valve to guarantee escrow is never trapped; here the valve is different
  66// and stronger: Release is PERMISSIONLESS, so every tranche becomes
  67// claimable-by-anyone the moment it is due, and the schedule drains itself
  68// without depending on any particular actor staying alive. Combined with
  69// "a due tranche can never be revoked", every ugnot in escrow has exactly
  70// one reachable destination and a height after which nobody can block it.
  71//
  72// # VESTED MEANS IRREVOCABLE, WITH NO RACE
  73//
  74// Revoke refuses any tranche whose due height has arrived, even though
  75// duebook's Cancel would happily consume it. THAT CHECK IS THIS REALM'S,
  76// not duebook's. Without it a funder could watch for the due height and
  77// front-run the beneficiary's Release to claw back value already earned.
  78// Likewise, `revocable` is fixed at funding and has no setter: an
  79// irrevocable schedule cannot be made revocable later, which is the only
  80// thing that makes the promise worth anything.
  81//
  82// # AUTHORIZATION
  83//
  84// Every identity is derived from the crossing entrypoint's
  85// cur.Previous().Address(). No function takes a caller identity as a
  86// parameter, so acting as another account is impossible by construction.
  87// Release is permissionless *because* it pays the recorded beneficiary:
  88// the caller's identity is not used to route value, so there is nothing
  89// for a stranger to gain.
  90//
  91// # HOW IT WIRES DUEBOOK CORRECTLY (duebook's five-point consumer contract)
  92//
  93//  1. THE CLOCK IS runtime.ChainHeight(), never a call argument. A realm
  94//     that lets a caller choose `now` has no delay at all.
  95//  2. HEIGHTS ARE THE ONLY UNIT. No timestamp appears anywhere.
  96//  3. THE *Book IS NEVER EXPORTED. It is an unexported package var and is
  97//     never returned across a realm boundary; handing it out would hand out
  98//     the right to schedule, cancel and claim.
  99//  4. THE ACTOR IS AUTHORIZED from cur.Previous().Address(); the duebook
 100//     owner recorded at Schedule is the funder, and duebook checks Cancel
 101//     against that recorded value, never against an argument.
 102//  5. THE EFFECT HAPPENS AFTER A SUCCESSFUL Claim, IN THE SAME
 103//     TRANSACTION.
 104//
 105// The deferral PAYLOAD is a human-readable label for events and rendering.
 106// THIS REALM NEVER PARSES IT. Every authoritative fact about a tranche —
 107// its amount, its schedule, its beneficiary — comes from this realm's own
 108// `tranches` tree, keyed by the deferral ID that duebook guarantees is
 109// never reused.
 110//
 111// # MONETARY INVARIANT (conservation)
 112//
 113// Let H be ugnot held at this realm's address, E = escrowTotal (the sum of
 114// amounts over tranches still pending), U the ledger's claimable balances,
 115// F the fee pot, and S >= 0 out-of-band surplus:
 116//
 117//	H == E + U + F + S
 118//
 119// Fund raises H and E together (coinio.Receive is the receipt-guaranteed
 120// shape). Release moves one tranche amount from E into U+F, with feeledger
 121// guaranteeing credited + fee == amount. Revoke moves a tranche amount from
 122// E into U, fee-free. Withdrawals debit the ledger BEFORE coinio.Payout
 123// moves the identical amount out (checks-effects-interactions). Any panic
 124// aborts the whole transaction. This realm never issues or removes coins.
 125// Surplus is recoverable only via SweepDenom, which reserves Liabilities().
 126//
 127// # ONLY GNOT
 128//
 129// Fund rejects any envelope that is not exactly one positive ugnot coin.
 130// Foreign denominations sit in surplus and are sweepable.
 131package vesting
 132
 133import (
 134	"chain"
 135	"chain/runtime"
 136	"chain/runtime/unsafe"
 137	"strconv"
 138	"strings"
 139
 140	"gno.land/p/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/coinio"
 141	"gno.land/p/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/duebook"
 142	"gno.land/p/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/feeledger"
 143	"gno.land/p/nt/avl/v0"
 144	"gno.land/p/nt/markdown/sanitize/v0"
 145)
 146
 147// Denom is the only asset this realm escrows.
 148const Denom = "ugnot"
 149
 150// MaxFeeBps is the hard protocol-fee cap: 1000 bps = 10%.
 151const MaxFeeBps = int64(1000)
 152
 153// Tranche status values. A tranche is created pending and reaches exactly
 154// one terminal state; both terminals are frozen.
 155const (
 156	StatusPending  = "pending"
 157	StatusReleased = "released"
 158	StatusRevoked  = "revoked"
 159)
 160
 161// Bounds. All are compile-time constants: none is admin-mutable, so a
 162// schedule's shape cannot be changed under its funder or beneficiary.
 163const (
 164	// MinDelayBlocks is the floor on how far ahead the FIRST tranche may
 165	// fall. One block is enough to prove the delay is enforced while
 166	// keeping the realm exercisable on a live chain.
 167	MinDelayBlocks = int64(1)
 168
 169	// MaxDelayBlocks caps the furthest tranche at roughly 10.8 years at
 170	// pearl-1's ~3.42s blocks. It also bounds now+delay far inside int64.
 171	MaxDelayBlocks = int64(100_000_000)
 172
 173	// MaxTranches bounds one schedule. It bounds the Fund transaction's
 174	// work, the RevokeAll loop, and the schedule detail render.
 175	MaxTranches = 64
 176
 177	// MaxOpenTranches caps pending tranches across EVERY schedule at once
 178	// — it is a whole-realm limit, not a per-schedule one, because it is
 179	// duebook's open-deferral cap. Fund fails when a new schedule would
 180	// cross it. Must be <= duebook.MaxOpenLimit.
 181	MaxOpenTranches = 5000
 182
 183	// MaxOpenPerFunder bounds how many tranches ONE funder may hold
 184	// pending at once, so that MaxOpenTranches cannot be consumed by a
 185	// single account.
 186	//
 187	// Without this bound the whole-realm cap is a denial-of-service
 188	// surface that costs nothing to use: 79 Fund calls of 64 one-ugnot
 189	// tranches fill the realm, Fund then aborts for everybody, and the
 190	// attacker recovers 100% of the escrow fee-free with RevokeAll. Two
 191	// deliberate choices elsewhere remove every recovery path — ttl is
 192	// always 0, so duebook's permissionless Expire valve never opens, and
 193	// MaxOpenTranches is a compile-time constant with no admin setter. The
 194	// denial would therefore be total and permanent, which is out of all
 195	// proportion to its cost.
 196	//
 197	// 256 leaves one funder four full MaxTranches schedules open at once —
 198	// generous for the intended use — while exhausting the realm now takes
 199	// at least ceil(5000/256) = 20 separately funded accounts.
 200	//
 201	// That is a SYBIL BOUND and nothing more, so the residual is quantified
 202	// here rather than left to be inferred from the 20: the cap counts
 203	// TRANCHES, never value. parseSpec accepts amount == 1, so 256 pending
 204	// tranches cost 256ugnot and all 20 accounts together escrow about
 205	// 5000ugnot — every bit of it recoverable fee-free, since revokeOne
 206	// refunds at 0 bps. The real cost of the realm-wide denial is therefore
 207	// ~80 Fund transactions' gas and 20 funded addresses, NOT capital.
 208	// Anyone sizing the residual risk from the "20 accounts" figure alone
 209	// will size it wrong by orders of magnitude.
 210	//
 211	// Bounding the attack by VALUE instead would take a per-tranche minimum
 212	// amount. That is a change to what the application is — it would price
 213	// small legitimate tranches out — so it is a reviewer's decision, not a
 214	// routine fix, and it is deliberately not made here.
 215	MaxOpenPerFunder = 256
 216
 217	// MaxTitleLen bounds a schedule title. Titles are charset-restricted
 218	// so they are list-safe in Render without escaping.
 219	MaxTitleLen = 80
 220
 221	// MaxMemoLen bounds the free-text memo. It is a BYTE length, not a
 222	// rune count, because its job is to bound storage.
 223	MaxMemoLen = 1000
 224
 225	// MaxSpecLen bounds the tranche specification string, so parsing cost
 226	// is bounded before any parsing begins.
 227	MaxSpecLen = 1400
 228
 229	// MaxListed caps how many entries a single view or render returns.
 230	MaxListed = 50
 231)
 232
 233// tranche is one dated payment. It is this realm's authoritative record;
 234// the matching duebook deferral carries only the due height and a label.
 235// Records are kept after they terminate, so history remains answerable
 236// once duebook has consumed the deferral.
 237type tranche struct {
 238	deferralID uint64
 239	scheduleID int64
 240	seq        int64 // 1-based position within its schedule
 241	amount     int64
 242	dueAt      int64
 243	status     string
 244	settledAt  int64 // height of release or revocation; 0 while pending
 245}
 246
 247// schedule is one funded vesting agreement.
 248type schedule struct {
 249	id          int64
 250	funder      address
 251	beneficiary address
 252	title       string
 253	memo        string
 254	total       int64 // Σ tranche amounts, fixed at funding
 255	feeBps      int64 // snapshotted at funding, charged at each release
 256	revocable   bool  // fixed at funding; there is no setter
 257	createdAt   int64
 258	trancheIDs  []uint64 // in tranche order
 259
 260	pending       int64 // count of tranches still pending
 261	releasedGross int64 // Σ amounts released (before fee)
 262	revokedTotal  int64 // Σ amounts returned to the funder
 263}
 264
 265var (
 266	admin        address // may set fee, fee recipient, and propose a successor
 267	pendingAdmin address // must accept before the handover takes effect
 268	feeRecipient address // may withdraw fees and sweep surplus
 269	feeBps       int64   // fee snapshotted into NEW schedules
 270
 271	self           address // this realm's address, captured at deploy
 272	nextScheduleID int64
 273	escrowTotal    int64 // == Σ amount over pending tranches (the E term)
 274
 275	schedules = avl.NewTree() // padID(scheduleID)  -> *schedule
 276	tranches  = avl.NewTree() // padU(deferralID)   -> *tranche
 277
 278	// openByFunder counts each funder's still-pending tranches, for the
 279	// MaxOpenPerFunder cap. An entry is REMOVED at zero rather than left
 280	// holding 0, so the tree tracks live funders and does not accumulate a
 281	// dead key per address that ever funded anything.
 282	openByFunder = avl.NewTree() // funder.String() -> int64
 283
 284	ledger = feeledger.MustNew(MaxFeeBps)
 285
 286	// book is unexported and never returned (duebook consumer contract 3).
 287	book = duebook.MustNew(MinDelayBlocks, MaxDelayBlocks, MaxOpenTranches)
 288)
 289
 290func init() {
 291	admin = unsafe.OriginCaller()
 292	feeRecipient = admin
 293	self = unsafe.CurrentRealm().Address()
 294}
 295
 296// rejectStraySend aborts if an EOA attached coins to an entrypoint that has
 297// no use for them. Only Fund accepts value; without this guard a mistyped
 298// send would be silently absorbed into surplus.
 299func rejectStraySend(cur realm) {
 300	if !cur.IsCurrent() {
 301		// every call site passes the entrypoint's own first cur; this
 302		// pins that discipline against future call-site drift, which is
 303		// the house pattern from fee_split/v2. Unexported and with all
 304		// 11 call sites forwarding a live cur, this is unreachable
 305		// today — it is here because the artifact is immutable and the
 306		// guard's answer decides whether coins are rejected or absorbed
 307		panic("realm capability is not current")
 308	}
 309	if cur.Previous().IsUserCall() && len(unsafe.OriginSend()) > 0 {
 310		panic("this entrypoint does not accept coins; only Fund does")
 311	}
 312}
 313
 314// Fund creates a vesting schedule in a single transaction: it escrows the
 315// attached GNOT, registers every tranche with duebook, and returns the new
 316// schedule id. Only direct EOA calls with -send are accepted.
 317//
 318// spec lists the tranches as "delay:amount" pairs separated by commas, for
 319// example "100:500,200:500,300:1000". Delays are in BLOCKS FROM NOW and
 320// must be STRICTLY INCREASING — two payments at the same height are one
 321// tranche with the amounts added. Every amount must be positive, and the
 322// amounts must sum to EXACTLY the attached send: this realm will not
 323// silently absorb a surplus or create an underfunded schedule.
 324//
 325// revocable decides, permanently, whether the funder may reclaim
 326// not-yet-due tranches. There is no setter; an irrevocable schedule stays
 327// irrevocable.
 328//
 329// maxFeeBps is the ceiling the caller signs for. Funding is rejected if the
 330// live fee exceeds it, which closes the race where an admin sequences a fee
 331// raise ahead of this transaction. Pass MaxFeeBps to accept any legal fee.
 332func Fund(cur realm, beneficiary address, title, memo, spec string, revocable bool, maxFeeBps int64) int64 {
 333	// Receive first and exactly once per transaction (coinio's contract):
 334	// it READS the send envelope and cannot mark it spent, so crediting
 335	// per call would mint unbacked liabilities from a single -send.
 336	funder, amount := coinio.Receive(0, cur, Denom)
 337
 338	if feeBps > maxFeeBps {
 339		panic("current fee " + itoa(feeBps) + " bps exceeds the caller's maximum " + itoa(maxFeeBps))
 340	}
 341	var zero address
 342	if beneficiary == zero {
 343		panic("empty beneficiary address")
 344	}
 345	// This realm can never call its own Withdraw, so a balance credited to
 346	// its own address is claimable by nobody while still counting inside
 347	// Liabilities() — permanently stranded AND permanently shrinking
 348	// SweepDenom's reach. Unlike an arbitrary typo, self is a uniquely
 349	// knowable address this realm publishes itself via Address(), so it is
 350	// the one wrong beneficiary worth spending a comparison on
 351	if beneficiary == self {
 352		panic("beneficiary must not be this realm's own address")
 353	}
 354	assertValidTitle(title)
 355	if len(memo) > MaxMemoLen {
 356		panic("memo exceeds " + itoa(MaxMemoLen) + " bytes")
 357	}
 358
 359	delays, amounts := parseSpec(spec)
 360	n := int64(len(delays))
 361
 362	// The declared total must match the escrow exactly, in both directions.
 363	declared := int64(0)
 364	for _, a := range amounts {
 365		sum, ok := checkedAdd(declared, a)
 366		if !ok {
 367			panic("tranche amounts overflow int64")
 368		}
 369		declared = sum
 370	}
 371	if declared != amount {
 372		panic("tranche amounts total " + itoa(declared) + Denom +
 373			" but " + itoa(amount) + Denom + " was sent; they must be equal")
 374	}
 375
 376	// Whole-realm capacity check, ahead of any state change, for a clear
 377	// message. duebook would refuse with ErrBookFull anyway, and a panic
 378	// there would abort this transaction wholly — the pre-check only
 379	// improves the error.
 380	if int64(book.OpenCount())+n > int64(MaxOpenTranches) {
 381		panic("realm tranche capacity reached: " + itoa(int64(book.OpenCount())) +
 382			" pending of " + itoa(int64(MaxOpenTranches)) + ", this schedule needs " + itoa(n))
 383	}
 384
 385	// Per-funder capacity, so one account cannot consume the whole-realm
 386	// cap above and lock everybody out. See MaxOpenPerFunder.
 387	if mine := openOf(funder); mine+n > int64(MaxOpenPerFunder) {
 388		panic("funder tranche capacity reached: " + itoa(mine) + " pending of " +
 389			itoa(int64(MaxOpenPerFunder)) + ", this schedule needs " + itoa(n))
 390	}
 391
 392	newEscrow, ok := checkedAdd(escrowTotal, amount)
 393	if !ok {
 394		panic("escrow overflow")
 395	}
 396
 397	now := runtime.ChainHeight() // the chain's clock (contract 1), heights only (contract 2)
 398
 399	nextScheduleID++
 400	s := &schedule{
 401		id:          nextScheduleID,
 402		funder:      funder,
 403		beneficiary: beneficiary,
 404		title:       title,
 405		memo:        memo,
 406		total:       amount,
 407		feeBps:      feeBps,
 408		revocable:   revocable,
 409		createdAt:   now,
 410		pending:     n,
 411	}
 412
 413	for i := 0; i < len(delays); i++ {
 414		seq := int64(i + 1)
 415		// The payload is a label for humans and events. It is NEVER parsed
 416		// (see the package doc): the authoritative record is the tranche
 417		// struct stored under the deferral ID below.
 418		label := "schedule " + itoa(s.id) + " tranche " + itoa(seq) + "/" + itoa(n)
 419
 420		// ttl 0 means the tranche never expires: vested value cannot be
 421		// lost by a slow or absent beneficiary.
 422		did, err := book.Schedule(funder.String(), label, now, delays[i], 0)
 423		if err != nil {
 424			panic(err)
 425		}
 426		d, found := book.Get(did)
 427		if !found {
 428			panic("internal: scheduled deferral not found")
 429		}
 430		tranches.Set(padU(did), &tranche{
 431			deferralID: did,
 432			scheduleID: s.id,
 433			seq:        seq,
 434			amount:     amounts[i],
 435			dueAt:      d.DueAt,
 436			status:     StatusPending,
 437		})
 438		s.trancheIDs = append(s.trancheIDs, did)
 439
 440		chain.Emit("TrancheScheduled",
 441			"schedule", itoa(s.id),
 442			"tranche", utoa(did),
 443			"seq", itoa(seq),
 444			"amount", itoa(amounts[i]),
 445			"dueAt", itoa(d.DueAt),
 446		)
 447	}
 448
 449	schedules.Set(padID(s.id), s)
 450	escrowTotal = newEscrow
 451	addOpen(funder, n) // commits with escrowTotal: both count the same tranches
 452
 453	chain.Emit("ScheduleFunded",
 454		"schedule", itoa(s.id),
 455		"funder", funder.String(),
 456		"beneficiary", beneficiary.String(),
 457		"total", itoa(amount),
 458		"tranches", itoa(n),
 459		"feeBps", itoa(s.feeBps),
 460		"revocable", btoa(revocable),
 461	)
 462	return s.id
 463}
 464
 465// Release pays out a due tranche. ANYONE may call it: the value is credited
 466// to the schedule's RECORDED BENEFICIARY, never to the caller, so a
 467// stranger calling this gains nothing and the beneficiary needs no liveness
 468// of their own. The protocol fee snapshotted at funding is charged here.
 469//
 470// Aborts if the tranche does not exist, is not due yet, or has already been
 471// released or revoked — the last of those is duebook's guarantee, not a
 472// check written here.
 473func Release(cur realm, trancheID int64) string {
 474	rejectStraySend(cur)
 475
 476	did := mustID(trancheID)
 477	now := runtime.ChainHeight()
 478
 479	// CONSUME FIRST (duebook consumer contract 5). Claim removes the
 480	// deferral before returning, so by the time the effect below runs no
 481	// second Claim of this ID can ever succeed — not in this transaction,
 482	// and not in any future one. This realm writes no replay guard of its
 483	// own because duebook's is structural.
 484	d, err := book.Claim(did, now)
 485	if err != nil {
 486		panic(err)
 487	}
 488
 489	// EFFECT SECOND, same transaction.
 490	t := mustTranche(d.ID)
 491	if t.status != StatusPending {
 492		// Unreachable: a successful Claim proves the deferral was open, and
 493		// this realm terminates the tranche record in the same transaction
 494		// it consumes the deferral. Kept as a consistency assertion — if it
 495		// ever fires, the two stores have diverged and the transaction must
 496		// not proceed.
 497		panic("internal: claimed a deferral whose tranche is " + t.status)
 498	}
 499	s := mustSchedule(t.scheduleID)
 500
 501	// feeledger validates before mutating, and guarantees
 502	// credited + fee == amount.
 503	credited, fee, err := ledger.Deposit(s.beneficiary.String(), t.amount, s.feeBps)
 504	if err != nil {
 505		panic(err)
 506	}
 507	escrowTotal -= t.amount // >= 0: escrowTotal == Σ pending amounts >= t.amount
 508	t.status = StatusReleased
 509	t.settledAt = now
 510	s.pending--
 511	s.releasedGross += t.amount
 512	addOpen(s.funder, -1) // this tranche no longer counts against the funder
 513
 514	chain.Emit("TrancheReleased",
 515		"schedule", itoa(s.id),
 516		"tranche", utoa(d.ID),
 517		"seq", itoa(t.seq),
 518		"beneficiary", s.beneficiary.String(),
 519		"credited", itoa(credited),
 520		"fee", itoa(fee),
 521		"caller", cur.Previous().Address().String(),
 522		"height", itoa(now),
 523	)
 524	return "released tranche " + utoa(d.ID) + " (" + itoa(credited) + Denom + " to " +
 525		s.beneficiary.String() + ")"
 526}
 527
 528// Revoke returns a NOT-YET-DUE tranche to the funder's claimable balance,
 529// fee-free. Only the funder may call it, and only on a schedule created
 530// revocable.
 531//
 532// A tranche whose due height has arrived can never be revoked, even though
 533// duebook's Cancel would consume it: that refusal is this realm's, and it
 534// is what stops a funder front-running the beneficiary's Release to claw
 535// back value already vested.
 536func Revoke(cur realm, trancheID int64) string {
 537	rejectStraySend(cur)
 538
 539	caller := cur.Previous().Address()
 540	did := mustID(trancheID)
 541	t := mustTranche(did)
 542	s := mustSchedule(t.scheduleID)
 543
 544	assertRevocableBy(s, caller)
 545	now := runtime.ChainHeight()
 546	if now >= t.dueAt {
 547		panic("tranche " + utoa(did) + " is due at height " + itoa(t.dueAt) +
 548			" (now " + itoa(now) + "): a vested tranche can never be revoked")
 549	}
 550
 551	revokeOne(cur, s, t, now)
 552	return "revoked tranche " + utoa(did) + " (" + itoa(t.amount) + Denom + " to the funder)"
 553}
 554
 555// RevokeAll returns every not-yet-due tranche of a schedule to the funder's
 556// claimable balance in one transaction, and returns how many were revoked.
 557// Already-due and already-settled tranches are skipped, not an error.
 558// Funder only, revocable schedules only. Aborts if nothing was revocable,
 559// so a no-op cannot be mistaken for success.
 560func RevokeAll(cur realm, scheduleID int64) string {
 561	rejectStraySend(cur)
 562
 563	caller := cur.Previous().Address()
 564	s := mustSchedule(scheduleID)
 565	assertRevocableBy(s, caller)
 566	now := runtime.ChainHeight()
 567
 568	// Bounded by MaxTranches, so the loop cost is bounded at funding time.
 569	count := int64(0)
 570	total := int64(0)
 571	for _, did := range s.trancheIDs {
 572		v := tranches.Get(padU(did))
 573		if v == nil {
 574			panic("internal: schedule references an unknown tranche")
 575		}
 576		t := v.(*tranche)
 577		if t.status != StatusPending || now >= t.dueAt {
 578			continue
 579		}
 580		amount := t.amount
 581		revokeOne(cur, s, t, now)
 582		count++
 583		total += amount // bounded by s.total, which was overflow-checked at funding
 584	}
 585	if count == 0 {
 586		panic("no revocable tranches: every tranche of this schedule is already due or settled")
 587	}
 588
 589	chain.Emit("ScheduleRevoked",
 590		"schedule", itoa(s.id),
 591		"tranches", itoa(count),
 592		"amount", itoa(total),
 593		"height", itoa(now),
 594	)
 595	return "revoked " + itoa(count) + " tranches (" + itoa(total) + Denom + " to the funder)"
 596}
 597
 598// revokeOne cancels one pending, not-yet-due tranche and credits the funder
 599// fee-free. Callers have already verified authorization, revocability and
 600// the due-height refusal.
 601func revokeOne(cur realm, s *schedule, t *tranche, now int64) {
 602	// CONSUME FIRST. Ownership is checked by duebook against the funder
 603	// address this realm recorded at Schedule, never against an argument
 604	// (duebook consumer contract 4).
 605	d, err := book.Cancel(t.deferralID, s.funder.String())
 606	if err != nil {
 607		panic(err)
 608	}
 609	_ = d
 610
 611	// EFFECT SECOND, same transaction. Refunds are always fee-free.
 612	if _, _, err := ledger.Deposit(s.funder.String(), t.amount, 0); err != nil {
 613		panic(err)
 614	}
 615	escrowTotal -= t.amount
 616	t.status = StatusRevoked
 617	t.settledAt = now
 618	s.pending--
 619	s.revokedTotal += t.amount
 620	addOpen(s.funder, -1) // revoking frees the funder's slot too
 621
 622	chain.Emit("TrancheRevoked",
 623		"schedule", itoa(s.id),
 624		"tranche", utoa(t.deferralID),
 625		"seq", itoa(t.seq),
 626		"funder", s.funder.String(),
 627		"amount", itoa(t.amount),
 628		"height", itoa(now),
 629	)
 630}
 631
 632// Withdraw sends amount ugnot of the caller's claimable balance (released
 633// tranches for a beneficiary, revocations for a funder) to the caller.
 634func Withdraw(cur realm, amount int64) string {
 635	rejectStraySend(cur)
 636	caller := cur.Previous().Address()
 637
 638	// Debit the ledger BEFORE the coins move (coinio's ordering contract).
 639	if err := ledger.Withdraw(caller.String(), amount); err != nil {
 640		panic(err)
 641	}
 642	coinio.Payout(0, cur, caller, Denom, amount)
 643
 644	chain.Emit("Withdrawn", "to", caller.String(), "amount", itoa(amount))
 645	return "withdrew " + itoa(amount) + Denom
 646}
 647
 648// WithdrawAll sends the caller's entire claimable balance to the caller.
 649// Aborts if there is nothing to withdraw.
 650func WithdrawAll(cur realm) string {
 651	rejectStraySend(cur)
 652	caller := cur.Previous().Address()
 653
 654	amount, err := ledger.WithdrawAll(caller.String())
 655	if err != nil {
 656		panic(err)
 657	}
 658	if amount == 0 {
 659		panic("nothing to withdraw")
 660	}
 661	coinio.Payout(0, cur, caller, Denom, amount)
 662
 663	chain.Emit("Withdrawn", "to", caller.String(), "amount", itoa(amount))
 664	return "withdrew " + itoa(amount) + Denom
 665}
 666
 667// WithdrawFees sends the accrued fee pot to the fee recipient. Only the fee
 668// recipient may call it.
 669func WithdrawFees(cur realm) string {
 670	rejectStraySend(cur)
 671	caller := cur.Previous().Address()
 672	if caller != feeRecipient {
 673		panic("only the fee recipient may withdraw fees")
 674	}
 675	if ledger.FeesAccrued() == 0 {
 676		panic("no fees accrued")
 677	}
 678	amount := ledger.WithdrawFees()
 679	coinio.Payout(0, cur, feeRecipient, Denom, amount)
 680
 681	chain.Emit("FeesWithdrawn", "to", feeRecipient.String(), "amount", itoa(amount))
 682	return "withdrew " + itoa(amount) + Denom + " in fees"
 683}
 684
 685// SweepDenom sends the surplus of a single denomination to the fee
 686// recipient. For ugnot only the excess over Liabilities() moves — escrowed
 687// tranches and claimable balances are untouchable by construction. Other
 688// denominations move wholly. Only the fee recipient may call it.
 689func SweepDenom(cur realm, denom string) string {
 690	rejectStraySend(cur)
 691	caller := cur.Previous().Address()
 692	if caller != feeRecipient {
 693		panic("only the fee recipient may sweep surplus")
 694	}
 695	reserve := int64(0)
 696	if denom == Denom {
 697		reserve = Liabilities()
 698	}
 699	swept := coinio.Sweep(0, cur, feeRecipient, denom, reserve)
 700
 701	chain.Emit("SurplusSwept", "to", feeRecipient.String(), "coins", itoa(swept)+denom)
 702	return "swept " + itoa(swept) + denom
 703}
 704
 705// --- administration ---
 706
 707// SetFeeBps sets the protocol fee snapshotted into FUTURE schedules at
 708// funding. Existing schedules keep the fee they were funded under, so an
 709// in-flight schedule's economics cannot be changed. Admin only.
 710func SetFeeBps(cur realm, bps int64) {
 711	rejectStraySend(cur)
 712	assertAdmin(cur.Previous().Address())
 713	if bps < 0 || bps > MaxFeeBps {
 714		panic("fee bps out of range [0, " + itoa(MaxFeeBps) + "]")
 715	}
 716	old := feeBps
 717	feeBps = bps
 718	chain.Emit("FeeBpsChanged", "old", itoa(old), "new", itoa(bps))
 719}
 720
 721// SetFeeRecipient re-points the fee/surplus role. The pot accrued under the
 722// OUTGOING recipient is settled to that recipient's own claimable balance
 723// first, so the successor inherits the role and not the earnings. Admin
 724// only; the zero address is rejected.
 725func SetFeeRecipient(cur realm, next address) {
 726	rejectStraySend(cur)
 727	assertAdmin(cur.Previous().Address())
 728	var zero address
 729	if next == zero {
 730		panic("empty fee recipient")
 731	}
 732	old := feeRecipient
 733
 734	// The fee pot is a single scalar — feeledger keeps no per-recipient
 735	// accounting — so a bare re-point would hand every basis point earned
 736	// under `old` to `next`, at the admin's sole discretion and with no
 737	// timelock. That is a privilege the rest of this realm does not grant:
 738	// the admin role itself is two-step, the fee is capped and snapshotted
 739	// per schedule, and the funder signs a maxFeeBps ceiling.
 740	//
 741	// Settling to `old`'s claimable balance moves F into U by the same
 742	// amount, so no coins move here and conservation is untouched; `old`
 743	// then withdraws on their own initiative like any other creditor.
 744	//
 745	// Refusing the call while fees are accrued would be the other available
 746	// fix and is worse: WithdrawFees is recipient-only, so a recipient who
 747	// simply declined to withdraw could hold the role hostage against the
 748	// admin indefinitely.
 749	if old != next && ledger.FeesAccrued() > 0 {
 750		pot := ledger.WithdrawFees()
 751		credited, fee, err := ledger.Deposit(old.String(), pot, 0)
 752		if err != nil {
 753			panic(err)
 754		}
 755		if credited != pot || fee != 0 {
 756			// Unreachable at 0 bps, where feeledger's credited+fee == amount
 757			// guarantee forces credited == amount. Asserted because this is
 758			// the one place the realm moves value between two of its own
 759			// liability terms, and a silent shortfall here would be a leak.
 760			panic("internal: fee settlement did not credit the full pot")
 761		}
 762		chain.Emit("FeesSettled", "to", old.String(), "amount", itoa(pot))
 763	}
 764
 765	feeRecipient = next
 766	chain.Emit("FeeRecipientChanged", "old", old.String(), "new", next.String())
 767}
 768
 769// ProposeAdmin nominates a successor admin. The handover takes effect only
 770// when that account calls AcceptAdmin, so a typo cannot strand the role.
 771// Admin only; the zero address clears a pending proposal.
 772func ProposeAdmin(cur realm, next address) {
 773	rejectStraySend(cur)
 774	assertAdmin(cur.Previous().Address())
 775	pendingAdmin = next
 776	chain.Emit("AdminProposed", "pending", next.String())
 777}
 778
 779// AcceptAdmin completes a handover. Only the proposed account may call it.
 780func AcceptAdmin(cur realm) {
 781	rejectStraySend(cur)
 782	caller := cur.Previous().Address()
 783	var zero address
 784	if pendingAdmin == zero || caller != pendingAdmin {
 785		panic("only the proposed admin may accept")
 786	}
 787	old := admin
 788	admin = caller
 789	pendingAdmin = zero
 790	chain.Emit("AdminTransferred", "old", old.String(), "new", admin.String())
 791}
 792
 793// --- read-only views ---
 794
 795// ScheduleInfo returns a schedule's fields by value.
 796func ScheduleInfo(id int64) (funder, beneficiary address, title string, total, feeBps int64,
 797	revocable bool, createdAt, numTranches, pending, releasedGross, revokedTotal int64) {
 798	s := mustSchedule(id)
 799	return s.funder, s.beneficiary, s.title, s.total, s.feeBps, s.revocable,
 800		s.createdAt, int64(len(s.trancheIDs)), s.pending, s.releasedGross, s.revokedTotal
 801}
 802
 803// Memo returns a schedule's raw memo text.
 804func Memo(id int64) string { return mustSchedule(id).memo }
 805
 806// TrancheInfo returns one tranche's fields by value. It answers for settled
 807// tranches too, after duebook has consumed the deferral.
 808func TrancheInfo(trancheID int64) (scheduleID, seq, amount, dueAt int64, status string, settledAt int64) {
 809	t := mustTranche(mustID(trancheID))
 810	return t.scheduleID, t.seq, t.amount, t.dueAt, t.status, t.settledAt
 811}
 812
 813// TranchesOf lists a schedule's tranches, newline-separated, one per line
 814// as "id seq amount dueAt status".
 815func TranchesOf(id int64) string {
 816	s := mustSchedule(id)
 817	out := ""
 818	for _, did := range s.trancheIDs {
 819		t := tranches.Get(padU(did)).(*tranche)
 820		out += utoa(did) + " " + itoa(t.seq) + " " + itoa(t.amount) + " " +
 821			itoa(t.dueAt) + " " + t.status + "\n"
 822	}
 823	return out
 824}
 825
 826// VestedOf returns, for a schedule, the amount whose due height has arrived
 827// and which is therefore no longer revocable — whether or not it has been
 828// released yet — alongside the amount still revocable by the funder.
 829func VestedOf(id int64) (vested, unvested int64) {
 830	s := mustSchedule(id)
 831	now := runtime.ChainHeight()
 832	for _, did := range s.trancheIDs {
 833		t := tranches.Get(padU(did)).(*tranche)
 834		if t.status == StatusRevoked {
 835			continue
 836		}
 837		if now >= t.dueAt {
 838			vested += t.amount
 839		} else {
 840			unvested += t.amount
 841		}
 842	}
 843	return vested, unvested
 844}
 845
 846// ReleasableNow lists tranche ids that are due and still pending, up to
 847// MaxListed, newline-separated. Anyone may Release each of them.
 848func ReleasableNow() string {
 849	now := runtime.ChainHeight()
 850	due := book.Due(now, MaxListed)
 851	out := ""
 852	for _, d := range due {
 853		out += utoa(d.ID) + "\n"
 854	}
 855	return out
 856}
 857
 858// Admin returns the current admin.
 859func Admin() address { return admin }
 860
 861// PendingAdmin returns the proposed successor admin, or the zero address.
 862func PendingAdmin() address { return pendingAdmin }
 863
 864// FeeRecipient returns who may withdraw fees and sweep surplus.
 865func FeeRecipient() address { return feeRecipient }
 866
 867// FeeBps returns the fee that will be snapshotted into newly funded
 868// schedules. Existing schedules keep their own snapshot.
 869func FeeBps() int64 { return feeBps }
 870
 871// FeeOn previews the fee and net credit for a tranche of amount at the
 872// CURRENT FeeBps.
 873func FeeOn(amount int64) (fee, credited int64) {
 874	f, err := feeledger.FeeFor(amount, feeBps)
 875	if err != nil {
 876		panic(err)
 877	}
 878	return f, amount - f
 879}
 880
 881// NumSchedules returns how many schedules have ever been funded.
 882func NumSchedules() int64 { return nextScheduleID }
 883
 884// OpenTrancheCount returns how many tranches are pending across the whole
 885// realm, against the MaxOpenTranches cap.
 886func OpenTrancheCount() int { return book.OpenCount() }
 887
 888// OpenTranchesOfFunder returns how many tranches funder currently holds
 889// pending, against the MaxOpenPerFunder cap. A funder can read this before
 890// funding to see the headroom they have left.
 891func OpenTranchesOfFunder(funder address) int64 { return openOf(funder) }
 892
 893// FundersWithOpenTranches returns how many distinct funders hold at least
 894// one pending tranche. It is the width of the per-funder cap: exhausting
 895// MaxOpenTranches requires this to reach ceil(MaxOpenTranches/MaxOpenPerFunder).
 896func FundersWithOpenTranches() int { return openByFunder.Size() }
 897
 898// EscrowTotal returns the escrow held by pending tranches (the E term).
 899func EscrowTotal() int64 { return escrowTotal }
 900
 901// BalanceOf returns addr's claimable balance (released tranches for a
 902// beneficiary, revocations for a funder).
 903func BalanceOf(addr address) int64 { return ledger.BalanceOf(addr.String()) }
 904
 905// UsersTotal returns the sum of all claimable balances (the U term).
 906func UsersTotal() int64 { return ledger.UsersTotal() }
 907
 908// FeesAccrued returns the fee pot (the F term).
 909func FeesAccrued() int64 { return ledger.FeesAccrued() }
 910
 911// Liabilities returns everything this realm owes:
 912// EscrowTotal + UsersTotal + FeesAccrued.
 913func Liabilities() int64 { return escrowTotal + ledger.Liabilities() }
 914
 915// Held returns the ugnot actually held at this realm's address (H).
 916func Held() int64 { return coinio.HeldAt(self, Denom) }
 917
 918// Surplus returns Held() - Liabilities() (the S term).
 919func Surplus() int64 { return Held() - Liabilities() }
 920
 921// Address returns this realm's address (the escrow target).
 922func Address() address { return self }
 923
 924// Height returns the current chain height. Delays in Fund's spec are
 925// relative to the height at which Fund executes, so this is an aid for
 926// composing a spec, not an input to one.
 927func Height() int64 { return runtime.ChainHeight() }
 928
 929// --- render ---
 930
 931// Render shows the realm at "" and a schedule detail at "<id>". Free text
 932// (titles are charset-restricted; memos are not) passes through the
 933// ecosystem sanitizer before it reaches markdown.
 934func Render(path string) string {
 935	if path != "" {
 936		return renderSchedule(path)
 937	}
 938	held := Held()
 939	liab := Liabilities()
 940	status := "OK"
 941	if held < liab {
 942		status = "VIOLATED"
 943	}
 944	now := runtime.ChainHeight()
 945
 946	out := "# Vesting — dated tranche disbursement\n\n"
 947	out += "Escrowed GNOT released in **discrete, individually dated tranches** — not by "
 948	out += "linear accrual. A tranche is worth nothing before its due height and its full "
 949	out += "amount from that height on.\n\n"
 950	out += "Schedule and exactly-once release via `duebook`; coin custody via `coinio`; "
 951	out += "accounting via `feeledger`.\n\n"
 952	out += "## Configuration\n\n"
 953	out += "- fee for new schedules: " + itoa(feeBps) + " bps (cap " + itoa(MaxFeeBps) +
 954		" bps; each schedule keeps the fee snapshotted at funding)\n"
 955	out += "- tranches per schedule: up to " + itoa(MaxTranches) + "\n"
 956	out += "- pending tranches realm-wide: " + itoa(int64(book.OpenCount())) + " of " +
 957		itoa(int64(MaxOpenTranches)) + "\n"
 958	out += "- pending tranches per funder: up to " + itoa(int64(MaxOpenPerFunder)) +
 959		" (" + itoa(int64(openByFunder.Size())) + " funder(s) hold at least one)\n"
 960	out += "- delay range: " + itoa(MinDelayBlocks) + " to " + itoa(MaxDelayBlocks) + " blocks\n"
 961	out += "- fee recipient: " + feeRecipient.String() + "\n"
 962	out += "- admin: " + admin.String() + "\n"
 963	out += "- current height: " + itoa(now) + "\n\n"
 964	out += "## Accounting (H == E + U + F + S)\n\n"
 965	out += "- escrowed in pending tranches (E): " + itoa(escrowTotal) + Denom + "\n"
 966	out += "- claimable (U): " + itoa(ledger.UsersTotal()) + Denom + "\n"
 967	out += "- fees accrued (F): " + itoa(ledger.FeesAccrued()) + Denom + "\n"
 968	out += "- held (H): " + itoa(held) + Denom + "\n"
 969	out += "- conservation: " + status + "\n\n"
 970	out += "## Latest schedules\n\n"
 971	if nextScheduleID == 0 {
 972		out += "No schedules yet.\n"
 973		return out
 974	}
 975	shown := 0
 976	schedules.ReverseIterate("", "", func(_ string, v any) bool {
 977		s := v.(*schedule)
 978		kind := "irrevocable"
 979		if s.revocable {
 980			kind = "revocable"
 981		}
 982		out += "- [#" + itoa(s.id) + "](/r/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/vesting:" +
 983			itoa(s.id) + ") " + s.title + " — " + itoa(s.total) + Denom + " in " +
 984			itoa(int64(len(s.trancheIDs))) + " tranches (" + itoa(s.pending) + " pending, " +
 985			kind + ")\n"
 986		shown++
 987		return shown >= 20
 988	})
 989	return out
 990}
 991
 992func renderSchedule(path string) string {
 993	id, err := strconv.ParseInt(path, 10, 64)
 994	if err != nil {
 995		return "> [!WARNING]\n> invalid schedule id\n"
 996	}
 997	v := schedules.Get(padID(id))
 998	if v == nil {
 999		return "> [!WARNING]\n> unknown schedule id\n"
1000	}
1001	s := v.(*schedule)
1002	now := runtime.ChainHeight()
1003	vested, unvested := VestedOf(s.id)
1004
1005	out := "# Schedule #" + itoa(s.id) + ": " + s.title + "\n\n"
1006	out += "- funder: " + s.funder.String() + "\n"
1007	out += "- beneficiary: " + s.beneficiary.String() + "\n"
1008	out += "- total: " + itoa(s.total) + Denom + " in " + itoa(int64(len(s.trancheIDs))) + " tranches\n"
1009	out += "- fee (snapshot): " + itoa(s.feeBps) + " bps\n"
1010	if s.revocable {
1011		out += "- revocable by the funder — but only for tranches that are NOT yet due\n"
1012	} else {
1013		out += "- **irrevocable**: the funder cannot reclaim any tranche\n"
1014	}
1015	out += "- funded at height: " + itoa(s.createdAt) + " (now " + itoa(now) + ")\n"
1016	out += "- vested (no longer revocable): " + itoa(vested) + Denom + "\n"
1017	out += "- unvested (still revocable if revocable): " + itoa(unvested) + Denom + "\n"
1018	out += "- released so far: " + itoa(s.releasedGross) + Denom +
1019		"; revoked: " + itoa(s.revokedTotal) + Denom + "\n\n"
1020
1021	out += "## Tranches\n\n"
1022	out += "| # | id | amount | due at | status |\n|---|---|---|---|---|\n"
1023	shown := 0
1024	for _, did := range s.trancheIDs {
1025		if shown >= MaxListed {
1026			out += "| … | | | | (" + itoa(int64(len(s.trancheIDs))-int64(shown)) + " more) |\n"
1027			break
1028		}
1029		t := tranches.Get(padU(did)).(*tranche)
1030		note := t.status
1031		if t.status == StatusPending && now >= t.dueAt {
1032			note = "**releasable now**"
1033		}
1034		out += "| " + itoa(t.seq) + " | " + utoa(did) + " | " + itoa(t.amount) + Denom +
1035			" | " + itoa(t.dueAt) + " | " + note + " |\n"
1036		shown++
1037	}
1038	if s.memo != "" {
1039		out += "\n## Memo\n\n" + sanitize.InlineText(s.memo) + "\n"
1040	}
1041	return out
1042}
1043
1044// --- internals ---
1045
1046func assertAdmin(caller address) {
1047	if caller != admin {
1048		panic("admin only")
1049	}
1050}
1051
1052// assertRevocableBy checks both halves of the revocation authority: the
1053// schedule must have been funded revocable, and the caller must be its
1054// funder.
1055func assertRevocableBy(s *schedule, caller address) {
1056	if !s.revocable {
1057		panic("schedule " + itoa(s.id) + " is irrevocable")
1058	}
1059	if caller != s.funder {
1060		panic("only the funder may revoke")
1061	}
1062}
1063
1064func mustSchedule(id int64) *schedule {
1065	v := schedules.Get(padID(id))
1066	if v == nil {
1067		panic("unknown schedule id")
1068	}
1069	return v.(*schedule)
1070}
1071
1072func mustTranche(did uint64) *tranche {
1073	v := tranches.Get(padU(did))
1074	if v == nil {
1075		panic("unknown tranche id")
1076	}
1077	return v.(*tranche)
1078}
1079
1080// openOf reports how many tranches funder currently holds pending. A
1081// missing key means zero, which is the same thing: addOpen removes the
1082// entry when the count reaches zero.
1083func openOf(funder address) int64 {
1084	v := openByFunder.Get(funder.String())
1085	if v == nil {
1086		return 0
1087	}
1088	return v.(int64)
1089}
1090
1091// addOpen adjusts funder's pending-tranche count by delta, which may be
1092// negative. Every increment happens in Fund and every decrement is paired
1093// with a pending -> terminal transition, so the count tracks exactly the
1094// tranches openOf claims.
1095func addOpen(funder address, delta int64) {
1096	next := openOf(funder) + delta
1097	if next < 0 {
1098		// Unreachable: a tranche can only leave StatusPending once, and it
1099		// was counted when it was created. Kept as a consistency assertion
1100		// — if it fires, this counter has diverged from the tranche records
1101		// and the transaction must not proceed.
1102		panic("internal: funder open-tranche count went negative")
1103	}
1104	if next == 0 {
1105		openByFunder.Remove(funder.String())
1106		return
1107	}
1108	openByFunder.Set(funder.String(), next)
1109}
1110
1111// mustID converts an entrypoint's int64 tranche id to duebook's uint64,
1112// rejecting the non-positive values duebook never issues.
1113func mustID(id int64) uint64 {
1114	if id <= 0 {
1115		panic("tranche id must be positive")
1116	}
1117	return uint64(id)
1118}
1119
1120// parseSpec parses "delay:amount,delay:amount,…" into parallel slices,
1121// enforcing every bound before any state is touched. Delays must be
1122// strictly increasing; amounts must be positive. Nothing here consults or
1123// mutates realm state, so a malformed spec is rejected with the escrow
1124// still untouched (the whole transaction aborts regardless).
1125func parseSpec(spec string) (delays, amounts []int64) {
1126	if spec == "" {
1127		panic("spec must list at least one tranche as \"delay:amount\"")
1128	}
1129	if len(spec) > MaxSpecLen {
1130		panic("spec exceeds " + itoa(MaxSpecLen) + " bytes")
1131	}
1132	parts := strings.Split(spec, ",")
1133	if len(parts) > MaxTranches {
1134		panic("a schedule may have at most " + itoa(MaxTranches) + " tranches, got " +
1135			itoa(int64(len(parts))))
1136	}
1137
1138	prev := int64(-1)
1139	for i, part := range parts {
1140		at := strings.Index(part, ":")
1141		if at < 0 {
1142			panic("tranche " + itoa(int64(i+1)) + ": expected \"delay:amount\", got \"" + part + "\"")
1143		}
1144		delay, err := strconv.ParseInt(part[:at], 10, 64)
1145		if err != nil {
1146			panic("tranche " + itoa(int64(i+1)) + ": delay is not an integer")
1147		}
1148		amount, err := strconv.ParseInt(part[at+1:], 10, 64)
1149		if err != nil {
1150			panic("tranche " + itoa(int64(i+1)) + ": amount is not an integer")
1151		}
1152		if delay < MinDelayBlocks || delay > MaxDelayBlocks {
1153			panic("tranche " + itoa(int64(i+1)) + ": delay " + itoa(delay) +
1154				" outside [" + itoa(MinDelayBlocks) + ", " + itoa(MaxDelayBlocks) + "]")
1155		}
1156		if delay <= prev {
1157			panic("tranche " + itoa(int64(i+1)) + ": delays must be strictly increasing (" +
1158				itoa(delay) + " follows " + itoa(prev) + "); combine same-height payments")
1159		}
1160		if amount <= 0 {
1161			panic("tranche " + itoa(int64(i+1)) + ": amount must be positive")
1162		}
1163		prev = delay
1164		delays = append(delays, delay)
1165		amounts = append(amounts, amount)
1166	}
1167	return delays, amounts
1168}
1169
1170// assertValidTitle bounds length and restricts the charset so titles are
1171// list-safe in Render without escaping.
1172func assertValidTitle(title string) {
1173	if len(title) == 0 || len(title) > MaxTitleLen {
1174		panic("title must be 1-" + itoa(MaxTitleLen) + " characters")
1175	}
1176	for i := 0; i < len(title); i++ {
1177		c := title[i]
1178		switch {
1179		case c >= 'a' && c <= 'z':
1180		case c >= 'A' && c <= 'Z':
1181		case c >= '0' && c <= '9':
1182		case c == ' ' || c == '_' || c == '-':
1183		default:
1184			panic("title may only contain letters, digits, space, _ and -")
1185		}
1186	}
1187}
1188
1189// padID renders a schedule id as a fixed-width key so avl iteration order
1190// is numeric order.
1191func padID(id int64) string {
1192	s := strconv.FormatInt(id, 10)
1193	for len(s) < 12 {
1194		s = "0" + s
1195	}
1196	return s
1197}
1198
1199// padU renders a deferral id as a fixed-width key. 20 digits covers the
1200// whole uint64 range, so tranche iteration is numeric order too.
1201func padU(id uint64) string {
1202	s := strconv.FormatUint(id, 10)
1203	for len(s) < 20 {
1204		s = "0" + s
1205	}
1206	return s
1207}
1208
1209func itoa(n int64) string { return strconv.FormatInt(n, 10) }
1210
1211func utoa(n uint64) string { return strconv.FormatUint(n, 10) }
1212
1213func btoa(b bool) string {
1214	if b {
1215		return "true"
1216	}
1217	return "false"
1218}
1219
1220func checkedAdd(a, b int64) (int64, bool) {
1221	sum := a + b
1222	if (b > 0 && sum < a) || (b < 0 && sum > a) {
1223		return 0, false
1224	}
1225	return sum, true
1226}