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.

hlgnot.gno

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