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

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

hlbubble.gno

25.97 Kb · 617 lines
  1// Package hlbubble is the BUBBLE lock: the bridge's BUBBLE route on gno.land (bridge/PLAN.md §5.2–§5.5), the same
  2// shape as r/hlgnot over the BUBBLE token (gno.land/r/…/bubble) instead of coins.
  3//
  4// Out. TransferRemote takes amount + fee of the caller's BUBBLE through the token's allowance (the caller approves
  5// this realm first), keeps the amount in the lock, pays the fee to r/hlfees, and sends a token message through
  6// r/hlmailbox to the synthetic BUBBLE program on Solana, which mints as much there.
  7//
  8// In. A burn on Solana comes back as a token message: Process takes it from the mailbox, checks it came from the
  9// enrolled router, and writes a claim in the recipient's queue; Claim pays claims in turns under the meters — a
 10// tenth of the lock a day (never below the floor), a quarter of that for any one recipient, the fee first. The old
 11// 1,000,000 BUBBLE day cap and the admin's pause are gone: the meter and the guardians' flags replace them.
 12//
 13// Who may change what. Nobody but the governor through the builders until it yields on r/hlmailbox, and GovDAO
 14// after (M1k); the guardians and watch keys can only pause, through r/hlmailbox. Every launch value is compiled in
 15// below; nothing moves until the Launch decision executes. The state machine is gno.land/p/…/hlroute/v0; this
 16// realm holds the BUBBLE and makes the calls.
 17package hlbubble
 18
 19import (
 20	"chain"
 21	"chain/runtime"
 22	"encoding/hex"
 23	"strconv"
 24	"strings"
 25	"time"
 26
 27	"gno.land/p/g1zdaz6sypk5tdsjc2fzsqrrn4ttn8qzmnl4waqp/decisions/v0"
 28	"gno.land/p/g1zdaz6sypk5tdsjc2fzsqrrn4ttn8qzmnl4waqp/hlroute/v0"
 29	hl "gno.land/p/g1zdaz6sypk5tdsjc2fzsqrrn4ttn8qzmnl4waqp/hyperlane/v0"
 30	"gno.land/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/bubble"
 31	"gno.land/r/g1zdaz6sypk5tdsjc2fzsqrrn4ttn8qzmnl4waqp/hlmailbox"
 32	"gno.land/r/gov/dao"
 33)
 34
 35const (
 36	namespace = "gno.land/r/g1zdaz6sypk5tdsjc2fzsqrrn4ttn8qzmnl4waqp/"
 37	pkgPath   = namespace + "hlbubble" // must match gnomod.toml
 38
 39	mailboxPath = namespace + "hlmailbox"
 40	feesPath    = namespace + "hlfees"
 41	bubblePath  = "gno.land/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/bubble" // the live token realm, never redeployed
 42	hlgnotPath  = namespace + "hlgnot"
 43	hlgovPath   = namespace + "hlgov"
 44
 45	one = int64(1_000_000) // one BUBBLE in base units
 46
 47	// The launch values (§5.3, §5.5). The router — the synthetic BUBBLE program on Solana — is a PLACEHOLDER (the
 48	// test route of the vectors) until the setup step (§5.8); bridge/local/e2e.sh swaps it for its run's.
 49	launchFee      = 100 * one
 50	launchMinimum  = 200 * one
 51	launchFeeFloor = 40 * one
 52	launchCapFloor = 10_000 * one
 53	launchMaximum  = 25_000 * one
 54	launchRouter   = "c807d773013b14cee2855ca9efafeac0b3bbc3a42fd397c2fbffda6417d290f1" // the BUBBLE token program ETqUXu1uJPj8qgmw8AJMqhgWuL5QW5ymgUMiCARbXYPA
 55
 56	maxI64 = uint64(1<<63 - 1)
 57)
 58
 59// launchGovernor is the governor (M1k): the mailbox's, compiled here too so a member reads it in this realm's
 60// served source; init refuses a deploy with another. bridge/local/e2e.sh swaps it for gnodev's test1.
 61const launchGovernor = address("g1ecsuj0q572jr0dhu29q9njtnmw03hyu7tyyvv6")
 62
 63var (
 64	st = hlroute.New(hlroute.Launch{Symbol: "BUBBLE", Fee: launchFee, Minimum: launchMinimum, FeeFloor: launchFeeFloor,
 65		CapFloor: launchCapFloor, Maximum: launchMaximum, Domain: hlmailbox.LaunchOrigin(), Router: mustKey(launchRouter),
 66		Now: time.Now().Unix()})
 67
 68	// realms are the addresses a transfer may not name as its recipient on either side (§5.2).
 69	realms = []address{chain.PackageAddress(pkgPath), chain.PackageAddress(mailboxPath), chain.PackageAddress(feesPath),
 70		chain.PackageAddress(bubblePath), chain.PackageAddress(hlgnotPath), chain.PackageAddress(hlgovPath)}
 71)
 72
 73// init refuses a deploy whose compiled governor is not the mailbox's (M1k): the bridge's realms are governed by
 74// one key until it yields, or by GovDAO from the first block, never a mix.
 75func init() {
 76	if launchGovernor != hlmailbox.Governor() {
 77		panic("hlbubble: the compiled governor " + string(launchGovernor) + " is not the mailbox's " + string(hlmailbox.Governor()))
 78	}
 79}
 80
 81func mustKey(s string) [32]byte {
 82	k, err := hl.ParseKey32(s)
 83	if err != nil {
 84		panic(err.Error())
 85	}
 86	return k
 87}
 88
 89func now() int64 { return time.Now().Unix() }
 90
 91func u32(n uint32) string { return strconv.FormatUint(uint64(n), 10) }
 92func itoa(n int64) string { return strconv.FormatInt(n, 10) }
 93
 94func caller(cur realm) address {
 95	if !cur.IsCurrent() {
 96		panic("hlbubble: that realm value is not live; pass your own cur")
 97	}
 98	return cur.Previous().Address()
 99}
