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}