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

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

hlroute.gno

98.84 Kb · 2464 lines
   1// Package hlroute is the state a bridge route realm keeps (bridge/PLAN.md §5.2–§5.5, §5.9), shared by r/hlgnot and
   2// r/hlbubble: the lock's counters and the supply identity, the claims in per-recipient queues, the two meters that
   3// rate-limit what is paid, the sweep queue, the send records a successor refunds by id, and GovDAO's decisions on
   4// the route's settings. It holds no coins and makes no call: the realm that owns a State moves the asset through
   5// the Env callbacks and asks hlmailbox what only the realm can ask. Every clock here is unix time, passed in.
   6package hlroute
   7
   8import (
   9	"encoding/hex"
  10	"errors"
  11	"strconv"
  12	"strings"
  13
  14	"gno.land/p/nt/avl/v0"
  15	"gno.land/p/g1zdaz6sypk5tdsjc2fzsqrrn4ttn8qzmnl4waqp/decisions/v0"
  16)
  17
  18const (
  19	Day = decisions.Day
  20	// MaxClaims is the most claims one Claim call pays (§5.4: n ≤ 20).
  21	MaxClaims = 20
  22	// MaxIDs is the most message ids one void, restore, credit or refund names.
  23	MaxIDs = 256
  24	// PageLines is how many waiting claims, and how many sweep entries, the route's page lists; the rest are
  25	// counted there and read by hundreds at claims/<from> and sweeps/<from>, so a flood of dust can never make
  26	// the page a member reads exceed a query's gas.
  27	PageLines = 100
  28	// PullPage is how many records one Pull hands to a successor (R13): the migration moves the claims, the sweep
  29	// entries and the sends a page at a time, so no page outgrows a block however long the route ran.
  30	PullPage = 500
  31	// MaxRangeClaims is the most credits (claims and sweep entries, voidable or not, R23) a range void's scan may
  32	// visit when it is made (R14): at ≈0.4M gas a credit on chain a thousand is ≈0.4B, inside a block with room;
  33	// GovDAO splits a bigger range by heights, from the last height a decision's note says it voided. The range is
  34	// closed at the height the proposal is made, so what it covers never grows after.
  35	MaxRangeClaims = 1000
  36	// guardianSeats are seats 1–4 (hlmailbox's numbering); a void's unpause option vacates at most maxGuardianSeats
  37	// of them (§5.3: two guardians at most per proposal).
  38	guardianSeats    = 4
  39	maxGuardianSeats = 2
  40
  41	// Claim states.
  42	Open     = "open"     // credited, unpaid or paying
  43	Withheld = "withheld" // voided as withheld: the remainder stays booked, restorable at once
  44	Forged   = "forged"   // voided as forged: the remainder dropped, the paid part lost; restorable after 7 days
  45	Done     = "done"     // paid in full
  46
  47	// Who credited a claim.
  48	BySigners = "signers" // the verifier (the signers until Proposal 2)
  49	ByVote    = "vote"    // GovDAO's credit by id
  50
  51	// Decision kinds.
  52	KindCapFloor = "set cap floor"
  53	KindMaximum  = "set maximum"
  54	KindFee      = "set fee and minimum"
  55	KindFeeFloor = "set fee floor"
  56	KindRouter   = "set router"
  57	KindVoid     = "void"
  58	KindRestore  = "restore"
  59	KindCredit   = "credit"
  60	KindSweep    = "sweep"
  61	KindRefund   = "refund"
  62	// KindApplyRefund is GovDAO's own at-once apply of a queued refund decision (AT2): a refund is never Execute's.
  63	KindApplyRefund = "apply refund"
  64	KindWriteOff    = "write off"
  65	KindMigrate     = "migrate"
  66	KindCancel      = "cancel"
  67
  68	one = int64(1_000_000) // 6 decimals on both sides
  69
  70	maxInt64  = int64(1<<63 - 1)
  71	maxHeight = int64(9_999_999_999_999_999) // the height index's keys are 16 digits
  72)
  73
  74// Launch are a route's compiled launch values.
  75type Launch struct {
  76	Symbol   string // "GNOT" or "BUBBLE"
  77	Fee      int64  // the flat fee on every send and the first installment of every claim
  78	Minimum  int64  // the least a send may carry
  79	FeeFloor int64  // the fee and the minimum never go below it
  80	CapFloor int64  // the day cap is max(CapFloor, 10% of the lock)
  81	Maximum  int64  // the most a send may carry
  82	Domain   uint32 // the destination's domain
  83	Router   [32]byte
  84	Now      int64 // deploy time: the meters start empty here
  85}
  86
  87// Claim is one IOU the route owes a recipient (§5.4).
  88type Claim struct {
  89	ID     string // the message id, lowercase hex
  90	To     address
  91	Amount int64
  92	Paid   int64 // fee included
  93	At     int64 // credited at (unix time)
  94	Height int64 // credited at (block height): what a range names
  95	By     string
  96	State  string
  97	Seq    int64 // its place in the recipient's queue
  98	// QueueNext is set in a migration page only (R20): the predecessor's final counter for the recipient's queue, so
  99	// the successor's own credits land above every place a later page can claim. It is 0 on a stored claim.
 100	QueueNext int64
 101}
 102
 103// Range names claims by when they were credited rather than by id: every claim credited at a block height from
 104// From to To inclusive, or — Since — every claim the verifier credited at a height at or above From, open above
 105// while a flag stands (the verifier's later credits are covered too) and closed by a void at the height it
 106// executes (claims credited after the vote were not decided on). Written "heights:<from>-<to>" or
 107// "since:<from>"; a guardian's payouts flag and the void builder take one in place of a list of ids, so one flag
 108// and one closure cover a flood larger than a list.
 109type Range struct {
 110	From, To int64
 111	Since    bool
 112}
 113
 114// ParseRange reads a range; ok is false when s is not one (a list of ids, then). A malformed range panics.
 115func ParseRange(s string) (r Range, ok bool) {
 116	s = strings.TrimSpace(s)
 117	switch {
 118	case strings.HasPrefix(s, "heights:"):
 119		body := s[len("heights:"):]
 120		i := strings.Index(body, "-")
 121		if i < 0 {
 122			panic("hlroute: a height range is heights:<from>-<to>, not " + s)
 123		}
 124		from, to := parseHeight(body[:i]), parseHeight(body[i+1:])
 125		if to < from {
 126			panic("hlroute: the range runs from the lower height to the higher")
 127		}
 128		return Range{From: from, To: to}, true
 129	case strings.HasPrefix(s, "since:"):
 130		return Range{From: parseHeight(s[len("since:"):]), Since: true}, true
 131	}
 132	return Range{}, false
 133}
 134
 135func parseHeight(s string) int64 {
 136	n, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
 137	if err != nil || n < 1 || n > maxHeight {
 138		panic("hlroute: not a block height: " + s)
 139	}
 140	return n
 141}
 142
 143// Covers is whether a claim credited at height — by the verifier, or not — is in the range.
 144func (r Range) Covers(height int64, verifier bool) bool {
 145	if r.Since {
 146		return verifier && height >= r.From && (r.To == 0 || height <= r.To)
 147	}
 148	return height >= r.From && height <= r.To
 149}
 150
 151// String says the range in words.
 152func (r Range) String() string {
 153	if r.Since {
 154		s := "the verifier's credits since height " + itoa(r.From)
 155		if r.To != 0 {
 156			s += " up to height " + itoa(r.To)
 157		}
 158		return s
 159	}
 160	return "claims credited at heights " + itoa(r.From) + "-" + itoa(r.To)
 161}
 162
 163// closed is the range as a void takes it at height: a Since range, or one reaching past height, ends there.
 164func (r Range) closed(height int64) Range {
 165	if r.Since && (r.To == 0 || r.To > height) || !r.Since && r.To > height {
 166		r.To = height
 167	}
 168	return r
 169}
 170
 171// Remaining is what the claim still owes.
 172func (c *Claim) Remaining() int64 { return c.Amount - c.Paid }
 173
 174// Meter is §5.4's bucket: a level that refills at a rate per day up to a ceiling, emptied at every payouts epoch.
 175// Frac carries the refill's remainder (in base units × seconds, below a day), so refills in any pieces sum to
 176// exactly rate a day.
 177type Meter struct {
 178	Level int64
 179	At    int64 // last refill
 180	Epoch int64 // the payouts epoch it last saw
 181	Frac  int64
 182}
 183
 184// refill brings the meter up to now at rate a day, never above ceil; a new epoch empties it as of epochAt first.
 185// rate and ceil are at most a few billion tokens (2^63 base units over a day would overflow nothing here, as the
 186// elapsed seconds are capped at a day and rate%Day × Day < 2^33).
 187func (m *Meter) refill(now, rate, ceil, epoch, epochAt int64) {
 188	if m.Epoch != epoch {
 189		m.Level, m.At, m.Epoch, m.Frac = 0, epochAt, epoch, 0
 190	}
 191	if now <= m.At {
 192		return
 193	}
 194	el := now - m.At
 195	if el > Day {
 196		el = Day
 197		m.Frac = 0
 198	}
 199	num := (rate%Day)*el + m.Frac
 200	m.Level += (rate/Day)*el + num/Day
 201	m.Frac = num % Day
 202	if m.Level > ceil {
 203		m.Level, m.Frac = ceil, 0
 204	}
 205	m.At = now
 206}
 207
 208// Queue is one recipient's claims, oldest first, and that recipient's own meter (a quarter of the bridge's).
 209type Queue struct {
 210	items    avl.Tree // seqKey(seq) → claim id: the open claims only (a voided or paid claim leaves the queue, R12)
 211	meter    Meter
 212	next     int64
 213	base     int64 // an import places a predecessor's claim at base + its old seq, so the pages keep the queue's order
 214	imported bool
 215}
 216
 217// Send is one outgoing transfer's record, kept so a successor can refund it by id if it was never delivered.
 218type Send struct {
 219	From       address
 220	Amount     int64
 221	At         int64
 222	Refunded   int64 // when it was refunded, 0 if not
 223	WrittenOff int64 // when it was written off as delivered on Solana after its refund (AR1), 0 if not
 224	Imported   bool  // brought by a predecessor's page (AQ2): the only sends a refund may name
 225}
 226
 227// SweepEntry is one entry of the sweep queue (§5.4): a credit that pays nobody until GovDAO directs it — a burn
 228// below the fee, to a realm or non-gno recipient, or above the lock — kept by id with the height it was credited
 229// at and who credited it, so a void names it by id or by range like a claim (R11): a forged entry is voided as
 230// forged (Voided: its amount leaves the sweep queue's balance and what is owed, so the identity closes) and
 231// restored after 7 days; a credit by id to its owner replaces it with a claim.
 232type SweepEntry struct {
 233	ID     string
 234	Amount int64
 235	Height int64
 236	By     string
 237	Voided bool
 238}
 239
 240// Env is what the realm does for the state: the calls only a realm can make. Every field is set.
 241type Env struct {
 242	Flagged    func(id string, height int64, verifier bool) bool // a guardian's standing payouts flag on this route names the claim: by id, or by a range covering the height it was credited at (and whether the verifier credited it)
 243	ClearFlags func(fn string) bool                              // clears every key's flag for fn on this route (the void's unpause option)
 244	RemoveSeat func(seat int, holder address) bool               // vacates a guardian or watch-key seat if holder still has it (the void's unpause option); false, nothing done, if the occupant changed (R17)
 245	Delivered  func(id string) bool                              // hlmailbox marked the id done
 246	MarkDone   func(id string)                                   // marks an id done in hlmailbox (credit by vote)
 247	Epoch      func() int64                                      // hlmailbox's payouts epoch for this route now (a queue made by a decision starts there)
 248	Height     func() int64                                      // the block height now (a claim credited by a decision is credited at it; a range void closes at it)
 249	Pay        func(to address, amount int64)
 250	PayFee     func(amount int64)
 251	AddRoute     func(path string)        // registers a successor route with hlmailbox
 252	AnnouncedFor func(path string) string // the route ("GNOT" or "BUBBLE") a realm at this pkgpath announced itself to hlmailbox as a successor of (R25, AQ5), "" if it never did: a migrate names only a path announced for this route
 253	Predecessor  func() string            // the route whose migrate registered this realm with hlmailbox (hlmailbox.Predecessor of this realm's own path), "" for a launch route or a realm not registered yet (R31): a successor migrates on only once its own import has ended
 254	IsRoute      func(path string) bool   // whether hlmailbox registers a route at this pkgpath (R31): a migrate never names one
 255	Enrolled     func() (height, at int64) // the block height and time r/hlgov last dispatched a kind 6 enrolling this realm as the token's router on Solana, as it noted with hlmailbox (hlmailbox.Enrolled of this realm's own path); 0, 0 if never, or cleared by r/hlgov's cancel (AR1, R37): a refund waits 14 days after it, and a successor sends nothing before it (AT7)
 256}
 257
 258// The three functions of a route a flag can pause (hlmailbox's names).
 259const (
 260	FnSends   = "sends"
 261	FnCredits = "credits"
 262	FnPayouts = "payouts"
 263)
 264
 265// State is a route's whole state but the asset itself.
 266type State struct {
 267	Symbol string
 268	Domain uint32
 269
 270	Fee, Minimum, FeeFloor, CapFloor, Maximum int64
 271	LaunchMinimum, LaunchCapFloor             int64 // the compiled floors no builder goes below
 272
 273	Router   [32]byte
 274	OldRouter [32]byte
 275	OldUntil int64 // the old router's messages are accepted until this time (7 days after a swap)
 276
 277	// The identity (§5.9): Lock = SentTotal − PaidTotal = supply + Owed + Voided − Lost, up to in-flight messages.
 278	SentTotal int64 // locked by sends (fees excluded)
 279	PaidTotal int64 // paid out of claims, fees included, and sweep payments
 280	Owed      int64 // credited, unpaid; the sweep queue included
 281	Voided    int64 // the unpaid remainder of claims voided as withheld
 282	Lost      int64 // the paid part of claims voided as forged, and the sends delivered on Solana after their refund, written off (AR1)
 283	Lock      int64
 284	Sweep     int64 // the sweep queue's balance, part of Owed
 285
 286	claims   avl.Tree // id → *Claim, every claim ever credited
 287	credits  avl.Tree // heightKey(height, id) → id: the claims and the sweep entries by the height they were credited at, what a range walks
 288	queues   avl.Tree // recipient → *Queue
 289	waiting  avl.Tree // recipient → true while its queue holds an open claim — exact (set at a credit or a restore, dropped with the last open claim, R12): the cursor's ring and the page's walk
 290	cursor   string   // the last recipient served
 291	meter    Meter    // the bridge's meter
 292	sweeps   avl.Tree // id → *SweepEntry
 293	sends    avl.Tree // id → *Send
 294	refunds  avl.Tree // id → true: the sends refunded here, what refunds/<from> pages (AQ2)
 295	nRefunded     int64 // sends refunded here (the records held here: a refunded send goes with its page, R33)
 296	refundedTotal int64 // what they summed to
 297	nWrittenOff     int64 // sends written off as delivered on Solana after their refund (AR1), held here
 298	writtenOffTotal int64 // what they summed to
 299	nClaims  int64    // claims ever credited
 300	nOpen    int64    // claims open now
 301	exported int      // pages handed to the successor by Export (R13)
 302	imported int      // pages taken from a predecessor by Import, in order (R20)
 303	importing bool    // between a predecessor's first page and its last (AP4): Credit refuses, so no credit of this route's own can collide with a place a page still to come claims
 304
 305	Ledger     decisions.Ledger
 306	MigratedTo string // the successor realm, once a migrate decision applied; every function then refuses
 307	MigratedAt int64
 308	LaunchSeen int64 // the launch time the meters were last emptied for
 309}
 310
 311// New is a route's state with its launch values, the meters empty as of l.Now.
 312func New(l Launch) *State {
 313	s := &State{Symbol: l.Symbol, Domain: l.Domain, Fee: l.Fee, Minimum: l.Minimum, FeeFloor: l.FeeFloor, CapFloor: l.CapFloor,
 314		Maximum: l.Maximum, LaunchMinimum: l.Minimum, LaunchCapFloor: l.CapFloor, Router: l.Router}
 315	s.meter.At = l.Now
 316	return s
 317}
 318
 319func itoa(n int64) string { return strconv.FormatInt(n, 10) }
 320
 321func seqKey(n int64) string {
 322	s := itoa(n)
 323	return strings.Repeat("0", 12-len(s)) + s
 324}
 325
 326// heightKey sorts the credits by height, then id.
 327func heightKey(h int64, id string) string {
 328	s := itoa(h)
 329	return strings.Repeat("0", 16-len(s)) + s + "|" + id
 330}
 331
 332// Amount writes base units as whole tokens with the symbol: 12,000,000 → "12 GNOT".
 333func (s *State) Amount(n int64) string {
 334	neg := ""
 335	if n < 0 {
 336		neg, n = "-", -n
 337	}
 338	w := itoa(n / one)
 339	if f := n % one; f != 0 {
 340		fs := strconv.FormatInt(f+one, 10)[1:]
 341		w += "." + strings.TrimRight(fs, "0")
 342	}
 343	return neg + w + " " + s.Symbol
 344}
 345
 346// CleanID is a message id as the state keys it: lowercase hex, no 0x; it errors on anything but 32 bytes.
 347func CleanID(id string) (string, error) {
 348	id = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(id)), "0x")
 349	if b, err := hex.DecodeString(id); err != nil || len(b) != 32 {
 350		return "", errors.New("not a 32-byte message id in hex: " + id)
 351	}
 352	return id, nil
 353}
 354
 355func mustID(id string) string {
 356	c, err := CleanID(id)
 357	if err != nil {
 358		panic("hlroute: " + err.Error())
 359	}
 360	return c
 361}
 362
 363// ParseIDs reads a comma-separated list of message ids, refusing an empty list, a bad id and a repeat.
 364func ParseIDs(list string) []string {
 365	parts := strings.Split(list, ",")
 366	out := make([]string, 0, len(parts))
 367	for _, p := range parts {
 368		if strings.TrimSpace(p) == "" {
 369			continue
 370		}
 371		id := mustID(p)
 372		for _, x := range out {
 373			if x == id {
 374				panic("hlroute: id named twice: " + id)
 375			}
 376		}
 377		out = append(out, id)
 378	}
 379	if len(out) == 0 {
 380		panic("hlroute: no ids")
 381	}
 382	if len(out) > MaxIDs {
 383		panic("hlroute: more than " + strconv.Itoa(MaxIDs) + " ids")
 384	}
 385	return out
 386}
 387
 388// Cap is the day cap: max(the floor, a tenth of the lock).
 389func (s *State) Cap() int64 {
 390	c := s.Lock / 10
 391	if c < s.CapFloor {
 392		c = s.CapFloor
 393	}
 394	return c
 395}
 396
 397// Migrated is whether the lock went to a successor; every function refuses then.
 398func (s *State) Migrated() bool { return s.MigratedTo != "" }
 399
 400func (s *State) mustNotBeMigrated() {
 401	if s.Migrated() {
 402		panic("hlroute: migrated to " + s.MigratedTo)
 403	}
 404}
 405
 406// mustNotBeImporting refuses what a partial record set would mislead (R28) while a predecessor's pages are still
 407// arriving: a range void (a page brings claims at their old heights, inside any range closed earlier) and a
 408// migrate (the pages still at the predecessor could never reach the next route).
 409func (s *State) mustNotBeImporting(what string) {
 410	if s.importing {
 411		panic("hlroute: the predecessor's pages are still arriving (" + strconv.Itoa(s.imported) + " so far): " + what + " waits for the last page")
 412	}
 413}
 414
 415// ---------------------------------------------------------------- sends --
 416
 417// CheckSend is what a send must satisfy: a positive amount between the minimum and the maximum.
 418func (s *State) CheckSend(amount int64) {
 419	s.mustNotBeMigrated()
 420	if amount <= 0 {
 421		panic("hlroute: the amount must be positive")
 422	}
 423	if amount < s.Minimum {
 424		panic("hlroute: below the minimum of " + s.Amount(s.Minimum))
 425	}
 426	if amount > s.Maximum {
 427		panic("hlroute: above the per-message maximum of " + s.Amount(s.Maximum))
 428	}
 429}
 430
 431// Send records an outgoing transfer that locked amount.
 432func (s *State) Send(now int64, id string, from address, amount int64) {
 433	id = mustID(id)
 434	if s.sends.Has(id) {
 435		panic("hlroute: a send with this id exists")
 436	}
 437	s.sends.Set(id, &Send{From: from, Amount: amount, At: now})
 438	s.SentTotal += amount
 439	s.Lock += amount
 440}
 441
 442// SendOf is the record of a send by id, or nil.
 443func (s *State) SendOf(id string) *Send {
 444	v := s.sends.Get(mustID(id))
 445	if v == nil {
 446		return nil
 447	}
 448	return v.(*Send)
 449}
 450
 451// Sends is how many sends are recorded.
 452func (s *State) Sends() int { return s.sends.Size() }
 453
 454// RouterAccepts is whether a message from this sender is from the route's router: the current one, or the old one
 455// within the 7 days after a swap.
 456func (s *State) RouterAccepts(now int64, sender [32]byte) bool {
 457	if sender == s.Router {
 458		return true
 459	}
 460	return s.OldUntil > now && sender == s.OldRouter && s.OldRouter != ([32]byte{})
 461}
 462
 463// -------------------------------------------------------------- credits --
 464
 465// queue is a recipient's queue, made on first use with its meter empty as of now under the payouts epoch of now:
 466// a queue made after an epoch's reset never takes that reset (which would fill it from the reset's time, up to a
 467// whole quarter at once); it starts empty at its first credit, as §5.4 says of every meter.
 468func (s *State) queue(to address, now, epoch int64) *Queue {
 469	v := s.queues.Get(string(to))
 470	if v != nil {
 471		return v.(*Queue)
 472	}
 473	q := &Queue{}
 474	q.meter.At, q.meter.Epoch = now, epoch
 475	s.queues.Set(string(to), q)
 476	return q
 477}
 478
 479// ClaimOf is a claim by id, or nil.
 480func (s *State) ClaimOf(id string) *Claim {
 481	v := s.claims.Get(mustID(id))
 482	if v == nil {
 483		return nil
 484	}
 485	return v.(*Claim)
 486}
 487
 488// SweptOf is the amount swept under an id (0 if none), voided or not.
 489func (s *State) SweptOf(id string) int64 {
 490	if e := s.SweepOf(id); e != nil {
 491		return e.Amount
 492	}
 493	return 0
 494}
 495
 496// SweepOf is a sweep entry by id, or nil.
 497func (s *State) SweepOf(id string) *SweepEntry {
 498	v := s.sweeps.Get(mustID(id))
 499	if v == nil {
 500		return nil
 501	}
 502	return v.(*SweepEntry)
 503}
 504
 505// Credit books an incoming transfer: a claim in the recipient's queue, or — for a burn below the fee, one to a
 506// realm address (sweep), or one above the lock — the sweep queue. height is the block height now (what a range
 507// names the claim by); epoch is hlmailbox's payouts epoch for the route now (a fresh queue's meter starts there).
 508// It returns whether it was swept.
 509//
 510// A burn above the lock cannot be honest (a mint needs a lock and a burn needs supply, so the supply is never
 511// above the lock): it is swept, owed and paying nobody until GovDAO directs it, which also bounds every claim by
 512// the lock. An amount that would overflow what is owed is refused outright (the message stays undelivered), and so
 513// is every credit while a predecessor's pages are still arriving (AP4): the relayer brings the message again once
 514// the last page is in.
 515func (s *State) Credit(now, height, epoch int64, id string, to address, amount int64, by string, sweep bool) bool {
 516	s.mustNotBeMigrated()
 517	if s.importing {
 518		panic("hlroute: importing a predecessor's pages (" + strconv.Itoa(s.imported) + " so far): credits are refused until the last page has arrived")
 519	}
 520	id = mustID(id)
 521	if s.claims.Has(id) || s.sweeps.Has(id) {
 522		panic("hlroute: credited already: " + id)
 523	}
 524	if amount < 0 {
 525		panic("hlroute: a negative amount")
 526	}
 527	if amount > maxInt64-s.Owed {
 528		panic("hlroute: the amount would overflow what is owed")
 529	}
 530	if sweep || amount < s.Fee || amount > s.Lock {
 531		s.sweepIn(height, id, amount, by)
 532		return true
 533	}
 534	s.enqueue(now, height, epoch, id, to, amount, by)
 535	return false
 536}
 537
 538// sweepIn books a credit in the sweep queue: owed, paying nobody, indexed by its credit height like a claim.
 539func (s *State) sweepIn(height int64, id string, amount int64, by string) {
 540	if height < 0 || height > maxHeight {
 541		panic("hlroute: not a block height")
 542	}
 543	s.sweeps.Set(id, &SweepEntry{ID: id, Amount: amount, Height: height, By: by})
 544	s.credits.Set(heightKey(height, id), id)
 545	s.Sweep += amount
 546	s.Owed += amount
 547}
 548
 549func (s *State) enqueue(now, height, epoch int64, id string, to address, amount int64, by string) *Claim {
 550	if height < 0 || height > maxHeight {
 551		panic("hlroute: not a block height")
 552	}
 553	q := s.queue(to, now, epoch)
 554	q.next++
 555	c := &Claim{ID: id, To: to, Amount: amount, At: now, Height: height, By: by, State: Open, Seq: q.next}
 556	s.claims.Set(id, c)
 557	s.credits.Set(heightKey(height, id), id)
 558	q.items.Set(seqKey(c.Seq), id)
 559	s.waiting.Set(string(to), true)
 560	s.Owed += amount
 561	s.nClaims++
 562	s.nOpen++
 563	return c
 564}
 565
 566// oldestOpen is the first open claim in a queue, or nil.
 567func (s *State) oldestOpen(q *Queue) *Claim {
 568	var found *Claim
 569	q.items.Iterate("", "", func(_ string, v any) bool {
 570		c := s.claims.Get(v.(string)).(*Claim)
 571		if c.State == Open {
 572			found = c
 573			return true
 574		}
 575		return false
 576	})
 577	return found
 578}
 579
 580// nextRecipient is the recipient after the cursor in the ring, wrapping.
 581func (s *State) nextRecipient() string {
 582	next := ""
 583	s.waiting.Iterate(s.cursor+"\x00", "", func(k string, _ any) bool {
 584		next = k
 585		return true
 586	})
 587	if next == "" {
 588		s.waiting.Iterate("", "", func(k string, _ any) bool {
 589			next = k
 590			return true
 591		})
 592	}
 593	return next
 594}
 595
 596// ResetMeters empties both meters as of now (the launch).
 597func (s *State) ResetMeters(now int64) {
 598	s.meter.Level, s.meter.At, s.meter.Frac = 0, now, 0
 599	s.queues.Iterate("", "", func(_ string, v any) bool {
 600		q := v.(*Queue)
 601		q.meter.Level, q.meter.At, q.meter.Frac = 0, now, 0
 602		return false
 603	})
 604}
 605
 606// MeterLevels is the bridge meter's level now and a recipient's, after refilling under the given epoch.
 607func (s *State) MeterLevels(now int64, to address, epoch, epochAt int64) (bridge, recipient int64) {
 608	c := s.Cap()
 609	s.meter.refill(now, c, c, epoch, epochAt)
 610	bridge = s.meter.Level
 611	if v := s.queues.Get(string(to)); v != nil {
 612		q := v.(*Queue)
 613		q.meter.refill(now, c/4, c/4, epoch, epochAt)
 614		recipient = q.meter.Level
 615	}
 616	return bridge, recipient
 617}
 618
 619// Claim pays up to n claims (n ≤ 20) taking recipients in turns: the oldest open claim of each recipient with
 620// something waiting, as much of it as both meters allow, the fee as its first installment, skipping a recipient
 621// whose meter is dry. epoch and epochAt are hlmailbox's payouts epoch for this route. It returns how many claims
 622// it paid something on; the payments went through env.PayFee and env.Pay.
 623func (s *State) Claim(now int64, n int, epoch, epochAt int64, env Env) int {
 624	s.mustNotBeMigrated()
 625	if n < 1 {
 626		panic("hlroute: n must be positive")
 627	}
 628	if n > MaxClaims {
 629		n = MaxClaims
 630	}
 631	paid := 0
 632	for visits := 0; paid < n && visits < 4*n && s.waiting.Size() > 0; visits++ {
 633		addr := s.nextRecipient()
 634		s.cursor = addr
 635		q := s.queues.Get(addr).(*Queue)
 636		c := s.oldestOpen(q)
 637		if c == nil {
 638			s.waiting.Remove(addr)
 639			continue
 640		}
 641		dayCap := s.Cap()
 642		s.meter.refill(now, dayCap, dayCap, epoch, epochAt)
 643		q.meter.refill(now, dayCap/4, dayCap/4, epoch, epochAt)
 644		avail := s.meter.Level
 645		if q.meter.Level < avail {
 646			avail = q.meter.Level
 647		}
 648		// never more than the lock holds (R26): a claim left standing above the lock — honest claims after a
 649		// forgery drained part of it, or a hand credit — is paid what the lock holds, and a dry lock is a skip, not
 650		// a payment the realm's SendCoins would revert, the cursor with it, for every recipient behind
 651		if avail > s.Lock {
 652			avail = s.Lock
 653		}
 654		fee := int64(0)
 655		if c.Paid == 0 {
 656			fee = s.Fee
 657			if fee > c.Amount {
 658				fee = c.Amount
 659			}
 660			if avail < fee || avail <= 0 {
 661				continue // dry: the fee is the first installment, whole
 662			}
 663		} else if avail <= 0 {
 664			continue
 665		}
 666		pay := c.Remaining() - fee
 667		if pay > avail-fee {
 668			pay = avail - fee
 669		}
 670		if fee > 0 {
 671			env.PayFee(fee)
 672		}
 673		if pay > 0 {
 674			env.Pay(c.To, pay)
 675		}
 676		total := fee + pay
 677		c.Paid += total
 678		s.meter.Level -= total
 679		q.meter.Level -= total
 680		s.PaidTotal += total
 681		s.Owed -= total
 682		s.Lock -= total
 683		if c.Remaining() == 0 {
 684			s.finish(c, q)
 685		}
 686		paid++
 687	}
 688	return paid
 689}
 690
 691// finish takes a claim paid in full out of its queue; the record stays, as done.
 692func (s *State) finish(c *Claim, q *Queue) {
 693	c.State = Done
 694	s.dequeue(c, q)
 695	s.nOpen--
 696}
 697
 698// dequeue takes a claim out of its queue; a queue left with no open claim leaves the ring (R12: nothing but
 699// open claims ever sits in a queue, so a flood of voided claims never stands ahead of the open ones).
 700func (s *State) dequeue(c *Claim, q *Queue) {
 701	q.items.Remove(seqKey(c.Seq))
 702	if q.items.Size() == 0 {
 703		s.waiting.Remove(string(c.To))
 704	}
 705}
 706
 707// ------------------------------------------------------------ the void --
 708
 709// void applies one void to a claim: withheld keeps the remainder booked; forged drops it and books the paid part
 710// as lost. A withheld claim may be re-voided as forged; a claim paid in full may be voided as forged (its paid
 711// part becomes lost), not as withheld. An open claim voided leaves its queue (R12).
 712func (s *State) void(c *Claim, forged bool) {
 713	rem := c.Remaining()
 714	switch c.State {
 715	case Open:
 716		s.Owed -= rem
 717		s.nOpen--
 718		s.dequeue(c, s.queues.Get(string(c.To)).(*Queue))
 719		if forged {
 720			s.Lost += c.Paid
 721			c.State = Forged
 722		} else {
 723			s.Voided += rem
 724			c.State = Withheld
 725		}
 726	case Withheld:
 727		if !forged {
 728			panic("hlroute: " + c.ID + " is withheld already")
 729		}
 730		s.Voided -= rem
 731		s.Lost += c.Paid
 732		c.State = Forged
 733	case Done:
 734		if !forged {
 735			panic("hlroute: " + c.ID + " is paid in full; only a void as forged books it")
 736		}
 737		s.Lost += c.Paid
 738		c.State = Forged
 739	default:
 740		panic("hlroute: " + c.ID + " is forged already")
 741	}
 742}
 743
 744// voidable is whether a void as forged, or as withheld, can take the claim as it stands: an open claim either way,
 745// a withheld or a paid one as forged only. The list form refuses such ids when the proposal is made; the range
 746// form skips them.
 747func voidable(c *Claim, forged bool) bool {
 748	switch c.State {
 749	case Open:
 750		return true
 751	case Withheld, Done:
 752		return forged
 753	}
 754	return false
 755}
 756
 757// voidSweep voids a sweep entry as forged (R11): its amount leaves the sweep queue's balance and what is owed, so
 758// a forged entry no longer holds the identity open; nothing was paid, so nothing is lost. The balance is the sum of
 759// the live entries (the queue is settled by id, R19), so an entry above it is a broken invariant, refused.
 760func (s *State) voidSweep(e *SweepEntry) {
 761	if e.Voided {
 762		panic("hlroute: " + e.ID + " is voided already")
 763	}
 764	if e.Amount > s.Sweep {
 765		panic("hlroute: " + e.ID + "'s " + s.Amount(e.Amount) + " exceeds the sweep queue's balance of " + s.Amount(s.Sweep))
 766	}
 767	e.Voided = true
 768	s.Sweep -= e.Amount
 769	s.Owed -= e.Amount
 770}
 771
 772// restoreSweep is the inverse of voidSweep.
 773func (s *State) restoreSweep(e *SweepEntry) {
 774	if !e.Voided {
 775		panic("hlroute: " + e.ID + " is not voided")
 776	}
 777	e.Voided = false
 778	s.Sweep += e.Amount
 779	s.Owed += e.Amount
 780}
 781
 782// sweepVoidable is whether a void can take a sweep entry as it stands: as forged only (an entry is withheld by
 783// nature — owed, paying nobody — so a void as withheld has nothing to do), and once.
 784func sweepVoidable(e *SweepEntry, forged bool) bool { return forged && !e.Voided }
 785
 786// mustFitRange counts the credits a range covers now, voidable or not, and refuses above MaxRangeClaims (R28): the
 787// range was under the cap when the proposal was made (DraftVoid), but a predecessor's pages bring claims at their
 788// old heights — inside a range closed before they arrived — so the walk that voids it, at once or after the delay,
 789// is counted again before it voids anything; one above the cap holds the decision for GovDAO's cancel, who splits
 790// the range by heights.
 791func (s *State) mustFitRange(r Range) {
 792	n := 0
 793	s.eachInRange(r, func(*Claim) bool {
 794		n++
 795		return n > MaxRangeClaims
 796	}, func(*SweepEntry) bool {
 797		n++
 798		return n > MaxRangeClaims
 799	})
 800	if n > MaxRangeClaims {
 801		panic("hlroute: more than " + strconv.Itoa(MaxRangeClaims) + " credits in " + r.String() + " now (a predecessor's pages brought more since the proposal was made): cancel it and split the range by heights")
 802	}
 803}
 804
 805// eachInRange walks the credits a range covers — the claims and the sweep entries, by height then id — until a
 806// callback returns true. A Since range still open (To 0) runs to the newest credit and covers the verifier's
 807// credits only.
 808func (s *State) eachInRange(r Range, fn func(c *Claim) bool, sfn func(e *SweepEntry) bool) {
 809	end := "~"
 810	if r.To != 0 {
 811		end = heightKey(r.To+1, "")
 812	}
 813	s.credits.Iterate(heightKey(r.From, ""), end, func(_ string, v any) bool {
 814		id := v.(string)
 815		if cv := s.claims.Get(id); cv != nil {
 816			c := cv.(*Claim)
 817			if r.Since && c.By != BySigners {
 818				return false
 819			}
 820			return fn(c)
 821		}
 822		e := s.sweeps.Get(id).(*SweepEntry)
 823		if r.Since && e.By != BySigners {
 824			return false
 825		}
 826		return sfn(e)
 827	})
 828}
 829
 830// restore is the full inverse of either void; a claim with a remainder goes back into its queue at its place
 831// (R12). now and epoch make the recipient's queue if it has none (a successor restoring an imported claim).
 832func (s *State) restore(c *Claim, now, epoch int64) {
 833	rem := c.Remaining()
 834	switch c.State {
 835	case Withheld:
 836		s.Voided -= rem
 837		s.Owed += rem
 838	case Forged:
 839		s.Lost -= c.Paid
 840		s.Owed += rem
 841	default:
 842		panic("hlroute: " + c.ID + " is not voided")
 843	}
 844	if rem == 0 {
 845		c.State = Done
 846		return
 847	}
 848	c.State = Open
 849	s.nOpen++
 850	q := s.queue(c.To, now, epoch)
 851	q.items.Set(seqKey(c.Seq), c.ID)
 852	s.waiting.Set(string(c.To), true)
 853}
 854
 855// ------------------------------------------------------------- drafts --
 856
 857// Draft is a builder's checked parameters: what a proposal carries, decided and applied when it executes.
 858type Draft struct {
 859	Kind   string
 860	Text   string
 861	Params any
 862}
 863
 864type (
 865	capFloorParams struct{ n int64 }
 866	maximumParams  struct{ n int64 }
 867	feeParams      struct{ fee, minimum int64 }
 868	feeFloorParams struct{ n int64 }
 869	routerParams   struct{ router [32]byte }
 870	// VoidParams are a void's: the ids left to void (the at-once ones are gone from the list once applied) or,
 871	// Ranged, the range of credits to void (closed at the height the proposal executed), and the unpause option's
 872	// parameters — the functions whose flags to clear and the seats to vacate — applied in the executing
 873	// transaction.
 874	VoidParams struct {
 875		IDs         []string
 876		Ranged      bool
 877		Range       Range
 878		Forged      bool
 879		UnpauseFns  []string
 880		RemoveSeats []int
 881		RemoveAddrs []address // the seats' holders when the proposal was made: a seat whose occupant changed is skipped (R17)
 882	}
 883	restoreParams struct{ ids []string }
 884	creditParams  struct {
 885		id     string
 886		to     address
 887		amount int64
 888	}
 889	sweepParams struct {
 890		ids []string // the entries to pay, by id (R19): their sum goes to `to` and they are dropped
 891		to  address
 892	}
 893	refundParams      struct{ ids []string }
 894	applyRefundParams struct{ id int64 } // the queued refund decision GovDAO applies at once (AT2)
 895	writeOffParams    struct{ ids []string }
 896	migrateParams     struct{ to string }
 897	cancelParams      struct{ id int64 }
 898)
 899
 900// DraftCapFloor: the day cap's floor; never below the compiled launch floor, and its quarter never below the fee
 901// (a recipient's meter holds a quarter of the cap at most and the fee is a claim's first installment, whole: a
 902// quarter under the fee would stall every new recipient's first payment, with no flag and no alarm).
 903func (s *State) DraftCapFloor(n int64) Draft {
 904	s.mustNotBeMigrated()
 905	if n < s.LaunchCapFloor {
 906		panic("hlroute: the cap floor never goes below the launch floor of " + s.Amount(s.LaunchCapFloor))
 907	}
 908	if n/4 < s.Fee {
 909		panic("hlroute: the cap floor's quarter never goes below the fee of " + s.Amount(s.Fee) + " (a recipient's meter must hold the fee, the first installment)")
 910	}
 911	if n == s.CapFloor {
 912		panic("hlroute: the cap floor is " + s.Amount(n) + " already")
 913	}
 914	return Draft{KindCapFloor, "set cap floor to " + s.Amount(n) + " (now " + s.Amount(s.CapFloor) + ")", capFloorParams{n}}
 915}
 916
 917// DraftMaximum: the per-message maximum; never below the minimum. The description reminds GovDAO of the order a
 918// raise takes (R15): the pausable ISM's per-message maximum on Solana (r/hlgov's kind 12) is raised first, since a
 919// send above it leaves gno.land and is refused at delivery for good — stranded until a refund by id.
 920func (s *State) DraftMaximum(n int64) Draft {
 921	s.mustNotBeMigrated()
 922	if n < s.Minimum || n < s.LaunchMinimum {
 923		panic("hlroute: the maximum never goes below the minimum of " + s.Amount(s.Minimum))
 924	}
 925	if n == s.Maximum {
 926		panic("hlroute: the maximum is " + s.Amount(n) + " already")
 927	}
 928	return Draft{KindMaximum, "set maximum to " + s.Amount(n) + " (now " + s.Amount(s.Maximum) + "); raise the Solana limit (kind 12) first; a send above the Solana maximum strands", maximumParams{n}}
 929}
 930
 931// DraftFee: the fee and the minimum together; the fee not below the fee floor and not above a quarter of the cap
 932// floor (the most a recipient's meter can hold, and the fee is the first installment, whole), the minimum positive
 933// and not above the maximum.
 934func (s *State) DraftFee(fee, minimum int64) Draft {
 935	s.mustNotBeMigrated()
 936	if fee < s.FeeFloor {
 937		panic("hlroute: the fee never goes below the fee floor of " + s.Amount(s.FeeFloor))
 938	}
 939	if fee > s.CapFloor/4 {
 940		panic("hlroute: the fee never goes above a quarter of the cap floor (" + s.Amount(s.CapFloor/4) + "): a recipient's meter holds a quarter at most and the fee is the first installment, whole")
 941	}
 942	if minimum <= 0 || minimum > s.Maximum {
 943		panic("hlroute: the minimum must be positive and not above the maximum of " + s.Amount(s.Maximum))
 944	}
 945	if fee == s.Fee && minimum == s.Minimum {
 946		panic("hlroute: nothing changes")
 947	}
 948	return Draft{KindFee, "set fee to " + s.Amount(fee) + " and minimum to " + s.Amount(minimum) + " (now " + s.Amount(s.Fee) + " and " + s.Amount(s.Minimum) + ")", feeParams{fee, minimum}}
 949}
 950
 951// DraftFeeFloor: the fee floor; not above the fee, not negative.
 952func (s *State) DraftFeeFloor(n int64) Draft {
 953	s.mustNotBeMigrated()
 954	if n < 0 || n > s.Fee {
 955		panic("hlroute: the fee floor must be between 0 and the fee of " + s.Amount(s.Fee))
 956	}
 957	if n == s.FeeFloor {
 958		panic("hlroute: the fee floor is " + s.Amount(n) + " already")
 959	}
 960	return Draft{KindFeeFloor, "set fee floor to " + s.Amount(n) + " (now " + s.Amount(s.FeeFloor) + ")", feeFloorParams{n}}
 961}
 962
 963// DraftRouter: the remote router; the old one's messages stay accepted 7 days after the swap applies.
 964func (s *State) DraftRouter(router [32]byte, text string) Draft {
 965	s.mustNotBeMigrated()
 966	if router == ([32]byte{}) {
 967		panic("hlroute: the router is all zeros")
 968	}
 969	if router == s.Router {
 970		panic("hlroute: that is the router already")
 971	}
 972	return Draft{KindRouter, "set router to " + text, routerParams{router}}
 973}
 974
 975// ParseFns reads a comma-separated list of the three functions, "" for none; no repeats.
 976func ParseFns(fns string) []string {
 977	var out []string
 978	for _, p := range strings.Split(fns, ",") {
 979		fn := strings.TrimSpace(p)
 980		if fn == "" {
 981			continue
 982		}
 983		if fn != FnSends && fn != FnCredits && fn != FnPayouts {
 984			panic("hlroute: the functions are sends, credits and payouts, not " + fn)
 985		}
 986		for _, x := range out {
 987			if x == fn {
 988				panic("hlroute: " + fn + " named twice")
 989			}
 990		}
 991		out = append(out, fn)
 992	}
 993	return out
 994}
 995
 996// ParseSeats reads a comma-separated list of seat numbers (1–6), "" for none; no repeats, and two guardian seats
 997// (1–4) at most — §5.3's cap on guardian changes per proposal, the same as hlmailbox's rotate seats, so no void
 998// vacates the four guardians in one stroke.
 999func ParseSeats(seats string) []int {
1000	var out []int
1001	g := 0
1002	for _, p := range strings.Split(seats, ",") {
1003		p = strings.TrimSpace(p)
1004		if p == "" {
1005			continue
1006		}
1007		n, err := strconv.Atoi(p)
1008		if err != nil || n < 1 || n > 6 {
1009			panic("hlroute: the seat must be 1 to 6, not " + p)
1010		}
1011		for _, x := range out {
1012			if x == n {
1013				panic("hlroute: seat " + p + " named twice")
1014			}
1015		}
1016		if n <= guardianSeats {
1017			g++
1018		}
1019		out = append(out, n)
1020	}
1021	if g > maxGuardianSeats {
1022		panic("hlroute: two guardian seats at most per proposal")
1023	}
1024	return out
1025}
1026
1027// seatsText writes seat removals as seat:address pairs.
1028func seatsText(seats []int, addrs []address) string {
1029	parts := make([]string, len(seats))
1030	for i, n := range seats {
1031		parts[i] = strconv.Itoa(n) + ":" + string(addrs[i])
1032	}
1033	return strings.Join(parts, ",")
1034}
1035
1036// DraftVoid: void credits by id — a list of up to MaxIDs ids, claims or sweep entries (an entry as forged only),
1037// or a Range ("heights:<from>-<to>", every credit at those heights; "since:<from>", every credit the verifier made
1038// at or after it), closed at height — the block height the proposal is made at (R14), so what it covers never
1039// grows before it executes — and covering at most MaxRangeClaims credits, voidable or not (R23: the walk at
1040// execution visits them all), else GovDAO splits it by heights — as withheld or forged, with the unpause option —
1041// the functions whose flags to clear (comma
1042// separated, "" for none) and the seats to vacate (comma separated, "" for none; seatOf gives each seat's holder
1043// now, recorded so a seat whose occupant changes before the proposal executes is skipped, R17), applied in the
1044// same closure as the void so the void and the reopening never execute in the wrong order (§5.3). A range must
1045// cover a credit the void can take when the proposal is made; one in it the void cannot take as it stands
1046// (forged already; withheld, paid or a sweep entry, for a void as withheld) is skipped, not refused.
1047func (s *State) DraftVoid(ids string, forged bool, unpauseFns, removeSeats string, height int64, seatOf func(seat int) address) Draft {
1048	s.mustNotBeMigrated()
1049	fns, seats := ParseFns(unpauseFns), ParseSeats(removeSeats)
1050	addrs := make([]address, len(seats))
1051	for i, seat := range seats {
1052		if addrs[i] = seatOf(seat); addrs[i] == "" {
1053			panic("hlroute: seat " + strconv.Itoa(seat) + " is empty")
1054		}
1055	}
1056	how := "withheld"
1057	if forged {
1058		how = "forged"
1059	}
1060	p := VoidParams{Forged: forged, UnpauseFns: fns, RemoveSeats: seats, RemoveAddrs: addrs}
1061	var text string
1062	if r, ok := ParseRange(ids); ok {
1063		s.mustNotBeImporting("a range void")
1064		r = r.closed(height)
1065		// the cap counts every credit the scan visits, voidable or not (R23): the walk at execution covers them
1066		// all, so a range over a flood voided already costs as much as the flood; the decision's note names the
1067		// last height it voided, where the next range starts
1068		n, v := 0, 0
1069		s.eachInRange(r, func(c *Claim) bool {
1070			n++
1071			if voidable(c, forged) {
1072				v++
1073			}
1074			return n > MaxRangeClaims
1075		}, func(e *SweepEntry) bool {
1076			n++
1077			if sweepVoidable(e, forged) {
1078				v++
1079			}
1080			return n > MaxRangeClaims
1081		})
1082		if n > MaxRangeClaims {
1083			panic("hlroute: more than " + strconv.Itoa(MaxRangeClaims) + " credits in " + r.String() + ", voidable or not: split the range by heights")
1084		}
1085		if v == 0 {
1086			panic("hlroute: no claim to void in " + r.String())
1087		}
1088		p.Ranged, p.Range = true, r
1089		text = "void as " + how + ": " + r.String()
1090	} else {
1091		list := ParseIDs(ids)
1092		for _, id := range list {
1093			if e := s.SweepOf(id); e != nil {
1094				switch {
1095				case !forged:
1096					panic("hlroute: " + id + " is a sweep entry: voided as forged only")
1097				case e.Voided:
1098					panic("hlroute: " + id + " is voided already")
1099				}
1100				continue
1101			}
1102			c := s.ClaimOf(id)
1103			if c == nil {
1104				panic("hlroute: no claim or sweep entry " + id)
1105			}
1106			switch {
1107			case c.State == Forged:
1108				panic("hlroute: " + id + " is forged already")
1109			case c.State == Withheld && !forged:
1110				panic("hlroute: " + id + " is withheld already")
1111			case c.State == Done && !forged:
1112				panic("hlroute: " + id + " is paid in full; only a void as forged books it")
1113			}
1114		}
1115		p.IDs = list
1116		text = "void as " + how + ": " + strings.Join(list, ",")
1117	}
1118	if len(fns) > 0 {
1119		text += ", and unpause " + strings.Join(fns, ",")
1120	}
1121	if len(seats) > 0 {
1122		text += ", removing seats " + seatsText(seats, addrs)
1123	}
1124	return Draft{KindVoid, text, p}
1125}
1126
1127// DraftRestore: restore voided claims, voided sweep entries, or written-off sends, by id — a write-off's inverse
1128// (R38): a mistaken write-off (a record two providers showed differently read as delivered, a neighbouring id in
1129// a list) books lost for the deployment's life, the watch's "mistaken void" alarm stands as long, and a later
1130// forged credit up to that amount moves the alarm's figure and trips nothing; the restore clears the mark and
1131// takes the amount back out of lost, after 7 days like a forged claim's restore, since it re-opens the identity.
1132func (s *State) DraftRestore(ids string) Draft {
1133	s.mustNotBeMigrated()
1134	list := ParseIDs(ids)
1135	for _, id := range list {
1136		if e := s.SweepOf(id); e != nil {
1137			if !e.Voided {
1138				panic("hlroute: " + id + " is not voided")
1139			}
1140			continue
1141		}
1142		if sd := s.SendOf(id); sd != nil {
1143			if sd.WrittenOff == 0 {
1144				panic("hlroute: " + id + " is a send that is not written off: nothing to restore")
1145			}
1146			continue
1147		}
1148		c := s.ClaimOf(id)
1149		if c == nil || (c.State != Withheld && c.State != Forged) {
1150			panic("hlroute: " + id + " is not voided")
1151		}
1152	}
1153	return Draft{KindRestore, "restore: " + strings.Join(list, ","), restoreParams{list}}
1154}
1155
1156// DraftCredit: credit a claim by id, by hand (§5.3): refused for an id already done unless its claim stands
1157// voided, or the id is a sweep entry's (R11) — the credit then replaces the entry with the claim it names, the
1158// cure for a burn to a realm or non-gno recipient, or a diverted credit. The amount is at most the lock (R26,
1159// R7's rule for the verifier's credits: a credit above the lock cannot be honest, and a claim above it would ask
1160// Claim for coins the lock does not hold); the closure asks again.
1161func (s *State) DraftCredit(id string, to address, amount int64, delivered bool) Draft {
1162	s.mustNotBeMigrated()
1163	id = mustID(id)
1164	if amount <= 0 {
1165		panic("hlroute: the amount must be positive")
1166	}
1167	if amount > s.Lock {
1168		panic("hlroute: the amount " + s.Amount(amount) + " is above the lock of " + s.Amount(s.Lock) + " (a credit above the lock cannot be honest)")
1169	}
1170	text := "credit " + id + " to " + string(to) + " for " + s.Amount(amount)
1171	if c := s.ClaimOf(id); c != nil {
1172		if c.State != Withheld && c.State != Forged {
1173			panic("hlroute: " + id + " is credited already and not voided")
1174		}
1175	} else if e := s.SweepOf(id); e != nil {
1176		text += " (replacing the sweep entry of " + s.Amount(e.Amount) + ")"
1177	} else if delivered {
1178		panic("hlroute: " + id + " is done already")
1179	}
1180	return Draft{KindCredit, text, creditParams{id, to, amount}}
1181}
1182
1183// DraftSweep: pay sweep entries, named by id (comma separated, up to MaxIDs), to an address — their sum, at most
1184// the lock (R11: a forged entry above the lock is voided, never paid out of coins the lock does not hold). The
1185// queue is settled by id (R19): a paid entry is dropped with its index, so it never stands to be credited by id or
1186// voided a second time, and the counters never go negative. A voided entry is restored first; a claim's id is no
1187// entry's; an entry voided or gone by the time the payment applies is skipped and the note says so.
1188func (s *State) DraftSweep(ids string, to address) Draft {
1189	s.mustNotBeMigrated()
1190	list := ParseIDs(ids)
1191	sum := int64(0)
1192	for _, id := range list {
1193		e := s.SweepOf(id)
1194		switch {
1195		case e == nil && s.ClaimOf(id) != nil:
1196			panic("hlroute: " + id + " is a claim, not a sweep entry")
1197		case e == nil:
1198			panic("hlroute: no sweep entry " + id)
1199		case e.Voided:
1200			panic("hlroute: " + id + " is voided; restore it first")
1201		}
1202		sum += e.Amount
1203	}
1204	if sum > s.Lock {
1205		panic("hlroute: the entries sum to " + s.Amount(sum) + ", above the lock's " + s.Amount(s.Lock))
1206	}
1207	return Draft{KindSweep, "pay " + s.Amount(sum) + " of the sweep queue to " + string(to) + ": " + strings.Join(list, ","), sweepParams{list, to}}
1208}
1209
1210// DraftRefund: refund sends by id to their senders — a successor's duty, over the sends it imported from its
1211// predecessor (AQ2, §5.3): a refunded send stays deliverable on Solana, so only a send addressed to a router the
1212// token no longer enrolls is ever refunded, after kind 6 enrolled this realm — which the realm cannot see, so it
1213// reads the note r/hlgov leaves with the mailbox when it dispatches a kind 6 for this route (Env.Enrolled) and
1214// refuses until the note is 14 days old (AR1, R37, mustBeEnrolled; asked again when the refund applies): a refund
1215// that lands before kind 6 lets one stranger's Reemit and delivery of the refunded send mint it twice, with no
1216// realm able to refuse the message. Necessary, not sufficient: the member checks hlwatch first, which reads the
1217// token's enrolled router on Solana and the kind 6's state there, alarms a refund proposal whose successor the
1218// token has not enrolled and prints DELIVERED beside any id Solana processed — and the apply, after the 7 days,
1219// is GovDAO's own at-once decision (DraftApplyRefund, AT2), never anyone's Execute, so that check stands between
1220// the delay and the coins. A live route's own send, stranded or not, is never refunded: it waits for a kind 12
1221// (§5.4). The sum is at most the lock (R30, the sweep payment's bound: a forgery paid before its void can leave
1222// the lock short of the sends it imported), checked again when it applies.
1223func (s *State) DraftRefund(ids string, now int64, env Env) Draft {
1224	s.mustNotBeMigrated()
1225	s.mustBeEnrolled(now, env)
1226	list := ParseIDs(ids)
1227	sum := int64(0)
1228	for _, id := range list {
1229		sd := s.SendOf(id)
1230		switch {
1231		case sd == nil || sd.Refunded != 0:
1232			panic("hlroute: " + id + " is not a send to refund")
1233		case !sd.Imported:
1234			panic("hlroute: " + id + " is this route's own send: a stranded send waits for a kind 12 on Solana, never a refund (a refund is a successor's, over the sends it imported)")
1235		}
1236		sum += sd.Amount
1237	}
1238	if sum > s.Lock {
1239		panic("hlroute: the sends sum to " + s.Amount(sum) + ", above the lock's " + s.Amount(s.Lock))
1240	}
1241	return Draft{KindRefund, "refund " + s.Amount(sum) + " of sends imported from the predecessor (14 days after kind 6's dispatch enrolled this realm) to their senders: " + strings.Join(list, ","), refundParams{list}}
1242}
1243
1244// EnrolmentWait is how long a refund waits after r/hlgov's dispatch of the kind 6 enrolling this route (R37, F27):
1245// two of the 7-day delays — a week for the delivery to Solana and a week for Solana's own clock, since the admin
1246// program executes a kind 6 7 days after it is recorded there, so a week from the dispatch always ended before
1247// the enrolment could take effect. A hold defers Solana further, which the realm cannot see: hlwatch and the
1248// relayer read the token's enrolled router there.
1249const EnrolmentWait = 2 * decisions.Delay
1250
1251// mustBeEnrolled refuses a refund until kind 6 has enrolled this route on Solana for 14 days (EnrolmentWait), by
1252// the note r/hlgov leaves with the mailbox when it dispatches one (AR1, R37) — a cancelled kind 6 clears the note
1253// (hlmailbox.ClearEnrolled, from r/hlgov's cancel), so the wait restarts with a re-proposal. The latest note
1254// counts — a later kind 6 for the route restarts the wait; a refund is irreversible and two weeks are cheap.
1255func (s *State) mustBeEnrolled(now int64, env Env) {
1256	h, at := env.Enrolled()
1257	if h == 0 {
1258		panic("hlroute: no kind 6 has enrolled this route on Solana yet (none dispatched from r/hlgov): a refund waits 14 days after the enrolment, since a refunded send stays deliverable there until the predecessor's router is replaced")
1259	}
1260	if now < at+EnrolmentWait {
1261		panic("hlroute: kind 6 enrolling this route was dispatched at height " + itoa(h) + ", " + itoa(now-at) + " s ago: a refund waits 14 days after it (until " + itoa(at+EnrolmentWait) + "): a week for the delivery and a week for Solana's own clock, which executes a kind 6 7 days after recording it; a hold defers it further, which hlwatch reads there")
1262	}
1263}
1264
1265// DraftApplyRefund: GovDAO's own at-once apply of a queued refund decision whose 7 days have passed (AT2). A refund
1266// is the one decision whose gate the realm cannot read — Solana's: the token's router there must be this realm and
1267// the kind 6 executed, which hlwatch reads and alarms NOT ENROLLED ON SOLANA while it is not — and anyone's
1268// Execute on the note alone let a refunded sender, his id in the proposal's description, apply his refund the day
1269// it matured while a kind 6 deferred by one flag left the predecessor the router, then deliver his send there
1270// with the published tool: minted twice, the lock short for good. So Execute refuses the kind, and a refund is
1271// applied by this builder alone — at once, like an unpause — the member having read hlwatch's gate before the
1272// vote. The apply is the delay's own (apply's refund branch: mustBeEnrolled's 14-day note asked again, the sum at
1273// most the lock, a send refunded since skipped), and the realm asks its payouts flag of it as of a sweep payment.
1274// Refused — when the proposal is made and again when it executes, the proposal surviving for a retry — for a
1275// decision that is no refund, one not pending (applied, cancelled), one whose delay has not passed, and until the
1276// note is 14 days old. The description names the refund decision and carries its text, the ids included.
1277func (s *State) DraftApplyRefund(id, now int64, env Env) Draft {
1278	s.mustNotBeMigrated()
1279	d := s.refundToApply(id, now, env)
1280	return Draft{KindApplyRefund, "apply refund decision " + itoa(id) + " at once (" + d.Text + "), after hlwatch's check that the token's router on Solana is this realm", applyRefundParams{id}}
1281}
1282
1283// refundToApply is the queued refund decision an apply names, as it stands now: a refund, pending, its delay
1284// passed, the enrolment note 14 days old (mustBeEnrolled; asked once more by the refund's own apply).
1285func (s *State) refundToApply(id, now int64, env Env) *decisions.Decision {
1286	d := s.Ledger.Get(id)
1287	if d == nil {
1288		panic("hlroute: no decision " + itoa(id))
1289	}
1290	if d.Kind != KindRefund {
1291		panic("hlroute: decision " + itoa(id) + " is of kind " + d.Kind + ", not refund: an apply refund names a queued refund decision")
1292	}
1293	if !d.Pending() {
1294		panic("hlroute: refund decision " + itoa(id) + " is " + d.State() + ", not pending")
1295	}
1296	if now < d.Due {
1297		panic("hlroute: refund decision " + itoa(id) + " is queued until " + itoa(d.Due) + " (" + itoa(d.Due-now) + " s more): its 7 days have not passed")
1298	}
1299	s.mustBeEnrolled(now, env)
1300	return d
1301}
1302
1303// DraftWriteOff: book refunded sends, by id (comma separated, up to MaxIDs), as lost (AR1) — a refunded send
1304// delivered on Solana after all, by a stranger's Reemit before kind 6 replaced the predecessor's router or in the
1305// day a hold's lift leaves, minted a second time against nothing, and no builder but this one closes the
1306// identity: lock = sentTotal − paidTotal = supply + owed + voided − lost, the amount lost. Only a refunded send,
1307// once; the realm cannot see Solana, so the member reads hlwatch's DELIVERED line for each id first and the
1308// description says so. After 7 days; an id written off by another decision since is skipped and counted.
1309func (s *State) DraftWriteOff(ids string) Draft {
1310	s.mustNotBeMigrated()
1311	list := ParseIDs(ids)
1312	sum := int64(0)
1313	for _, id := range list {
1314		sd := s.SendOf(id)
1315		switch {
1316		case sd == nil || sd.Refunded == 0:
1317			panic("hlroute: " + id + " is not a refunded send: only a send delivered on Solana after its refund is written off")
1318		case sd.WrittenOff != 0:
1319			panic("hlroute: " + id + " is written off already")
1320		}
1321		sum += sd.Amount
1322	}
1323	return Draft{KindWriteOff, "write off " + s.Amount(sum) + " of refunded sends delivered on Solana after their refund (hlwatch's DELIVERED line names the id): " + strings.Join(list, ","), writeOffParams{list}}
1324}
1325
1326// DraftMigrate: the lock, the claims and the send records go to a successor realm at pkgpath — a deployed
1327// realm's, never a sub-identity's (`host#subpath`; the mailbox refuses such a route too), never a route the
1328// mailbox registers already (R31: the mailbox would refuse it when the migration applies, a week on, the vote
1329// wasted), and one that announced itself to the mailbox from its own code for this route (Env.AnnouncedFor: the
1330// symbol it named, "" if it never announced itself, R25 and AQ5), so the lock never goes to a path with no realm
1331// behind it — a typo, or an undeployed path whose namespace owner could deploy anything there and pull it — nor
1332// to the other token's successor by a slip. A route that succeeds another migrates on only once its own import
1333// has ended (R28, R31): before page 0 nothing has moved, and its MigratedTo would make its Import refuse, the
1334// predecessor's Pull revert for ever and the lock sit on the first predecessor, which refuses everything, with no
1335// decision that reaches it. The same checks are made again when the migration applies (mustMigrate).
1336func (s *State) DraftMigrate(to, self string, env Env) Draft {
1337	s.mustAllowMigrate(to)
1338	if !strings.HasPrefix(to, "gno.land/r/") || strings.Contains(to, "#") || to == self {
1339		panic("hlroute: the successor is another realm under gno.land/r/")
1340	}
1341	s.mustMigrate(to, env)
1342	return Draft{KindMigrate, "migrate to " + to + " (announced as the " + s.Symbol + " route's successor)", migrateParams{to}}
1343}
1344
1345// mustAllowMigrate is a migrated route's one exception to "every function refuses" (AR2): before page 0 nothing has
1346// moved — the lock and the counters go with the first page — so while the successor named has pulled nothing a
1347// second migrate re-points the migration to another successor (7 days, like the first; the mailbox replaces the
1348// registration), the cure for a successor that never pulls or whose driver refuses every caller, which would
1349// otherwise hold the lock on a predecessor that refuses everything, with no decision that reaches it. The same
1350// successor is refused (nothing to re-point); once page 0 is pulled the migration is under way and no decision
1351// re-points it. Asked when the proposal is made, when it executes and when the migration would apply.
1352func (s *State) mustAllowMigrate(to string) {
1353	if !s.Migrated() {
1354		return
1355	}
1356	if s.exported > 0 {
1357		panic("hlroute: migrated to " + s.MigratedTo + ": the successor pulled " + strconv.Itoa(s.exported) + " pages already (the lock moved with page 0); a migration under way is never re-pointed")
1358	}
1359	if to == s.MigratedTo {
1360		panic("hlroute: migrated to " + to + " already (nothing pulled yet): a re-point names another successor")
1361	}
1362}
1363
1364// mustMigrate is every check a migrate to `to` must pass against the live state, when the proposal is made and
1365// again when the migration would apply (R31): this route's own import, if it succeeds another, ended — page 0 in
1366// (nothing pulled is "page 0 is not in yet") and the last page too (R28) — the path no route the mailbox registers
1367// already, and announced for this route (R25, AQ5). A failure when the migration would apply leaves the decision
1368// pending for a retry or a cancel, never spent.
1369func (s *State) mustMigrate(to string, env Env) {
1370	s.mustNotBeImporting("a migrate")
1371	if pred := env.Predecessor(); pred != "" && s.imported == 0 {
1372		panic("hlroute: page 0 is not in yet: this route succeeds " + pred + " and has pulled nothing; a migrate waits for the last page of its own import")
1373	}
1374	if env.IsRoute(to) {
1375		panic("hlroute: the successor " + to + " is a route already")
1376	}
1377	s.mustBeAnnouncedFor(to, env.AnnouncedFor(to))
1378}
1379
1380// mustBeAnnouncedFor refuses a successor that never announced itself, or announced itself for the other route.
1381func (s *State) mustBeAnnouncedFor(to, announcedFor string) {
1382	switch announcedFor {
1383	case "":
1384		panic("hlroute: the successor " + to + " has not announced itself to the mailbox (hlmailbox.AnnounceSuccessor, from its own code)")
1385	case s.Symbol:
1386	default:
1387		panic("hlroute: the successor " + to + " announced itself as the " + announcedFor + " route's successor, not this " + s.Symbol + " route's")
1388	}
1389}
1390
1391// DraftCancel: cancel a pending decision of this route.
1392func (s *State) DraftCancel(id int64) Draft {
1393	d := s.Ledger.Get(id)
1394	if d == nil || !d.Pending() {
1395		panic("hlroute: decision " + itoa(id) + " is not pending")
1396	}
1397	return Draft{KindCancel, "cancel decision " + itoa(id) + " (" + d.Text + ")", cancelParams{id}}
1398}
1399
1400// ------------------------------------------------------------ deciding --
1401
1402// Decide is what a builder's closure does when its proposal executes: it records the decision and settles the
1403// at-once test against the live state — a lowering, a cancel, a restore of a withheld claim and the void of ids a
1404// guardian's standing payouts flag names apply now; everything else waits 7 days for Execute. A migrate is
1405// checked again here (R31, AR2: the state may have moved since the proposal was made) and a cancel, or a migrate
1406// that re-points a migration nothing was pulled from, is taken on a migrated route.
1407func (s *State) Decide(made, now int64, d Draft, env Env) *decisions.Decision {
1408	if d.Kind != KindCancel && d.Kind != KindMigrate {
1409		s.mustNotBeMigrated()
1410	}
1411	record := func(due int64, params any) *decisions.Decision {
1412		return s.Ledger.Record(d.Kind, d.Text, params, made, now, due)
1413	}
1414	switch p := d.Params.(type) {
1415	case capFloorParams:
1416		if p.n <= s.CapFloor {
1417			dec := record(now, p)
1418			s.apply(dec, now, env)
1419			s.Ledger.Apply(dec, now, "at once: a lowering")
1420			return dec
1421		}
1422		return s.queued(record(now+decisions.Delay, p))
1423	case maximumParams:
1424		if p.n <= s.Maximum {
1425			dec := record(now, p)
1426			s.apply(dec, now, env)
1427			s.Ledger.Apply(dec, now, "at once: a lowering")
1428			return dec
1429		}
1430		return s.queued(record(now+decisions.Delay, p))
1431	case feeParams:
1432		if p.fee <= s.Fee && p.minimum <= s.Minimum {
1433			dec := record(now, p)
1434			s.apply(dec, now, env)
1435			s.Ledger.Apply(dec, now, "at once: a lowering")
1436			return dec
1437		}
1438		return s.queued(record(now+decisions.Delay, p))
1439	case feeFloorParams:
1440		if p.n <= s.FeeFloor {
1441			dec := record(now, p)
1442			s.apply(dec, now, env)
1443			s.Ledger.Apply(dec, now, "at once: a lowering")
1444			return dec
1445		}
1446		return s.queued(record(now+decisions.Delay, p))
1447	case VoidParams:
1448		// per credit, once, now: a claim or sweep entry some guardian's standing payouts flag names — by id, or by
1449		// a range covering its credit — is voided at once, the rest wait in the same decision (the list of those
1450		// left, or the range, closed when the proposal was made); a range skips a credit the void cannot take as
1451		// it stands
1452		var later []string
1453		n, skipped, queued := 0, 0, 0
1454		last := int64(0) // the last height voided at once (the walk is by height), named in the note (R23)
1455		rest := VoidParams{Forged: p.Forged}
1456		if p.Ranged {
1457			r := p.Range // closed at the height the proposal was made (R14)
1458			s.mustFitRange(r)
1459			s.eachInRange(r, func(c *Claim) bool {
1460				switch {
1461				case !voidable(c, p.Forged):
1462					skipped++
1463				case env.Flagged(c.ID, c.Height, c.By == BySigners):
1464					s.void(c, p.Forged)
1465					n++
1466					last = c.Height
1467				default:
1468					queued++
1469				}
1470				return false
1471			}, func(e *SweepEntry) bool {
1472				switch {
1473				case !sweepVoidable(e, p.Forged):
1474					skipped++
1475				case env.Flagged(e.ID, e.Height, e.By == BySigners):
1476					s.voidSweep(e)
1477					n++
1478					last = e.Height
1479				default:
1480					queued++
1481				}
1482				return false
1483			})
1484			rest.Ranged, rest.Range = true, r
1485		} else {
1486			// an id the void cannot take as it stands — voided by another decision since the proposal was made,
1487			// paid in full under a void as withheld, or gone — is skipped and counted, the range form's rule, never
1488			// a panic that blocks the whole proposal with its unpause option and seat removals (R22)
1489			for _, id := range p.IDs {
1490				if e := s.SweepOf(id); e != nil {
1491					switch {
1492					case !sweepVoidable(e, p.Forged):
1493						skipped++
1494					case env.Flagged(id, e.Height, e.By == BySigners):
1495						s.voidSweep(e)
1496						n++
1497					default:
1498						later = append(later, id)
1499					}
1500					continue
1501				}
1502				c := s.ClaimOf(id)
1503				switch {
1504				case c == nil || !voidable(c, p.Forged):
1505					skipped++
1506				case env.Flagged(id, c.Height, c.By == BySigners):
1507					s.void(c, p.Forged)
1508					n++
1509				default:
1510					later = append(later, id)
1511				}
1512			}
1513			rest.IDs, queued = later, len(later)
1514		}
1515		for _, fn := range p.UnpauseFns {
1516			env.ClearFlags(fn)
1517		}
1518		// a seat is vacated only while the key the proposal named still holds it (R17): a replacement that landed
1519		// in between stays, and the note says so
1520		var vacated, kept []string
1521		for i, seat := range p.RemoveSeats {
1522			if env.RemoveSeat(seat, p.RemoveAddrs[i]) {
1523				vacated = append(vacated, strconv.Itoa(seat))
1524			} else {
1525				kept = append(kept, strconv.Itoa(seat))
1526			}
1527		}
1528		note := itoa(int64(n)) + " voided at once (named by a guardian's payouts flag)"
1529		if p.Ranged {
1530			note += " of " + rest.Range.String()
1531		}
1532		if skipped > 0 {
1533			note += ", " + itoa(int64(skipped)) + " skipped (not voidable as it stands)"
1534		}
1535		if len(p.UnpauseFns) > 0 {
1536			note += ", unpaused " + strings.Join(p.UnpauseFns, ",")
1537		}
1538		if len(vacated) > 0 {
1539			note += ", seats " + strings.Join(vacated, ",") + " vacated"
1540		}
1541		if len(kept) > 0 {
1542			note += ", seats " + strings.Join(kept, ",") + " skipped (the occupant changed since the proposal was made)"
1543		}
1544		lastNote := ""
1545		if p.Ranged && n > 0 {
1546			lastNote = ", last voided at height " + itoa(last)
1547		}
1548		if queued == 0 {
1549			dec := record(now, VoidParams{Forged: p.Forged, UnpauseFns: p.UnpauseFns, RemoveSeats: p.RemoveSeats, RemoveAddrs: p.RemoveAddrs})
1550			s.Ledger.Apply(dec, now, "at once: "+note+lastNote)
1551			return dec
1552		}
1553		dec := record(now+decisions.Delay, rest)
1554		dec.Note = note + ", " + itoa(int64(queued)) + " queued 7 days" + lastNote
1555		return dec
1556	case restoreParams:
1557		// a withheld claim at once; a forged claim, a voided sweep entry and a written-off send (R38), after 7 days;
1558		// an id no longer voided (restored by another decision since the proposal was made) or gone is skipped and
1559		// counted (R22)
1560		var later []string
1561		n, skipped := 0, 0
1562		for _, id := range p.ids {
1563			if e := s.SweepOf(id); e != nil {
1564				if !e.Voided {
1565					skipped++
1566					continue
1567				}
1568				later = append(later, id)
1569				continue
1570			}
1571			if sd := s.SendOf(id); sd != nil {
1572				if sd.WrittenOff == 0 {
1573					skipped++
1574					continue
1575				}
1576				later = append(later, id)
1577				continue
1578			}
1579			c := s.ClaimOf(id)
1580			switch {
1581			case c == nil:
1582				skipped++
1583			case c.State == Withheld:
1584				s.restore(c, now, env.Epoch())
1585				n++
1586			case c.State == Forged:
1587				later = append(later, id)
1588			default:
1589				skipped++
1590			}
1591		}
1592		note := itoa(int64(n)) + " withheld restored at once"
1593		if skipped > 0 {
1594			note += ", " + itoa(int64(skipped)) + " skipped (not voided as it stands)"
1595		}
1596		if len(later) == 0 {
1597			dec := record(now, restoreParams{})
1598			s.Ledger.Apply(dec, now, "at once: "+note)
1599			return dec
1600		}
1601		dec := record(now+decisions.Delay, restoreParams{later})
1602		dec.Note = note + ", " + itoa(int64(len(later))) + " forged, or written off, queued 7 days"
1603		return dec
1604	case cancelParams:
1605		if _, err := s.Ledger.Cancel(p.id, now); err != nil {
1606			panic("hlroute: " + err.Error())
1607		}
1608		dec := record(now, p)
1609		s.Ledger.Apply(dec, now, "at once: cancelled")
1610		return dec
1611	case migrateParams:
1612		s.mustAllowMigrate(p.to)
1613		s.mustMigrate(p.to, env)
1614		return s.queued(record(now+decisions.Delay, p))
1615	case refundParams:
1616		// queued 7 days like a sweep payment, but applied by GovDAO's own at-once decision after (AT2:
1617		// DraftApplyRefund), never by anyone's Execute — the note says so
1618		dec := s.queued(record(now+decisions.Delay, p))
1619		dec.Note += ": applied by GovDAO's own apply refund decision after, never by Execute"
1620		return dec
1621	case applyRefundParams:
1622		// GovDAO's own at-once apply of a queued refund (AT2): the refund decision is asked again as it stands — a
1623		// refund, pending, its delay passed, the note 14 days old — and applied as the delay would have applied it,
1624		// before anything is recorded (a refusal is a panic that leaves no record, so the proposal survives a retry
1625		// until its deadline); then both decisions are noted, each naming the other
1626		rd := s.refundToApply(p.id, now, env)
1627		note := s.apply(rd, now, env)
1628		dec := record(now, p)
1629		s.Ledger.Apply(rd, now, "applied by GovDAO's apply refund decision "+itoa(dec.ID)+note)
1630		s.Ledger.Apply(dec, now, "at once: refund decision "+itoa(p.id)+" applied"+note)
1631		return dec
1632	case routerParams, creditParams, sweepParams, writeOffParams:
1633		return s.queued(record(now+decisions.Delay, d.Params))
1634	}
1635	panic("hlroute: unknown draft " + d.Kind)
1636}
1637
1638func (s *State) queued(dec *decisions.Decision) *decisions.Decision {
1639	dec.Note = "queued 7 days"
1640	return dec
1641}
1642
1643// Execute applies a queued decision whose delay has passed; on a migrated route only a migrate that re-points a
1644// migration nothing was pulled from (AR2: apply asks). Never a refund (AT2): its apply is GovDAO's own at-once
1645// decision (DraftApplyRefund), made after hlwatch's check that the token's router on Solana is this realm — a check
1646// the realm cannot make and anyone's Execute would skip.
1647func (s *State) Execute(now, id int64, env Env) *decisions.Decision {
1648	if d := s.Ledger.Get(id); d != nil && d.Kind == KindRefund {
1649		panic("hlroute: decision " + itoa(id) + " is a refund: a refund is applied by GovDAO's own at-once decision (apply refund) after hlwatch's Solana check, never by Execute")
1650	}
1651	dec, err := s.Ledger.Due(id, now)
1652	if err != nil {
1653		panic("hlroute: " + err.Error())
1654	}
1655	if dec.Kind != KindMigrate {
1656		s.mustNotBeMigrated()
1657	}
1658	note := s.apply(dec, now, env)
1659	s.Ledger.Apply(dec, now, "applied after the delay"+note)
1660	return dec
1661}
1662
1663// apply is a decision's effect, the same at once and after the delay; the checks a builder made are made again
1664// against the live state. It returns what the note should add ("" for nothing): what a list or a range skipped,
1665// what a sweep payment paid.
1666func (s *State) apply(dec *decisions.Decision, now int64, env Env) (note string) {
1667	switch p := dec.Params.(type) {
1668	case capFloorParams:
1669		if p.n < s.LaunchCapFloor || p.n/4 < s.Fee {
1670			panic("hlroute: below the launch floor, or a quarter below the fee, now")
1671		}
1672		s.CapFloor = p.n
1673	case maximumParams:
1674		if p.n < s.Minimum {
1675			panic("hlroute: the maximum never goes below the minimum")
1676		}
1677		s.Maximum = p.n
1678	case feeParams:
1679		if p.fee < s.FeeFloor || p.fee > s.CapFloor/4 || p.minimum <= 0 || p.minimum > s.Maximum {
1680			panic("hlroute: the fee is under the fee floor or above a quarter of the cap floor, or the minimum is out of range, now")
1681		}
1682		s.Fee, s.Minimum = p.fee, p.minimum
1683	case feeFloorParams:
1684		if p.n > s.Fee {
1685			panic("hlroute: the fee floor is above the fee now")
1686		}
1687		s.FeeFloor = p.n
1688	case routerParams:
1689		s.OldRouter, s.OldUntil = s.Router, now+decisions.Delay
1690		s.Router = p.router
1691	case VoidParams:
1692		// every credit the void can still take as it stands is voided; one it cannot — voided at once when the
1693		// proposal executed, or by another decision since, paid in full under a void as withheld, or gone — is
1694		// skipped and counted, the list form like the range form (R22)
1695		n, skipped := 0, 0
1696		last := int64(0)
1697		if p.Ranged {
1698			// the range as closed when the proposal was made, counted again against the cap (R28); the last height
1699			// voided goes into the note (R23)
1700			s.mustFitRange(p.Range)
1701			s.eachInRange(p.Range, func(c *Claim) bool {
1702				if voidable(c, p.Forged) {
1703					s.void(c, p.Forged)
1704					n++
1705					last = c.Height
1706				} else {
1707					skipped++
1708				}
1709				return false
1710			}, func(e *SweepEntry) bool {
1711				if sweepVoidable(e, p.Forged) {
1712					s.voidSweep(e)
1713					n++
1714					last = e.Height
1715				} else {
1716					skipped++
1717				}
1718				return false
1719			})
1720		} else {
1721			for _, id := range p.IDs {
1722				if e := s.SweepOf(id); e != nil {
1723					if sweepVoidable(e, p.Forged) {
1724						s.voidSweep(e)
1725						n++
1726					} else {
1727						skipped++
1728					}
1729					continue
1730				}
1731				if c := s.ClaimOf(id); c != nil && voidable(c, p.Forged) {
1732					s.void(c, p.Forged)
1733					n++
1734				} else {
1735					skipped++
1736				}
1737			}
1738		}
1739		note = "; " + strconv.Itoa(n) + " voided"
1740		if skipped > 0 {
1741			note += ", " + strconv.Itoa(skipped) + " skipped (not voidable as it stands)"
1742		}
1743		if p.Ranged && n > 0 {
1744			note += ", last voided at height " + itoa(last)
1745		}
1746	case restoreParams:
1747		// an id no longer voided (restored by another decision since) or gone is skipped and counted (R22); a
1748		// written-off send is lost no more (R38)
1749		n, skipped, undone := 0, 0, 0
1750		for _, id := range p.ids {
1751			if e := s.SweepOf(id); e != nil {
1752				if e.Voided {
1753					s.restoreSweep(e)
1754					n++
1755				} else {
1756					skipped++
1757				}
1758				continue
1759			}
1760			if sd := s.SendOf(id); sd != nil {
1761				if sd.WrittenOff != 0 {
1762					s.restoreWriteOff(sd)
1763					n++
1764					undone++
1765				} else {
1766					skipped++
1767				}
1768				continue
1769			}
1770			if c := s.ClaimOf(id); c != nil && (c.State == Withheld || c.State == Forged) {
1771				s.restore(c, now, env.Epoch())
1772				n++
1773			} else {
1774				skipped++
1775			}
1776		}
1777		note = "; " + strconv.Itoa(n) + " restored"
1778		switch undone {
1779		case 0:
1780		case 1:
1781			note += " (1 write-off undone: the send is lost no more)"
1782		default:
1783			note += " (" + strconv.Itoa(undone) + " write-offs undone: the sends are lost no more)"
1784		}
1785		if skipped > 0 {
1786			note += ", " + strconv.Itoa(skipped) + " skipped (not voided as it stands)"
1787		}
1788	case creditParams:
1789		s.creditByVote(now, p, env)
1790	case sweepParams:
1791		// the entries still live are paid as one sum and dropped (R19); one voided or gone since the proposal was
1792		// made is skipped; a sum the lock no longer covers waits
1793		sum, paid, skipped := int64(0), 0, 0
1794		for _, id := range p.ids {
1795			if e := s.SweepOf(id); e != nil && !e.Voided {
1796				sum += e.Amount
1797				paid++
1798			} else {
1799				skipped++
1800			}
1801		}
1802		if sum > s.Sweep {
1803			panic("hlroute: the entries sum to " + s.Amount(sum) + ", more than the sweep queue's balance of " + s.Amount(s.Sweep))
1804		}
1805		if sum > s.Lock {
1806			panic("hlroute: the lock (" + s.Amount(s.Lock) + ") holds less than the entries' " + s.Amount(sum) + " now")
1807		}
1808		for _, id := range p.ids {
1809			if e := s.SweepOf(id); e != nil && !e.Voided {
1810				s.sweeps.Remove(id)
1811				s.credits.Remove(heightKey(e.Height, e.ID))
1812			}
1813		}
1814		s.Sweep -= sum
1815		s.Owed -= sum
1816		s.PaidTotal += sum
1817		s.Lock -= sum
1818		if sum > 0 {
1819			env.Pay(p.to, sum)
1820		}
1821		note = "; " + strconv.Itoa(paid) + " entries paid (" + s.Amount(sum) + ")"
1822		if skipped > 0 {
1823			note += ", " + strconv.Itoa(skipped) + " skipped (voided or gone since the proposal was made)"
1824		}
1825	case refundParams:
1826		// kind 6's enrolment of this route is asked again (AR1: a later kind 6 restarts the wait); then the sends a
1827		// predecessor's page brought (AQ2) and still unrefunded are summed first: a sum the lock no longer covers
1828		// waits (R30), as a sweep payment's does; one refunded by another decision since the proposal was made, or
1829		// gone, is skipped and counted (R22)
1830		s.mustBeEnrolled(now, env)
1831		sum, n, skipped := int64(0), 0, 0
1832		for _, id := range p.ids {
1833			if sd := s.SendOf(id); sd != nil && sd.Imported && sd.Refunded == 0 {
1834				sum += sd.Amount
1835				n++
1836			} else {
1837				skipped++
1838			}
1839		}
1840		if sum > s.Lock {
1841			panic("hlroute: the lock (" + s.Amount(s.Lock) + ") holds less than the sends' " + s.Amount(sum) + " now")
1842		}
1843		for _, id := range p.ids {
1844			sd := s.SendOf(id)
1845			if sd == nil || !sd.Imported || sd.Refunded != 0 {
1846				continue
1847			}
1848			s.refund(id, sd, now)
1849			env.Pay(sd.From, sd.Amount)
1850		}
1851		note = "; " + strconv.Itoa(n) + " refunded"
1852		if skipped > 0 {
1853			note += ", " + strconv.Itoa(skipped) + " skipped (refunded already, or gone)"
1854		}
1855	case writeOffParams:
1856		// a refunded send still unmarked is written off, its amount lost and the identity closed by that much (AR1);
1857		// one written off by another decision since the proposal was made, never refunded, or gone is skipped (R22)
1858		sum, n, skipped := int64(0), 0, 0
1859		for _, id := range p.ids {
1860			if sd := s.SendOf(id); sd != nil && sd.Refunded != 0 && sd.WrittenOff == 0 {
1861				s.writeOff(sd, now)
1862				sum += sd.Amount
1863				n++
1864			} else {
1865				skipped++
1866			}
1867		}
1868		note = "; " + strconv.Itoa(n) + " written off (" + s.Amount(sum) + ")"
1869		if skipped > 0 {
1870			note += ", " + strconv.Itoa(skipped) + " skipped (written off already, never refunded, or gone)"
1871		}
1872	case migrateParams:
1873		// a re-point (AR2) passes the same checks as a first migrate; the mailbox replaces the registration
1874		s.mustAllowMigrate(p.to)
1875		s.mustMigrate(p.to, env)
1876		s.MigratedTo, s.MigratedAt = p.to, now
1877		env.AddRoute(p.to)
1878	default:
1879		panic("hlroute: decision " + itoa(dec.ID) + " has nothing to apply")
1880	}
1881	return note
1882}
1883
1884// refund marks a send refunded now and takes its amount out of sentTotal and the lock (the realm pays it back): the
1885// mark is what send/<id>, refunds/<from> and the json's refunded count show, so the monitors drop the send from what
1886// is in flight (AQ2).
1887func (s *State) refund(id string, sd *Send, now int64) {
1888	sd.Refunded = now
1889	s.SentTotal -= sd.Amount
1890	s.Lock -= sd.Amount
1891	s.refunds.Set(id, true)
1892	s.nRefunded++
1893	s.refundedTotal += sd.Amount
1894}
1895
1896// writeOff marks a refunded send delivered on Solana after its refund and books its amount as lost (AR1): the
1897// mark is what send/<id> and the json's writtenOff count show.
1898func (s *State) writeOff(sd *Send, now int64) {
1899	sd.WrittenOff = now
1900	s.Lost += sd.Amount
1901	s.nWrittenOff++
1902	s.writtenOffTotal += sd.Amount
1903}
1904
1905// restoreWriteOff is writeOff's inverse (R38): the mark cleared, the amount back out of lost and the counters down,
1906// so the identity closes on the supply as it was before the mistaken write-off; the refund mark stays, and the
1907// send may be written off again.
1908func (s *State) restoreWriteOff(sd *Send) {
1909	sd.WrittenOff = 0
1910	s.Lost -= sd.Amount
1911	s.nWrittenOff--
1912	s.writtenOffTotal -= sd.Amount
1913}
1914
1915// creditByVote credits an id by hand: a new claim for an id never done (marked done now); for an id whose claim
1916// stands voided, the voided record replaced by the recipient and amount GovDAO names at the back of that
1917// recipient's queue (what stood voided or lost for it is undone; what is owed moves by the new amount); for a
1918// sweep entry's id (R11), the entry dropped — its amount out of the sweep queue and of what is owed, unless a
1919// void took it already — and the claim queued in its place. The amount is at most the lock as it stands (R26).
1920func (s *State) creditByVote(now int64, p creditParams, env Env) {
1921	if p.amount > s.Lock {
1922		panic("hlroute: the amount " + s.Amount(p.amount) + " is above the lock of " + s.Amount(s.Lock) + " now")
1923	}
1924	if c := s.ClaimOf(p.id); c != nil {
1925		switch c.State {
1926		case Withheld:
1927			s.Voided -= c.Remaining()
1928		case Forged:
1929			s.Lost -= c.Paid
1930		default:
1931			panic("hlroute: " + p.id + " is credited already and not voided")
1932		}
1933		s.claims.Remove(p.id)
1934		s.credits.Remove(heightKey(c.Height, c.ID))
1935		s.nClaims--
1936		s.enqueue(now, env.Height(), env.Epoch(), p.id, p.to, p.amount, ByVote)
1937		return
1938	}
1939	if e := s.SweepOf(p.id); e != nil {
1940		if !e.Voided {
1941			if e.Amount > s.Sweep {
1942				panic("hlroute: " + e.ID + "'s " + s.Amount(e.Amount) + " exceeds the sweep queue's balance of " + s.Amount(s.Sweep))
1943			}
1944			s.Sweep -= e.Amount
1945			s.Owed -= e.Amount
1946		}
1947		s.sweeps.Remove(p.id)
1948		s.credits.Remove(heightKey(e.Height, e.ID))
1949		s.enqueue(now, env.Height(), env.Epoch(), p.id, p.to, p.amount, ByVote)
1950		return
1951	}
1952	if env.Delivered(p.id) {
1953		panic("hlroute: " + p.id + " is done already")
1954	}
1955	env.MarkDone(p.id)
1956	s.enqueue(now, env.Height(), env.Epoch(), p.id, p.to, p.amount, ByVote)
1957}
1958
1959// -------------------------------------------------------------- migrate --
1960
1961// Snapshot is one page of what a successor takes over: plain values it copies into its own state. Page 0 carries
1962// the counters; every page up to the page size of records.
1963type Snapshot struct {
1964	Page                                                int
1965	More                                                bool // records remain for the next page
1966	Lock, SentTotal, PaidTotal, Owed, Voided, Lost, Sweep int64
1967	Claims                                              []Claim
1968	Sweeps                                              []SweepItem
1969	Sends                                               []SendItem
1970}
1971
1972type SweepItem struct {
1973	ID     string
1974	Amount int64
1975	Height int64
1976	By     string
1977	Voided bool
1978}
1979
1980type SendItem struct {
1981	ID       string
1982	From     address
1983	Amount   int64
1984	At       int64
1985	Refunded   int64 // when it was refunded, 0 if not (R33): the mark goes with the record, so some realm always answers it
1986	WrittenOff int64 // when it was written off (AR1), 0 if not: the same
1987}
1988
1989// Exported is how many pages went to the successor so far: the next Pull takes that page number.
1990func (s *State) Exported() int { return s.exported }
1991
1992// Imported is how many of a predecessor's pages this route took in: 0 for a route that succeeded none.
1993func (s *State) Imported() int { return s.imported }
1994
1995// MustHaveImported is the gate on a message the router addressed to this route's predecessor (AQ1): hlmailbox lets
1996// a successor take such a message — the burns name the predecessor until kind 6 enrolls the successor — and this
1997// route credits it only once the predecessor's last page is in: before page 0 the lock is not here (the credit
1998// would be swept as one above the lock), and between pages Credit refuses anyway. The message waits undelivered
1999// meanwhile and the relayer brings it again; after the last page it is an ordinary credit, done once in the shared
2000// mailbox. A route that succeeded none refuses such a message for good.
2001func (s *State) MustHaveImported() {
2002	if s.imported == 0 || s.importing {
2003		panic("hlroute: addressed to another realm: a successor takes its predecessor's messages once the last page of the migration is in (" + strconv.Itoa(s.imported) + " pages so far)")
2004	}
2005}
2006
2007// MustTake is the gate on every incoming message, asked by the realm's Process before the mailbox is: toSelf is
2008// whether the message names this realm as its recipient, predecessor the route whose migrate registered this one
2009// (hlmailbox.Predecessor of the realm's own path, "" for a launch route). A route that succeeds another credits
2010// nothing before its predecessor's last page is in — a message addressed to itself included (R32, AR3): before
2011// page 0 the lock is not here, so an honest burn to the successor, which kind 6 may enroll the day the migrate
2012// applies, would be swept as one above the lock, owed and paying nobody until a sweep payment by vote — so every
2013// message waits undelivered meanwhile and the relayer brings it again. A message addressed to another realm is a
2014// predecessor's (MustHaveImported): taken once the last page is in, refused for good by a route that succeeded
2015// none. A launch route takes its own as it always did.
2016func (s *State) MustTake(toSelf bool, predecessor string) {
2017	if !toSelf {
2018		s.MustHaveImported()
2019		return
2020	}
2021	if predecessor != "" && (s.imported == 0 || s.importing) {
2022		panic("hlroute: this route succeeds " + predecessor + ": nothing is credited before the last page of the migration is in (" + strconv.Itoa(s.imported) + " pages so far)")
2023	}
2024}
2025
2026// MustSend is the gate on a send, asked by the realm's TransferRemote before anything moves (R35, F28; AT7): a route
2027// that succeeds another takes no send before its predecessor's last page is in — the mirror of MustTake — nor
2028// before r/hlgov has dispatched the kind 6 enrolling it as the token's router on Solana (Env.Enrolled: the note
2029// the kind-6 closure leaves with the mailbox, cleared by a cancel). Before page 0 nothing has moved, so the
2030// migration may still be re-pointed (AR2) and this realm dropped: a send through it would lock the sender's coins
2031// in a realm Solana never enrols (kind 6 waits for page 0), which cannot refund them (its own send, never a
2032// refund's) and cannot migrate (a route no more) — stranded for good. Between pages the lock is only partly here
2033// and Credit refuses anyway; a send waits for the last page as a credit does. After the last page and before the
2034// kind 6, the token program refuses a sender that is not its enrolled router, so every send would park on Solana
2035// until a kind 6 lands — weeks under a hold, never if GovDAO cancels it — unrefundable as the route's own (§5.3:
2036// a live route's stranded send waits for a kind 12, since a refund voids nothing on Solana); with the note in, a
2037// send parks a week at most (the kind 6's own clock there; a hold defers it, the guardians' sends flag the brake).
2038// A launch route sends as it always did: the setup step enrolled it, and no kind 6 ever notes it.
2039func (s *State) MustSend(predecessor string, env Env) {
2040	if predecessor == "" {
2041		return
2042	}
2043	if s.imported == 0 || s.importing {
2044		panic("hlroute: this route succeeds " + predecessor + ": nothing is sent before the last page of the migration is in (" + strconv.Itoa(s.imported) + " pages so far)")
2045	}
2046	if h, _ := env.Enrolled(); h == 0 {
2047		panic("hlroute: this route succeeds " + predecessor + ": nothing is sent before r/hlgov has dispatched the kind 6 enrolling this realm as the token's router on Solana (no enrolment note yet): a send before it would park there until a kind 6 lands, unrefundable as the route's own")
2048	}
2049}
2050
2051// Importing is whether a predecessor's pages are still arriving: from page 0's import until the page that says no
2052// more follows (AP4). A successor's adopt loop reads it to know when it is done.
2053func (s *State) Importing() bool { return s.importing }
2054
2055// Export hands the state to the successor a page at a time (R13): page 0 carries the counters (the realm pays the
2056// lock with it) and every page up to size records — the claims first, every claim ever credited (open, voided and
2057// paid, so a void or a restore reaches them on the successor, R12), then the sweep entries, then the sends, a
2058// refunded one with its mark (R33: the monitors drop a refunded send from what is in flight by the realm that
2059// answers send/<id> for it, so the record is never dropped; the refund counters follow it) — each leaving this
2060// state as it goes, so a page outgrows no block however long the route ran. The pages go in order; a page handed
2061// over already exports nothing (and the realm pays nothing). Only after a migrate decision applied.
2062func (s *State) Export(page, size int) *Snapshot {
2063	if !s.Migrated() {
2064		panic("hlroute: no migrate decision applied")
2065	}
2066	if page < 0 || page > s.exported {
2067		panic("hlroute: page " + strconv.Itoa(s.exported) + " is the next to pull, not " + strconv.Itoa(page))
2068	}
2069	snap := &Snapshot{Page: page}
2070	if page < s.exported {
2071		return snap
2072	}
2073	if size < 1 {
2074		panic("hlroute: a page holds at least one record")
2075	}
2076	if page == 0 {
2077		snap.Lock, snap.SentTotal, snap.PaidTotal, snap.Owed, snap.Voided, snap.Lost, snap.Sweep = s.Lock, s.SentTotal, s.PaidTotal, s.Owed, s.Voided, s.Lost, s.Sweep
2078		s.Lock, s.SentTotal, s.PaidTotal, s.Owed, s.Voided, s.Lost, s.Sweep = 0, 0, 0, 0, 0, 0, 0
2079	}
2080	n := 0
2081	var keys []string
2082	s.claims.Iterate("", "", func(k string, v any) bool {
2083		c := *v.(*Claim)
2084		// the recipient's counter is final (nothing credits here after the migrate decision): the successor raises
2085		// its own to it on first seeing the recipient (R20)
2086		c.QueueNext = s.queues.Get(string(c.To)).(*Queue).next
2087		snap.Claims = append(snap.Claims, c)
2088		keys = append(keys, k)
2089		n++
2090		return n >= size
2091	})
2092	for _, k := range keys {
2093		c := s.claims.Get(k).(*Claim)
2094		if c.State == Open {
2095			s.dequeue(c, s.queues.Get(string(c.To)).(*Queue))
2096			s.nOpen--
2097		}
2098		s.claims.Remove(k)
2099		s.credits.Remove(heightKey(c.Height, c.ID))
2100		s.nClaims--
2101	}
2102	keys = nil
2103	if n < size {
2104		s.sweeps.Iterate("", "", func(k string, v any) bool {
2105			e := v.(*SweepEntry)
2106			snap.Sweeps = append(snap.Sweeps, SweepItem{e.ID, e.Amount, e.Height, e.By, e.Voided})
2107			keys = append(keys, k)
2108			n++
2109			return n >= size
2110		})
2111		for _, k := range keys {
2112			e := s.sweeps.Get(k).(*SweepEntry)
2113			s.sweeps.Remove(k)
2114			s.credits.Remove(heightKey(e.Height, e.ID))
2115		}
2116		keys = nil
2117	}
2118	if n < size {
2119		s.sends.Iterate("", "", func(k string, v any) bool {
2120			sd := v.(*Send)
2121			snap.Sends = append(snap.Sends, SendItem{ID: k, From: sd.From, Amount: sd.Amount, At: sd.At, Refunded: sd.Refunded, WrittenOff: sd.WrittenOff})
2122			keys = append(keys, k)
2123			n++
2124			return n >= size
2125		})
2126		for _, k := range keys {
2127			sd := s.sends.Get(k).(*Send)
2128			if sd.Refunded != 0 {
2129				s.nRefunded--
2130				s.refundedTotal -= sd.Amount
2131			}
2132			if sd.WrittenOff != 0 {
2133				s.nWrittenOff--
2134				s.writtenOffTotal -= sd.Amount
2135			}
2136			s.sends.Remove(k)
2137			s.refunds.Remove(k)
2138		}
2139	}
2140	snap.More = s.claims.Size() > 0 || s.sweeps.Size() > 0 || s.sends.Size() > 0
2141	if n > 0 || page == 0 {
2142		s.exported++
2143	}
2144	return snap
2145}
2146
2147// Import takes a page of a predecessor's snapshot into this state: the counters add (page 0 carries them), the
2148// claims keep their ids, states and order — a recipient's queue places every imported claim at a base plus its
2149// old seq, and its counter is raised to the predecessor's final one on first seeing the recipient (R20), so this
2150// route's own credits land above every place a later page can claim — the open ones queued, the voided and paid
2151// ones kept as records, the sweep entries and the sends as they were, a refunded send with its mark, in the
2152// refunds index and counted (R33). The pages go in order, each once; from the first to the one that says no
2153// more follows, Credit refuses (AP4). epoch is this route's payouts epoch now: the queues it makes start there.
2154// Page 0 empties the bridge meter and every quarter as of now and takes launchedAt — hlmailbox's launch time —
2155// as the launch the meters were last emptied for (R34): a successor starts with empty meters, as a launch route
2156// does at Launch and every route after an unpause, and its first Claim applies no launch reset, which rewound
2157// every meter to the launch, months back, and filled them all at once — a third cap in the 24 hours the
2158// predecessor closed. The queues a later page makes start empty at their first claim, as every queue does.
2159func (s *State) Import(now, epoch, launchedAt int64, snap *Snapshot) {
2160	s.mustNotBeMigrated()
2161	if snap.Page != s.imported {
2162		panic("hlroute: page " + strconv.Itoa(s.imported) + " is the next to import, not " + strconv.Itoa(snap.Page))
2163	}
2164	if snap.Page == 0 {
2165		s.ResetMeters(now)
2166		s.LaunchSeen = launchedAt
2167	}
2168	s.imported++
2169	s.importing = snap.More
2170	s.Lock += snap.Lock
2171	s.SentTotal += snap.SentTotal
2172	s.PaidTotal += snap.PaidTotal
2173	s.Owed += snap.Owed
2174	s.Voided += snap.Voided
2175	s.Lost += snap.Lost
2176	s.Sweep += snap.Sweep
2177	for _, c := range snap.Claims {
2178		if s.claims.Has(c.ID) {
2179			panic("hlroute: a claim with id " + c.ID + " exists")
2180		}
2181		q := s.queue(c.To, now, epoch)
2182		if !q.imported {
2183			q.imported, q.base = true, q.next
2184			if top := q.base + c.QueueNext; top > q.next {
2185				q.next = top
2186			}
2187		}
2188		nc := &Claim{ID: c.ID, To: c.To, Amount: c.Amount, Paid: c.Paid, At: c.At, Height: c.Height, By: c.By, State: c.State, Seq: q.base + c.Seq}
2189		if nc.Seq > q.next {
2190			q.next = nc.Seq
2191		}
2192		s.claims.Set(c.ID, nc)
2193		s.credits.Set(heightKey(c.Height, c.ID), c.ID)
2194		s.nClaims++
2195		if c.State == Open {
2196			if q.items.Has(seqKey(nc.Seq)) {
2197				panic("hlroute: a claim at place " + itoa(nc.Seq) + " of " + string(c.To) + "'s queue exists")
2198			}
2199			q.items.Set(seqKey(nc.Seq), c.ID)
2200			s.nOpen++
2201			s.waiting.Set(string(c.To), true)
2202		}
2203	}
2204	for _, it := range snap.Sweeps {
2205		if s.sweeps.Has(it.ID) {
2206			panic("hlroute: a sweep entry with id " + it.ID + " exists")
2207		}
2208		s.sweeps.Set(it.ID, &SweepEntry{ID: it.ID, Amount: it.Amount, Height: it.Height, By: it.By, Voided: it.Voided})
2209		s.credits.Set(heightKey(it.Height, it.ID), it.ID)
2210	}
2211	for _, it := range snap.Sends {
2212		if s.sends.Has(it.ID) {
2213			panic("hlroute: a send with id " + it.ID + " exists")
2214		}
2215		s.sends.Set(it.ID, &Send{From: it.From, Amount: it.Amount, At: it.At, Refunded: it.Refunded, WrittenOff: it.WrittenOff, Imported: true})
2216		if it.Refunded != 0 {
2217			s.refunds.Set(it.ID, true)
2218			s.nRefunded++
2219			s.refundedTotal += it.Amount
2220		}
2221		if it.WrittenOff != 0 {
2222			s.nWrittenOff++
2223			s.writtenOffTotal += it.Amount
2224		}
2225	}
2226}
2227
2228// ---------------------------------------------------------------- pages --
2229
2230// meterView is the bridge meter as a reader sees it now: a copy, refilled, with the launch reset the next Claim
2231// will apply (§5.4: the meters start empty at Launch; launchedAt is hlmailbox's, 0 before the launch) applied
2232// first, so the page and hlwatch never show a full meter that Claim would then empty (K22).
2233func (s *State) meterView(now, epoch, epochAt, launchedAt int64) Meter {
2234	m := s.meter
2235	if launchedAt != 0 && launchedAt != s.LaunchSeen {
2236		m.Level, m.At, m.Frac = 0, launchedAt, 0
2237	}
2238	dayCap := s.Cap()
2239	m.refill(now, dayCap, dayCap, epoch, epochAt)
2240	return m
2241}
2242
2243// Figures is the realm's page body: the settings, the identity and the queues. launchedAt is hlmailbox's launch
2244// time (the meter is shown as the launch reset leaves it until the first Claim applies the reset).
2245func (s *State) Figures(now int64, epoch, epochAt, launchedAt int64, paused string) string {
2246	var b strings.Builder
2247	dayCap := s.Cap()
2248	m := s.meterView(now, epoch, epochAt, launchedAt)
2249	b.WriteString("- lock: " + s.Amount(s.Lock) + " (sent " + s.Amount(s.SentTotal) + ", paid " + s.Amount(s.PaidTotal) + ")\n")
2250	b.WriteString("- owed: " + s.Amount(s.Owed) + " in " + itoa(s.nOpen) + " open claims (the sweep queue " + s.Amount(s.Sweep) + " included); voided " + s.Amount(s.Voided) + "; lost " + s.Amount(s.Lost) + "\n")
2251	b.WriteString("- identity: lock = sentTotal − paidTotal = supply + owed + voided − lost, so the supply on Solana should be " + s.Amount(s.Lock-s.Owed-s.Voided+s.Lost) + " up to messages in flight\n")
2252	b.WriteString("- day cap: " + s.Amount(dayCap) + " (floor " + s.Amount(s.CapFloor) + ", a tenth of the lock otherwise); the bridge meter holds " + s.Amount(m.Level) + " now; a recipient's meter holds a quarter at most\n")
2253	b.WriteString("- fee " + s.Amount(s.Fee) + " (floor " + s.Amount(s.FeeFloor) + "); minimum " + s.Amount(s.Minimum) + "; per-message maximum " + s.Amount(s.Maximum) + "\n")
2254	b.WriteString("- " + decisions.Clocks() + "; the old router's window after a swap is the 7-day delay\n")
2255	b.WriteString("- router on domain " + strconv.FormatUint(uint64(s.Domain), 10) + ": `" + hex.EncodeToString(s.Router[:]) + "`")
2256	if s.OldUntil > now {
2257		b.WriteString(" (the old router `" + hex.EncodeToString(s.OldRouter[:]) + "` accepted until " + itoa(s.OldUntil) + ")")
2258	}
2259	b.WriteString("\n")
2260	if paused != "" {
2261		b.WriteString("- **paused**: " + paused + "\n")
2262	}
2263	if s.Migrated() {
2264		b.WriteString("- **migrated** to `" + s.MigratedTo + "` at " + itoa(s.MigratedAt) + ": every function refuses\n")
2265	}
2266	if s.importing {
2267		b.WriteString("- **importing**: a predecessor's pages are arriving (" + strconv.Itoa(s.imported) + " so far); credits are refused until the last page is in\n")
2268	}
2269	writtenOff := ""
2270	if s.nWrittenOff > 0 {
2271		writtenOff = ", written off " + itoa(s.nWrittenOff) + " (" + s.Amount(s.writtenOffTotal) + ")"
2272	}
2273	b.WriteString("- claims ever: " + itoa(s.nClaims) + "; sends recorded: " + itoa(int64(s.sends.Size())) + ", refunded " + itoa(s.nRefunded) + " (" + s.Amount(s.refundedTotal) + ")" + writtenOff + "; decisions: " + itoa(s.Ledger.Last()) + "\n")
2274	// the count is the counter, never a walk of every waiting recipient (R24): the page stays within a query's gas
2275	// under a flood of claims to fresh addresses; nOpen and the walk agree (R12)
2276	if total := int(s.nOpen); total > 0 {
2277		b.WriteString("\n## Waiting claims\n\n")
2278		listed := 0
2279		s.eachWaiting(func(to string, pos int, c *Claim) bool {
2280			b.WriteString("- " + to + " #" + strconv.Itoa(pos) + ": " + s.Amount(c.Remaining()) + " of " + s.Amount(c.Amount) + " " + c.State + " (" + c.By + ") at height " + itoa(c.Height) + " `" + c.ID + "`\n")
2281			listed++
2282			return listed >= PageLines
2283		})
2284		if rest := total - listed; rest > 0 {
2285			b.WriteString("- … and " + strconv.Itoa(rest) + " more (" + strconv.Itoa(total) + " waiting): the whole list, by hundreds, at claims/0, claims/" + strconv.Itoa(PageLines) + ", …\n")
2286		}
2287	}
2288	if total := s.sweeps.Size(); total > 0 {
2289		b.WriteString("\n## Sweep queue\n\n")
2290		s.sweeps.IterateByOffset(0, PageLines, func(_ string, v any) bool {
2291			b.WriteString("- " + s.sweepLine(v.(*SweepEntry)) + "\n")
2292			return false
2293		})
2294		if rest := total - PageLines; rest > 0 {
2295			b.WriteString("- … and " + strconv.Itoa(rest) + " more (" + strconv.Itoa(total) + " entries): the whole queue, by hundreds, at sweeps/0, sweeps/" + strconv.Itoa(PageLines) + ", …\n")
2296		}
2297	}
2298	return b.String()
2299}
2300
2301// sweepLine is one sweep entry on the page.
2302func (s *State) sweepLine(e *SweepEntry) string {
2303	line := s.Amount(e.Amount) + " `" + e.ID + "` (" + e.By + ") at height " + itoa(e.Height)
2304	if e.Voided {
2305		line += ", voided as forged"
2306	}
2307	return line
2308}
2309
2310// eachWaiting walks every open claim in the queues in the page's order (recipients by address, each queue oldest
2311// first) until fn returns true; pos is the claim's place in its recipient's queue, from 1. It walks the ring of
2312// recipients with an open claim, never a queue emptied by payouts or voids (R13).
2313func (s *State) eachWaiting(fn func(to string, pos int, c *Claim) bool) {
2314	s.waiting.Iterate("", "", func(k string, _ any) bool {
2315		q := s.queues.Get(k).(*Queue)
2316		pos, stop := 0, false
2317		q.items.Iterate("", "", func(_ string, idv any) bool {
2318			c := s.claims.Get(idv.(string)).(*Claim)
2319			pos++
2320			stop = fn(k, pos, c)
2321			return stop
2322		})
2323		return stop
2324	})
2325}
2326
2327// waitingCount is how many open claims sit in the queues, by a walk of every waiting recipient: the tests' check
2328// of nOpen (R12's invariant); no page uses it (R24).
2329func (s *State) waitingCount() int {
2330	n := 0
2331	s.waiting.Iterate("", "", func(k string, _ any) bool {
2332		n += s.queues.Get(k).(*Queue).items.Size()
2333		return false
2334	})
2335	return n
2336}
2337
2338// ClaimsPage is up to PageLines waiting claims from the from-th on (from 0), in the page's order, as a JSON array
2339// of ClaimJSON's objects; "[]" past the end.
2340func (s *State) ClaimsPage(from int) string {
2341	var b strings.Builder
2342	b.WriteString("[")
2343	i, n := 0, 0
2344	if from >= 0 {
2345		s.eachWaiting(func(_ string, _ int, c *Claim) bool {
2346			if i >= from {
2347				if n > 0 {
2348					b.WriteString(",")
2349				}
2350				b.WriteString(claimJSON(c))
2351				n++
2352			}
2353			i++
2354			return n >= PageLines
2355		})
2356	}
2357	b.WriteString("]")
2358	return b.String()
2359}
2360
2361// SweepsPage is up to PageLines sweep entries from the from-th on (from 0), by id, as a JSON array of
2362// {"id","swept"}; "[]" past the end.
2363func (s *State) SweepsPage(from int) string {
2364	var b strings.Builder
2365	b.WriteString("[")
2366	n := 0
2367	if from >= 0 {
2368		s.sweeps.IterateByOffset(from, PageLines, func(_ string, v any) bool {
2369			if n > 0 {
2370				b.WriteString(",")
2371			}
2372			b.WriteString(sweptJSON(v.(*SweepEntry)))
2373			n++
2374			return false
2375		})
2376	}
2377	b.WriteString("]")
2378	return b.String()
2379}
2380
2381// JSON is the figures for the agents; launchedAt as Figures takes it.
2382func (s *State) JSON(now int64, epoch, epochAt, launchedAt int64) string {
2383	dayCap := s.Cap()
2384	m := s.meterView(now, epoch, epochAt, launchedAt)
2385	return "\"symbol\":" + strconv.Quote(s.Symbol) + ",\"domain\":" + strconv.FormatUint(uint64(s.Domain), 10) +
2386		",\"router\":" + strconv.Quote(hex.EncodeToString(s.Router[:])) + ",\"oldRouter\":" + strconv.Quote(hex.EncodeToString(s.OldRouter[:])) + ",\"oldUntil\":" + itoa(s.OldUntil) +
2387		",\"lock\":" + itoa(s.Lock) + ",\"sentTotal\":" + itoa(s.SentTotal) + ",\"paidTotal\":" + itoa(s.PaidTotal) +
2388		",\"owed\":" + itoa(s.Owed) + ",\"voided\":" + itoa(s.Voided) + ",\"lost\":" + itoa(s.Lost) + ",\"sweep\":" + itoa(s.Sweep) +
2389		",\"cap\":" + itoa(dayCap) + ",\"meter\":" + itoa(m.Level) + ",\"capFloor\":" + itoa(s.CapFloor) +
2390		",\"fee\":" + itoa(s.Fee) + ",\"feeFloor\":" + itoa(s.FeeFloor) + ",\"minimum\":" + itoa(s.Minimum) + ",\"maximum\":" + itoa(s.Maximum) +
2391		",\"openClaims\":" + itoa(s.nOpen) + ",\"claims\":" + itoa(s.nClaims) + ",\"sends\":" + itoa(int64(s.sends.Size())) +
2392		",\"refunded\":" + itoa(s.nRefunded) + ",\"refundedTotal\":" + itoa(s.refundedTotal) +
2393		",\"writtenOff\":" + itoa(s.nWrittenOff) + ",\"writtenOffTotal\":" + itoa(s.writtenOffTotal) +
2394		",\"migratedTo\":" + strconv.Quote(s.MigratedTo) + ",\"importing\":" + strconv.FormatBool(s.importing) + ",\"imported\":" + strconv.Itoa(s.imported) +
2395		",\"clocks\":" + decisions.ClocksJSON() + ",\"decisions\":" + itoa(s.Ledger.Last())
2396}
2397
2398// SendJSON is one send by id — {"id","from","amount","at","imported","refunded","writtenOff"}: "refunded" the unix
2399// time it was, 0 if not, "writtenOff" the unix time it was written off as delivered on Solana after its refund
2400// (AR1), 0 if not, and "imported" whether a predecessor's page brought it (the only sends a refund may name, AQ2)
2401// — or "null".
2402func (s *State) SendJSON(id string) string {
2403	cid, err := CleanID(id)
2404	if err != nil {
2405		return "null"
2406	}
2407	sd := s.SendOf(cid)
2408	if sd == nil {
2409		return "null"
2410	}
2411	return sendJSON(cid, sd)
2412}
2413
2414func sendJSON(id string, sd *Send) string {
2415	return "{\"id\":" + strconv.Quote(id) + ",\"from\":" + strconv.Quote(string(sd.From)) + ",\"amount\":" + itoa(sd.Amount) + ",\"at\":" + itoa(sd.At) +
2416		",\"imported\":" + strconv.FormatBool(sd.Imported) + ",\"refunded\":" + itoa(sd.Refunded) + ",\"writtenOff\":" + itoa(sd.WrittenOff) + "}"
2417}
2418
2419// RefundsPage is up to PageLines sends refunded here from the from-th on (from 0), by id, as a JSON array of
2420// SendJSON's objects: what the monitors read to drop a refunded send from what is in flight (AQ2); "[]" past the
2421// end.
2422func (s *State) RefundsPage(from int) string {
2423	var b strings.Builder
2424	b.WriteString("[")
2425	n := 0
2426	if from >= 0 {
2427		s.refunds.IterateByOffset(from, PageLines, func(k string, _ any) bool {
2428			if n > 0 {
2429				b.WriteString(",")
2430			}
2431			b.WriteString(sendJSON(k, s.sends.Get(k).(*Send)))
2432			n++
2433			return false
2434		})
2435	}
2436	b.WriteString("]")
2437	return b.String()
2438}
2439
2440// ClaimJSON is one claim by id, or the sweep entry under that id, or "null".
2441func (s *State) ClaimJSON(id string) string {
2442	cid, err := CleanID(id)
2443	if err != nil {
2444		return "null"
2445	}
2446	c := s.ClaimOf(cid)
2447	if c == nil {
2448		if e := s.SweepOf(cid); e != nil {
2449			return sweptJSON(e)
2450		}
2451		return "null"
2452	}
2453	return claimJSON(c)
2454}
2455
2456func claimJSON(c *Claim) string {
2457	return "{\"id\":" + strconv.Quote(c.ID) + ",\"to\":" + strconv.Quote(string(c.To)) + ",\"amount\":" + itoa(c.Amount) + ",\"paid\":" + itoa(c.Paid) +
2458		",\"state\":" + strconv.Quote(c.State) + ",\"by\":" + strconv.Quote(c.By) + ",\"at\":" + itoa(c.At) + ",\"height\":" + itoa(c.Height) + ",\"seq\":" + itoa(c.Seq) + "}"
2459}
2460
2461// sweptJSON is a sweep entry: {"id","swept","height","by","voided"} ("swept" is the amount, as the agents read it).
2462func sweptJSON(e *SweepEntry) string {
2463	return "{\"id\":" + strconv.Quote(e.ID) + ",\"swept\":" + itoa(e.Amount) + ",\"height\":" + itoa(e.Height) + ",\"by\":" + strconv.Quote(e.By) + ",\"voided\":" + strconv.FormatBool(e.Voided) + "}"
2464}