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}