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}