100
101func mustHex(s, what string) []byte {
102	b, err := hex.DecodeString(strings.TrimPrefix(strings.TrimSpace(s), "0x"))
103	if err != nil {
104		panic("hlbubble: the " + what + " is not hex")
105	}
106	return b
107}
108
109// isRealm is whether an address is a realm of the bridge: the six compiled here, every route realm the mailbox
110// registers (R39: a successor's address is refused as a recipient at send and swept at a credit, to be credited to
111// its owner by id, never paid into a realm's account outside every counter) and every one it dropped (AT6: a
112// re-pointed-away successor, a route no more that nothing of the line can move BUBBLE out of). A handful of reads.
113func isRealm(a address) bool {
114	for _, r := range realms {
115		if r == a {
116			return true
117		}
118	}
119	for _, p := range hlmailbox.Routes() {
120		if chain.PackageAddress(p) == a {
121			return true
122		}
123	}
124	for _, p := range hlmailbox.DroppedRoutes() {
125		if chain.PackageAddress(p) == a {
126			return true
127		}
128	}
129	return false
130}
131
132func mustBeLive() {
133	if !hlmailbox.Live() {
134		panic("hlbubble: not launched: nothing is sent, credited or paid before the Launch decision")
135	}
136}
137
138func mustNotBePaused(fn string) {
139	if hlmailbox.Paused(pkgPath, fn) {
140		panic("hlbubble: " + fn + " are paused by a guardian or a watch key")
141	}
142}
143
144// mustNotBeDropped refuses a realm the mailbox dropped from its line (R35): its predecessor re-pointed the migration
145// to another successor while this realm had pulled nothing, so it is a route no more — Solana's token may still
146// name it the router until the next kind 6, and a send or a credit here would be against a lock no realm of the
147// line holds. The mailbox refuses its Dispatch and its Process too; the line's last realm takes every message
148// addressed to it once its import has ended.
149func mustNotBeDropped() {
150	if by := hlmailbox.Dropped(pkgPath); by != "" {
151		panic("hlbubble: a route no more: " + by + " re-pointed its migration to another successor; the line's last realm takes every message and send")
152	}
153}
154
155func syncLaunch() {
156	if la := hlmailbox.LaunchedAt(); la != st.LaunchSeen {
157		st.ResetMeters(la)
158		st.LaunchSeen = la
159	}
160}
161
162// ------------------------------------------------------------- reading --
163
164// Address is this realm's address: the spender to approve on the bubble realm before TransferRemote, the holder
165// of the lock, and (as 32 bytes) the router the Solana program enrolls.
166func Address() address { return chain.PackageAddress(pkgPath) }
167
168// Locked is the lock: what backs synthetic BUBBLE on Solana, by the counters (sent − paid).
169func Locked() int64 { return st.Lock }
170
171// Balance is the BUBBLE this realm holds; it is the lock unless someone sent BUBBLE here outside a transfer.
172func Balance() int64 { return bubble.BalanceOf(Address()) }
173
174// Router is the enrolled router on Solana, in hex.
175func Router() string { return hex.EncodeToString(st.Router[:]) }
176
177// Domain is the Solana domain this route sends to and takes from.
178func Domain() uint32 { return st.Domain }
179
180func Fee() int64      { return st.Fee }
181func Minimum() int64  { return st.Minimum }
182func FeeFloor() int64 { return st.FeeFloor }
183func CapFloor() int64 { return st.CapFloor }
184func Maximum() int64  { return st.Maximum }
185func Cap() int64      { return st.Cap() }
186
187// Counters is the identity's five counters (§5.9): sentTotal, paidTotal, owed, voided, lost.
188func Counters() (sentTotal, paidTotal, owed, voided, lost int64) {
189	return st.SentTotal, st.PaidTotal, st.Owed, st.Voided, st.Lost
190}
191
192// Migrated is the successor's pkgpath once a migration applied, "" before.
193func Migrated() string { return st.MigratedTo }
194
195// ---------------------------------------------------------------- out --
196
197// TransferRemote sends amount of the caller's BUBBLE to recipient on destination (a Solana address in base58, or
198// 32 bytes in hex) and returns the message id in hex. First, on the bubble realm, the caller approves THIS realm
199// (bubble.Approve, spender Address()) for at least amount + fee: the amount is locked here, the fee goes to
200// r/hlfees. The amount is between the minimum and the per-message maximum.
201func TransferRemote(cur realm, destination uint32, recipient string, amount int64) string {
202	who := caller(cur)
203	mustBeLive()
204	mustNotBePaused(hlmailbox.FnSends)
205	mustNotBeDropped()
206	st.MustSend(hlmailbox.Predecessor(pkgPath), env(cur)) // a successor takes no send before its last page is in (R35, F28) and before kind 6 names it (AT7)
207	if destination != st.Domain {
208		panic("hlbubble: no route to domain " + u32(destination))
209	}
210	st.CheckSend(amount)
211	to, err := hl.ParseKey32(recipient)
212	if err != nil {
213		panic(err.Error())
214	}
215	if to == ([32]byte{}) {
216		panic("hlbubble: the recipient is all zeros")
217	}
218	if a, err := hl.Bytes32ToAddress(to); err == nil && isRealm(a) {
219		panic("hlbubble: the recipient is a realm of the bridge")
220	}
221	self := Address()
222	before := bubble.BalanceOf(self)
223	bubble.TransferFrom(cross(cur), who, self, amount+st.Fee)
224	if bubble.BalanceOf(self) != before+amount+st.Fee {
225		panic("hlbubble: the lock moved by the wrong amount")
226	}
227	bubble.Transfer(cross(cur), chain.PackageAddress(feesPath), st.Fee)
228	id := hlmailbox.Dispatch(cross(cur), destination, st.Router, hl.TokenMessage(to, uint64(amount), nil))
229	idHex := hex.EncodeToString(id[:])
230	st.Send(now(), idHex, who, amount)
231	chain.Emit("SentTransferRemote",
232		"destination", u32(destination),
233		"recipient", hex.EncodeToString(to[:]),
234		"amount", itoa(amount),
235		"fee", itoa(st.Fee),
236		"from", string(who),
237		"id", idHex,
238	)
239	return idHex
240}
241
242// ----------------------------------------------------------------- in --
243
244// Process takes a token message from Solana and credits a claim to its recipient. Anyone may call it with the
245// verifier's metadata and the message, both in hex. A refused message stays undelivered. A realm registered as a
246// successor credits nothing — its own messages included (R32) — before the last page of its predecessor's
247// migration is in (AQ1); a migrated realm refuses before the mailbox is asked (see hlgnot's).
248func Process(cur realm, metadata, message string) {
249	caller(cur)
250	mustBeLive()
251	mustNotBePaused(hlmailbox.FnCredits)
252	mustNotBeDropped()
253	msg := mustHex(message, "message")
254	m, err := hl.ParseMessage(msg)
255	if err != nil {
256		panic(err.Error())
257	}
258	if st.Migrated() {
259		panic("hlbubble: migrated to " + st.MigratedTo + ": the successor takes this message")
260	}
261	if self, err := hl.AddressToBytes32(Address()); err != nil {
262		panic(err.Error())
263	} else {
264		st.MustTake(m.Recipient == self, hlmailbox.Predecessor(pkgPath))
265	}
266	t := now()
267	if m.Origin != st.Domain || !st.RouterAccepts(t, m.Sender) {
268		panic("hlbubble: the message is not from the router enrolled for domain " + u32(st.Domain))
269	}
270	rcpt, amount, _, err := hl.ParseTokenMessage(m.Body)
271	if err != nil {
272		panic(err.Error())
273	}
274	if amount > maxI64 {
275		panic("hlbubble: the amount is past what BUBBLE can hold")
276	}
277	origin, sender, body := hlmailbox.Process(cross(cur), mustHex(metadata, "metadata"), msg)
278	if origin != m.Origin || sender != m.Sender || string(body) != string(m.Body) {
279		panic("hlbubble: the mailbox read the message differently") // cannot happen: the same bytes, the same parser
280	}
281	// a recipient that is no gno address (its top twelve bytes not zero, §13) or a realm of the bridge is swept:
282	// owed, paying nobody until GovDAO credits it by id to its owner
283	to, err := hl.Bytes32ToAddress(rcpt)
284	recipient := string(to)
285	if err != nil {
286		recipient = hex.EncodeToString(rcpt[:])
287	}
288	idb := hl.ID(msg)
289	id := hex.EncodeToString(idb[:])
290	epoch, _ := hlmailbox.PayoutsEpoch(pkgPath)
291	swept := st.Credit(t, runtime.ChainHeight(), epoch, id, to, int64(amount), hlroute.BySigners, err != nil || isRealm(to))
292	chain.Emit("Credited",
293		"origin", u32(origin),
294		"recipient", recipient,
295		"amount", itoa(int64(amount)),
296		"id", id,
297		"swept", strconv.FormatBool(swept),
298	)
299}
300
301// Claim pays up to n claims (n ≤ 20) taking recipients in turns under the meters, the fee as each claim's first
302// installment. Anyone may call it; the relayers do. It returns how many claims it paid something on.
303func Claim(cur realm, n int) int {
304	caller(cur)
305	mustBeLive()
306	mustNotBePaused(hlmailbox.FnPayouts)
307	mustNotBeDropped()
308	syncLaunch()
309	epoch, at := hlmailbox.PayoutsEpoch(pkgPath)
310	paid := st.Claim(now(), n, epoch, at, env(cur))
311	chain.Emit("Claimed", "count", strconv.Itoa(paid))
312	return paid
313}
314
315func pay(cur realm, to address, amount int64) { bubble.Transfer(cross(cur), to, amount) }
316
317func env(cur realm) hlroute.Env {
318	return hlroute.Env{
319		Flagged:    func(id string, height int64, verifier bool) bool { return hlmailbox.Flagged(pkgPath, id, height, verifier) },
320		ClearFlags: func(fn string) bool { return hlmailbox.ClearFlags(cross(cur), fn) },
321		RemoveSeat: func(seat int, holder address) bool { return hlmailbox.VacateSeat(cross(cur), seat, holder) },
322		Delivered:  func(id string) bool { return hlmailbox.Delivered(id) != 0 },
323		MarkDone:   func(id string) { hlmailbox.MarkDone(cross(cur), id) },
324		Epoch:      func() int64 { e, _ := hlmailbox.PayoutsEpoch(pkgPath); return e },
325		Height:     func() int64 { return runtime.ChainHeight() },
326		Pay:        func(to address, amount int64) { pay(cur, to, amount) },
327		PayFee:     func(amount int64) { pay(cur, chain.PackageAddress(feesPath), amount) },
328		AddRoute:     func(path string) { hlmailbox.AddRoute(cross(cur), path) },
329		AnnouncedFor: func(path string) string { return hlmailbox.SuccessorFor(path) },
330		Predecessor:  func() string { return hlmailbox.Predecessor(pkgPath) },
331		IsRoute:      func(path string) bool { return hlmailbox.IsRoute(path) },
332		Enrolled:     func() (int64, int64) { return hlmailbox.Enrolled(pkgPath) },
333	}
334}
335
336// ------------------------------------------------------------ builders --
337
338// The builders (§5.3): the same as r/hlgnot's, amounts in BUBBLE base units. A decision made with one of them —
339// the governor's through Govern until it yields on r/hlmailbox, a GovDAO proposal's after (M1k) — is the only way
340// a setting changes; see hlgnot's for the clocks and the Request.
341
342// Request is what a builder returns (M1k): the proposal GovDAO's proxy executes after the yield (Proposal), and
343// the same decision the governor runs directly before it (Govern); see hlgnot's.
344type Request struct {
345	proposal dao.ProposalRequest
346	text     string
347	decide   func(cur realm) error
348}
349
350// Proposal is the request as a GovDAO proposal: `dao.MustCreateProposal(cross(cur), req.Proposal())`.
351func (r Request) Proposal() dao.ProposalRequest { return r.proposal }
352
353// Description is the proposal's: the parameters as the builder wrote them.
354func (r Request) Description() string { return r.text }
355
356// who names a previous realm in a refusal: its pkgpath, or a user's address.
357func who(path string, a address) string {
358	if path != "" {
359		return path
360	}
361	return string(a)
362}
363
364// mustBeGovernor is a decision's identity check (M1k), read from the closure's own live cur: the governor's own
365// call until it yields on r/hlmailbox, gno.land/r/gov/dao's proxy exactly after (or from the first block, when no
366// governor was compiled); see hlgnot's.
367func mustBeGovernor(cur realm) {
368	prev := cur.Previous()
369	if decisions.DAOGoverns(launchGovernor, hlmailbox.Yielded()) {
370		if p := prev.PkgPath(); p != decisions.DAOPath {
371			panic("hlbubble: only " + decisions.DAOPath + " executes a decision, not " + who(prev.PkgPath(), prev.Address()))
372		}
373		return
374	}
375	if !prev.IsUser() || prev.Address() != launchGovernor {
376		panic("hlbubble: until the governor yields, " + string(launchGovernor) + " alone decides, by Govern from its own key: not " + who(prev.PkgPath(), prev.Address()) + " (a GovDAO proposal is refused until the yield)")
377	}
378}
379
380func propose(cur realm, d hlroute.Draft) Request {
381	if !cur.IsCurrent() {
382		panic("hlbubble: that realm value is not live; pass your own cur")
383	}
384	made := now()
385	cb := func(cur realm) error {
386		mustBeGovernor(cur)
387		t := now()
388		if err := decisions.Expired(made, t); err != nil {
389			return err
390		}
391		if d.Kind != hlroute.KindCancel {
392			mustBeLive()
393		}
394		if d.Kind == hlroute.KindApplyRefund {
395			mustNotBePaused(hlmailbox.FnPayouts) // a refund moves BUBBLE out of the lock: under a standing payouts flag it waits like a claim (§5.9; the proposal survives for a retry)
396		}
397		dec := st.Decide(made, t, d, env(cur))
398		chain.Emit("Decision", "id", itoa(dec.ID), "kind", dec.Kind, "state", dec.State())
399		return nil
400	}
401	return Request{proposal: dao.NewProposalRequest("hlbubble: "+d.Kind, d.Text, dao.NewSimpleExecutor(0, cur, cb, d.Text)), text: d.Text, decide: cb}
402}
403
404// Govern is the governor's way in before the yield (M1k): the same closure GovDAO's proxy runs after it, called
405// non-crossing in this frame so it reads the governor from cur.Previous(); refused for anyone else and for
406// everyone once the governor yielded on r/hlmailbox (or when none was compiled); see hlgnot's.
407func Govern(cur realm, r Request) {
408	if !cur.IsCurrent() {
409		panic("hlbubble: that realm value is not live; pass your own cur")
410	}
411	if r.decide == nil {
412		panic("hlbubble: not a builder's request")
413	}
414	if yielded := hlmailbox.Yielded(); decisions.DAOGoverns(launchGovernor, yielded) {
415		if launchGovernor == "" {
416			panic("hlbubble: no governor was compiled, " + decisions.DAOPath + " governs from the first block: a decision is a GovDAO proposal, never Govern")
417		}
418		panic("hlbubble: the governor yielded at height " + itoa(yielded) + ", " + decisions.DAOPath + " governs since: a decision is a GovDAO proposal, never Govern")
419	}
420	mustBeGovernor(cur)
421	if err := r.decide(cur); err != nil {
422		panic("hlbubble: " + err.Error())
423	}
424}
425
426func NewSetCapFloorRequest(cur realm, n int64) Request {
427	mustBeLive()
428	return propose(cur, st.DraftCapFloor(n))
429}
430
431func NewSetMaximumRequest(cur realm, n int64) Request {
432	mustBeLive()
433	return propose(cur, st.DraftMaximum(n))
434}
435
436func NewSetFeeRequest(cur realm, fee, minimum int64) Request {
437	mustBeLive()
438	return propose(cur, st.DraftFee(fee, minimum))
439}
440
441func NewSetFeeFloorRequest(cur realm, n int64) Request {
442	mustBeLive()
443	return propose(cur, st.DraftFeeFloor(n))
444}
445
446func NewSetRouterRequest(cur realm, router string) Request {
447	mustBeLive()
448	return propose(cur, st.DraftRouter(mustKey(router), router))
449}
450
451// NewVoidRequest voids credits by message id (a claim, or a sweep entry as forged) or by a range of credits
452// ("heights:<from>-<to>" or "since:<from>", closed at the height the proposal is made, at most 1,000 credits), as
453// withheld or forged, with the unpause option (the seats' holders recorded; a seat whose occupant changed by
454// execution is skipped): see hlgnot's.
455func NewVoidRequest(cur realm, ids string, forged bool, unpauseFns, removeSeats string) Request {
456	mustBeLive()
457	return propose(cur, st.DraftVoid(ids, forged, unpauseFns, removeSeats, runtime.ChainHeight(), hlmailbox.Seat))
458}
459
460func NewRestoreRequest(cur realm, ids string) Request {
461	mustBeLive()
462	return propose(cur, st.DraftRestore(ids))
463}
464
465func NewCreditRequest(cur realm, id string, to address, amount int64) Request {
466	mustBeLive()
467	if !to.IsValid() || isRealm(to) {
468		panic("hlbubble: the recipient must be an address outside the bridge's realms")
469	}
470	return propose(cur, st.DraftCredit(id, to, amount, hlmailbox.Delivered(id) != 0))
471}
472
473// NewSweepRequest pays sweep entries, named by message id, to an address after 7 days — their sum, at most the
474// lock — and drops them (R19): see hlgnot's.
475func NewSweepRequest(cur realm, ids string, to address) Request {
476	mustBeLive()
477	if !to.IsValid() || isRealm(to) {
478		panic("hlbubble: the recipient must be an address outside the bridge's realms")
479	}
480	return propose(cur, st.DraftSweep(ids, to))
481}
482
483// NewRefundRequest queues a refund of imported sends by id, 7 days, once kind 6 has enrolled this realm on Solana
484// for 14 days (AR1, R37); applied then by NewApplyRefundRequest, never by Execute (AT2); see hlgnot's.
485func NewRefundRequest(cur realm, ids string) Request {
486	mustBeLive()
487	return propose(cur, st.DraftRefund(ids, now(), env(cur)))
488}
489
490// NewApplyRefundRequest applies a queued refund decision whose 7 days have passed, at once (AT2): GovDAO's own act
491// after hlwatch's check that the token's router on Solana is this realm; under a standing payouts flag it waits
492// like a claim; see hlgnot's.
493func NewApplyRefundRequest(cur realm, id int64) Request {
494	mustBeLive()
495	return propose(cur, st.DraftApplyRefund(id, now(), env(cur)))
496}
497
498// NewWriteOffRequest books refunded sends delivered on Solana after their refund as lost, after 7 days (AR1); see
499// hlgnot's.
500func NewWriteOffRequest(cur realm, ids string) Request {
501	mustBeLive()
502	return propose(cur, st.DraftWriteOff(ids))
503}
504
505// NewMigrateRequest: a successor that announced itself to the mailbox for the BUBBLE route (R25, AQ5) and is no
506// route already (R31), after 7 days; a realm that succeeds another migrates on only once its own import has ended
507// (R31); see hlgnot's.
508func NewMigrateRequest(cur realm, to string) Request {
509	mustBeLive()
510	return propose(cur, st.DraftMigrate(to, pkgPath, env(cur)))
511}
512
513func NewCancelRequest(cur realm, id int64) Request {
514	return propose(cur, st.DraftCancel(id))
515}
516
517// Execute applies a queued decision whose 7 days have passed. Anyone may call it. A sweep payment moves BUBBLE
518// out of the lock, so under a standing payouts flag it waits for the unpause like a claim (§5.9). A refund is never
519// Execute's (AT2): the state refuses the kind, and GovDAO applies it by NewApplyRefundRequest.
520func Execute(cur realm, id int64) {
521	caller(cur)
522	mustBeLive()
523	if d := st.Ledger.Get(id); d != nil && d.Kind == hlroute.KindSweep {
524		mustNotBePaused(hlmailbox.FnPayouts)
525	}
526	dec := st.Execute(now(), id, env(cur))
527	chain.Emit("Decision", "id", itoa(dec.ID), "kind", dec.Kind, "state", dec.State())
528}
529
530// Pull is the successor's: once a migrate decision names it, the realm at that pkgpath calls Pull from its own
531// code, page by page from 0, and receives the lock (transferred to its address, with page 0) and the records to
532// import, up to 500 a page (the snapshot's More says whether a page follows); a page pulled already hands over
533// nothing. Page 0 moves the lock, so under a standing payouts flag it waits for the unpause like a sweep payment
534// (R27, R16); the flag follows the claims to the successor (hlmailbox.AddRoute).
535func Pull(cur realm, page int) *hlroute.Snapshot {
536	if !cur.IsCurrent() {
537		panic("hlbubble: that realm value is not live; pass your own cur")
538	}
539	path := cur.Previous().PkgPath()
540	if st.MigratedTo == "" || path != st.MigratedTo {
541		panic("hlbubble: only the successor named by a migrate decision pulls")
542	}
543	if page == 0 {
544		mustNotBePaused(hlmailbox.FnPayouts)
545	}
546	snap := st.Export(page, hlroute.PullPage)
547	if page == 0 {
548		hlmailbox.NotePulled(cross(cur), path) // the lock moved: the mailbox answers Pulled (R35) — kind 6 may enrol the successor, and the migration is never re-pointed
549	}
550	if snap.Lock > 0 {
551		pay(cur, chain.PackageAddress(path), snap.Lock)
552	}
553	chain.Emit("Migrated", "to", path, "page", strconv.Itoa(page), "lock", itoa(snap.Lock), "claims", strconv.Itoa(len(snap.Claims)), "sweeps", strconv.Itoa(len(snap.Sweeps)), "sends", strconv.Itoa(len(snap.Sends)), "more", strconv.FormatBool(snap.More))
554	return snap
555}
556
557// --------------------------------------------------------------- pages --
558
559func pausedText() string {
560	var fns []string
561	for _, fn := range []string{hlmailbox.FnSends, hlmailbox.FnCredits, hlmailbox.FnPayouts} {
562		if hlmailbox.Paused(pkgPath, fn) {
563			fns = append(fns, fn)
564		}
565	}
566	return strings.Join(fns, ", ")
567}
568
569// Render: "" the route's page; "decisions"; "json"; "decisions.json"; "claim/<id>"; "send/<id>"; "claims/<from>",
570// "sweeps/<from>" and "refunds/<from>", up to 100 entries a page (hlgnot's Render says more).
571func Render(path string) string {
572	epoch, at := hlmailbox.PayoutsEpoch(pkgPath)
573	switch {
574	case strings.HasPrefix(path, "claims/"):
575		return st.ClaimsPage(parseFrom(path[len("claims/"):]))
576	case strings.HasPrefix(path, "sweeps/"):
577		return st.SweepsPage(parseFrom(path[len("sweeps/"):]))
578	case strings.HasPrefix(path, "send/"):
579		return st.SendJSON(path[len("send/"):])
580	case strings.HasPrefix(path, "refunds/"):
581		return st.RefundsPage(parseFrom(path[len("refunds/"):]))
582	case path == "":
583		var b strings.Builder
584		b.WriteString("# BUBBLE across chains\n\n")
585		b.WriteString("The bridge's BUBBLE route on gno.land. BUBBLE sent to Solana is locked here, and the synthetic BUBBLE there is backed by it one to one; a burn there is credited here as a claim, paid under the meters. Every setting moves only by a decision made with this realm's builders — the governor's own until it yields, a GovDAO proposal after.\n\n")
586		if hlmailbox.Live() {
587			b.WriteString("- **live** since " + itoa(hlmailbox.LaunchedAt()) + "\n")
588		} else {
589			b.WriteString("- **not launched**: the values below are the compiled launch values, waiting for the Launch decision\n")
590		}
591		b.WriteString("- " + decisions.GovernorLine(launchGovernor, hlmailbox.Yielded()) + "\n")
592		b.WriteString("- balance: " + st.Amount(Balance()) + "\n")
593		b.WriteString(st.Figures(now(), epoch, at, hlmailbox.LaunchedAt(), pausedText()))
594		b.WriteString("\n## Decisions\n\n" + st.Ledger.Page())
595		return b.String()
596	case path == "decisions":
597		return "# Decisions\n\n" + st.Ledger.Page()
598	case path == "json":
599		return "{\"address\":" + strconv.Quote(string(Address())) + ",\"live\":" + strconv.FormatBool(hlmailbox.Live()) +
600			"," + decisions.GovernorJSON(launchGovernor, hlmailbox.Yielded()) +
601			",\"balance\":" + itoa(Balance()) + ",\"paused\":" + strconv.Quote(pausedText()) + "," + st.JSON(now(), epoch, at, hlmailbox.LaunchedAt()) + "}"
602	case path == "decisions.json":
603		return st.Ledger.JSON()
604	case strings.HasPrefix(path, "claim/"):
605		return st.ClaimJSON(path[len("claim/"):])
606	}
607	return "unknown path; see the route's page"
608}
609
610// parseFrom reads a page's offset; -1 (an empty page) for anything but a number.
611func parseFrom(s string) int {
612	n, err := strconv.Atoi(s)
613	if err != nil || n < 0 {
614		return -1
615	}
616	return n
617}