hearth.gno
15.29 Kb · 642 lines
1// Package hearth escrows a single ecosystem raise in ugnot and later
2// splits revenue deposits across contributors by paid share.
3//
4// Contribute accepts ugnot while the raise is open. Each call must be at
5// least MinContribute. The running total cannot pass HardCap; excess ugnot
6// is sent back to the caller. Hitting HardCap closes the raise.
7//
8// Raise principal (WithdrawRaise) and revenue (DepositRevenue) are separate
9// ledgers. Withdrawing the raise does not change shares. After Close,
10// DepositRevenue opens an epoch. Settle pays the next batch of unpaid
11// wallets for that epoch. Claim pays one caller. Integer dust stays in the
12// realm and is not withdrawable.
13package hearth
14
15import (
16 "chain"
17 "chain/banker"
18 "chain/runtime/unsafe"
19 "math/bits"
20 "strconv"
21 "strings"
22 "time"
23
24 "gno.land/p/nt/avl/v0"
25)
26
27const (
28 // MinContribute is the smallest ugnot a Contribute call may accept (500 GNOT).
29 MinContribute int64 = 500_000_000
30 // HardCap is the maximum ugnot the raise will accept (150_000 GNOT).
31 HardCap int64 = 150_000_000_000
32 // MaxSeats is HardCap / MinContribute. A new address past this panics.
33 MaxSeats int = 300
34 // MaxSettle is the most wallets one Settle call will pay.
35 MaxSettle int = 20
36)
37
38// Raise window in UTC. 00:00 24 Sep 2026 through 00:00 1 Oct 2026, Vietnam (UTC+7).
39// Start 2026-09-23T17:00:00Z. End 2026-09-30T17:00:00Z, exclusive.
40var (
41 raiseStart int64 = 1790182800
42 raiseEnd int64 = 1790787600
43)
44
45type seat struct {
46 who address
47 ugnot int64
48}
49
50var (
51 admin address
52 ready bool
53 closed bool
54 raised int64
55 raisedBank int64
56 revenueBank int64
57 rewardBank int64
58 rewardOwed int64
59 epochCount int
60 seats *avl.Tree
61 epochs *avl.Tree
62 claimed *avl.Tree
63 handles *avl.Tree
64 owed *avl.Tree
65)
66
67func init() {
68 seats = avl.NewTree()
69 epochs = avl.NewTree()
70 claimed = avl.NewTree()
71 handles = avl.NewTree()
72 owed = avl.NewTree()
73}
74
75// Init binds the operator to the calling EOA. Once.
76func Init(cur realm) {
77 mustUser(cur)
78 mustNoCoins()
79 if ready {
80 panic("hearth: already init")
81 }
82 admin = cur.Previous().Address()
83 ready = true
84 chain.Emit("Init", "admin", admin.String())
85}
86
87// SetHandle records a public X handle for the caller. Same handle is a no-op.
88func SetHandle(cur realm, handle string) {
89 mustUser(cur)
90 mustNoCoins()
91 mustReady()
92 h := cleanHandle(handle)
93 key := cur.Previous().Address().String()
94 if prev, ok := handles.Get(key).(string); ok && prev == h {
95 return
96 }
97 handles.Set(key, h)
98 chain.Emit("Handle", "addr", key, "handle", h)
99}
100
101// Contribute accepts ugnot toward the raise.
102func Contribute(cur realm) {
103 mustUser(cur)
104 mustReady()
105 if closed {
106 panic("hearth: raise closed")
107 }
108 got := ugnotSent()
109 accept, refund, reason := planAccept(got)
110 if reason != "" {
111 panic("hearth: " + reason)
112 }
113 caller := cur.Previous().Address()
114 key := caller.String()
115 if curSeat, ok := seats.Get(key).(*seat); ok {
116 curSeat.ugnot += accept
117 } else {
118 if seats.Size() >= MaxSeats {
119 panic("hearth: seats full")
120 }
121 seats.Set(key, &seat{who: caller, ugnot: accept})
122 }
123 raised += accept
124 raisedBank += accept
125 if raised == HardCap {
126 closed = true
127 }
128 if refund > 0 {
129 send(cur, caller, refund)
130 }
131 chain.Emit(
132 "Contributed",
133 "addr", key,
134 "accept", strconv.FormatInt(accept, 10),
135 "refund", strconv.FormatInt(refund, 10),
136 "raised", strconv.FormatInt(raised, 10),
137 )
138}
139
140// Close ends the raise before the cap so revenue epochs can start.
141func Close(cur realm) {
142 mustUser(cur)
143 mustNoCoins()
144 mustAdmin(cur)
145 if closed {
146 panic("hearth: already closed")
147 }
148 if raised <= 0 {
149 panic("hearth: empty raise")
150 }
151 closed = true
152 chain.Emit("Closed", "raised", strconv.FormatInt(raised, 10))
153}
154
155// WithdrawRaise sends the operator the raise principal still on the realm.
156// It does not touch revenue and does not change shares.
157func WithdrawRaise(cur realm) {
158 mustUser(cur)
159 mustNoCoins()
160 mustAdmin(cur)
161 if raisedBank <= 0 {
162 panic("hearth: no principal")
163 }
164 amt := raisedBank
165 raisedBank = 0
166 send(cur, admin, amt)
167 chain.Emit("RaiseWithdrawn", "amount", strconv.FormatInt(amt, 10))
168}
169
170// DepositReward takes the operator's ugnot and credits each contributor
171// got * seat / raised. Rounding dust stays withdrawable. Principal is unchanged.
172// MinMonthlyUgnot is the monthly floor, not a cap. A larger deposit uses the same split.
173func DepositReward(cur realm) {
174 mustUser(cur)
175 mustAdmin(cur)
176 got := ugnotSent()
177 if got <= 0 {
178 panic("hearth: no ugnot")
179 }
180 if raised <= 0 {
181 panic("hearth: no raise")
182 }
183 credited := int64(0)
184 seats.Iterate("", "", func(key string, value any) bool {
185 s := value.(*seat)
186 share := mulDiv(got, s.ugnot, raised)
187 if share <= 0 {
188 return false
189 }
190 setOwed(key, owedOf(key)+share)
191 credited += share
192 return false
193 })
194 if credited > got {
195 panic("hearth: credit")
196 }
197 rewardOwed += credited
198 rewardBank += got - credited
199 chain.Emit(
200 "RewardIn",
201 "amount", strconv.FormatInt(got, 10),
202 "credited", strconv.FormatInt(credited, 10),
203 "dust", strconv.FormatInt(got-credited, 10),
204 )
205}
206
207// ClaimReward pays the caller the ugnot already credited to them.
208func ClaimReward(cur realm) {
209 mustUser(cur)
210 mustNoCoins()
211 key := cur.Previous().Address().String()
212 share := owedOf(key)
213 if share <= 0 {
214 panic("hearth: nothing owed")
215 }
216 if share > rewardOwed {
217 panic("hearth: owed short")
218 }
219 setOwed(key, 0)
220 rewardOwed -= share
221 send(cur, cur.Previous().Address(), share)
222 chain.Emit("RewardPaid", "addr", key, "share", strconv.FormatInt(share, 10))
223}
224
225// WithdrawReward sends unallocated rounding dust back to the operator.
226// amt 0 withdraws all dust. Credited shares cannot be withdrawn.
227func WithdrawReward(cur realm, amt int64) {
228 mustUser(cur)
229 mustNoCoins()
230 mustAdmin(cur)
231 if amt < 0 {
232 panic("hearth: bad amount")
233 }
234 if amt == 0 {
235 amt = rewardBank
236 }
237 if amt <= 0 || amt > rewardBank {
238 panic("hearth: pool short")
239 }
240 rewardBank -= amt
241 send(cur, admin, amt)
242 chain.Emit("RewardOut", "amount", strconv.FormatInt(amt, 10), "dust", strconv.FormatInt(rewardBank, 10))
243}
244
245// DepositRevenue opens one epoch with the attached ugnot. Raise must be closed.
246func DepositRevenue(cur realm) {
247 mustUser(cur)
248 mustReady()
249 if !closed {
250 panic("hearth: raise open")
251 }
252 got := ugnotSent()
253 if got <= 0 {
254 panic("hearth: no ugnot")
255 }
256 epochCount++
257 id := strconv.Itoa(epochCount)
258 epochs.Set(id, got)
259 revenueBank += got
260 chain.Emit("Revenue", "epoch", id, "amount", strconv.FormatInt(got, 10))
261}
262
263// Claim pays the caller their unpaid share of one epoch.
264func Claim(cur realm, epoch int) {
265 mustUser(cur)
266 mustNoCoins()
267 key := cur.Previous().Address().String()
268 pay(cur, epoch, key, true)
269}
270
271// Settle pays up to maxN unpaid seats for one epoch, in address order.
272// Returns how many seats it marked this call.
273func Settle(cur realm, epoch int, maxN int) int {
274 mustUser(cur)
275 mustNoCoins()
276 if maxN < 1 || maxN > MaxSettle {
277 panic("hearth: bad batch")
278 }
279 _ = epochAmount(epoch)
280 n := 0
281 seats.Iterate("", "", func(key string, _ any) bool {
282 if n >= maxN {
283 return true
284 }
285 if isClaimed(epoch, key) {
286 return false
287 }
288 pay(cur, epoch, key, false)
289 n++
290 return false
291 })
292 chain.Emit("Settled", "epoch", strconv.Itoa(epoch), "n", strconv.Itoa(n))
293 return n
294}
295
296// TransferAdmin moves the operator. The raise ledger is unchanged.
297func TransferAdmin(cur realm, next address) {
298 mustUser(cur)
299 mustNoCoins()
300 mustAdmin(cur)
301 if next == admin || next == address("") {
302 panic("hearth: bad admin")
303 }
304 admin = next
305 chain.Emit("Admin", "admin", admin.String())
306}
307
308func pay(cur realm, epoch int, key string, mustPositive bool) {
309 if isClaimed(epoch, key) {
310 panic("hearth: already claimed")
311 }
312 s, ok := seats.Get(key).(*seat)
313 if !ok {
314 panic("hearth: no seat")
315 }
316 amt := epochAmount(epoch)
317 share := mulDiv(amt, s.ugnot, raised)
318 if share < 0 || share > revenueBank {
319 panic("hearth: revenue short")
320 }
321 if share == 0 && mustPositive {
322 panic("hearth: dust share")
323 }
324 markClaimed(epoch, key)
325 if share == 0 {
326 return
327 }
328 revenueBank -= share
329 send(cur, s.who, share)
330 chain.Emit(
331 "Paid",
332 "epoch", strconv.Itoa(epoch),
333 "addr", key,
334 "share", strconv.FormatInt(share, 10),
335 )
336}
337
338// Raised is the ugnot accepted into shares.
339func Raised() int64 { return raised }
340
341// RaisedBank is principal still held for WithdrawRaise.
342func RaisedBank() int64 { return raisedBank }
343
344// RevenueBank is deposited revenue not yet paid out, including dust.
345func RevenueBank() int64 { return revenueBank }
346
347// RewardBank is the operator reward pool still on the realm.
348func RewardBank() int64 { return rewardBank }
349
350// MinMonthlyUgnot is 5% of contributed ugnot. Deposits may be larger.
351// A larger deposit still splits by contribution share.
352func MinMonthlyUgnot() int64 {
353 if raised <= 0 {
354 return 0
355 }
356 return mulDiv(raised, 5, 100)
357}
358
359// RewardOwed is ugnot credited to contributors and not yet claimed.
360func RewardOwed() int64 { return rewardOwed }
361
362// PoolShare is the ugnot still owed to addr from deposits already split.
363func PoolShare(addr address) int64 { return owedOf(addr.String()) }
364
365// Closed reports whether new contributions are rejected.
366func Closed() bool { return closed }
367
368// Ready reports whether Init has run.
369func Ready() bool { return ready }
370
371// RaiseStartUnix is the first second contributions are accepted, UTC.
372func RaiseStartUnix() int64 { return raiseStart }
373
374// RaiseEndUnix is the first second contributions are rejected, UTC.
375func RaiseEndUnix() int64 { return raiseEnd }
376
377// Admin is the operator address.
378func Admin() address { return admin }
379
380// SeatCount is the number of contributor addresses.
381func SeatCount() int { return seats.Size() }
382
383// EpochCount is the number of revenue deposits.
384func EpochCount() int { return epochCount }
385
386// SeatOf returns ugnot contributed by addr, or 0.
387func SeatOf(addr address) int64 {
388 s, ok := seats.Get(addr.String()).(*seat)
389 if !ok {
390 return 0
391 }
392 return s.ugnot
393}
394
395// HandleOf returns the stored X handle, or empty.
396func HandleOf(addr address) string {
397 h, ok := handles.Get(addr.String()).(string)
398 if !ok {
399 return ""
400 }
401 return h
402}
403
404// EpochAmount returns the ugnot deposited for a 1-based epoch.
405func EpochAmount(epoch int) int64 { return epochAmount(epoch) }
406
407// UserCall reports whether this crossing was a direct EOA MsgCall.
408func UserCall(cur realm) bool {
409 return cur.Previous().IsUserCall()
410}
411
412// AdminCall reports whether the direct caller is the operator.
413func AdminCall(cur realm) bool {
414 return ready && cur.Previous().IsUserCall() && cur.Previous().Address() == admin
415}
416
417// PlanAccept is the contribute decision. A non-empty reason means the call must revert.
418func PlanAccept(got int64) (accept int64, refund int64, reason string) {
419 return planAccept(got)
420}
421
422// ShareOf is the ugnot addr would receive for epoch. Zero if none.
423func ShareOf(epoch int, addr address) int64 {
424 if raised <= 0 || epoch < 1 || epoch > epochCount {
425 return 0
426 }
427 s, ok := seats.Get(addr.String()).(*seat)
428 if !ok {
429 return 0
430 }
431 return mulDiv(epochAmount(epoch), s.ugnot, raised)
432}
433
434// Claimed reports whether addr was already paid or skipped for epoch.
435func Claimed(epoch int, addr address) bool {
436 return isClaimed(epoch, addr.String())
437}
438
439// Summary is a single line for clients:
440// admin|raised|raisedBank|revenueBank|closed|seats|epochs|cap|min|rewardDust|rewardOwed
441func Summary() string {
442 flag := "0"
443 if closed {
444 flag = "1"
445 }
446 return strings.Join([]string{
447 admin.String(),
448 strconv.FormatInt(raised, 10),
449 strconv.FormatInt(raisedBank, 10),
450 strconv.FormatInt(revenueBank, 10),
451 flag,
452 strconv.Itoa(seats.Size()),
453 strconv.Itoa(epochCount),
454 strconv.FormatInt(HardCap, 10),
455 strconv.FormatInt(MinContribute, 10),
456 strconv.FormatInt(rewardBank, 10),
457 strconv.FormatInt(rewardOwed, 10),
458 }, "|")
459}
460
461// Render shows the raise terms and totals. It does not list every seat.
462func Render(_ string) string {
463 state := "open"
464 if !ready {
465 state = "awaiting init"
466 } else if closed {
467 state = "closed"
468 }
469 return strings.Join([]string{
470 "# Commons",
471 "",
472 "One ugnot raise for the operator's gno.land products.",
473 "Revenue deposits after close are split by contributed share.",
474 "Contributions are not refundable. Revenue is only what is deposited.",
475 "",
476 "- State: " + state,
477 "- Raised ugnot: " + strconv.FormatInt(raised, 10),
478 "- Principal still here: " + strconv.FormatInt(raisedBank, 10),
479 "- Revenue still here: " + strconv.FormatInt(revenueBank, 10),
480 "- Seats: " + strconv.Itoa(seats.Size()),
481 "- Epochs: " + strconv.Itoa(epochCount),
482 "- Min ugnot: " + strconv.FormatInt(MinContribute, 10),
483 "- Cap ugnot: " + strconv.FormatInt(HardCap, 10),
484 }, "\n")
485}
486
487func planAccept(got int64) (accept int64, refund int64, reason string) {
488 if !ready {
489 return 0, 0, "not init"
490 }
491 now := time.Now().Unix()
492 if now < raiseStart {
493 return 0, 0, "not started"
494 }
495 if now >= raiseEnd {
496 return 0, 0, "raise ended"
497 }
498 if closed {
499 return 0, 0, "raise closed"
500 }
501 if got <= 0 {
502 return 0, 0, "no ugnot"
503 }
504 room := HardCap - raised
505 if room < MinContribute {
506 return 0, 0, "cap"
507 }
508 accept = got
509 if accept > room {
510 accept = room
511 }
512 if accept < MinContribute {
513 return 0, 0, "below min"
514 }
515 return accept, got - accept, ""
516}
517
518func mustUser(cur realm) {
519 if !cur.Previous().IsUserCall() {
520 panic("hearth: direct user call only")
521 }
522}
523
524func mustReady() {
525 if !ready {
526 panic("hearth: not init")
527 }
528}
529
530func mustAdmin(cur realm) {
531 mustReady()
532 if cur.Previous().Address() != admin {
533 panic("hearth: operator only")
534 }
535}
536
537func mustNoCoins() {
538 if ugnotSent() != 0 || foreignDenom() {
539 panic("hearth: unexpected coins")
540 }
541}
542
543func foreignDenom() bool {
544 for _, c := range unsafe.OriginSend() {
545 if c.Denom != "ugnot" {
546 return true
547 }
548 }
549 return false
550}
551
552func ugnotSent() int64 {
553 coins := unsafe.OriginSend()
554 for _, c := range coins {
555 if c.Denom != "ugnot" {
556 panic("hearth: ugnot only")
557 }
558 }
559 return coins.AmountOf("ugnot")
560}
561
562func epochAmount(epoch int) int64 {
563 if epoch < 1 || epoch > epochCount {
564 panic("hearth: bad epoch")
565 }
566 v, ok := epochs.Get(strconv.Itoa(epoch)).(int64)
567 if !ok {
568 panic("hearth: bad epoch")
569 }
570 return v
571}
572
573func claimKey(epoch int, addr string) string {
574 return strconv.Itoa(epoch) + "|" + addr
575}
576
577func isClaimed(epoch int, addr string) bool {
578 v, ok := claimed.Get(claimKey(epoch, addr)).(bool)
579 return ok && v
580}
581
582func markClaimed(epoch int, addr string) {
583 claimed.Set(claimKey(epoch, addr), true)
584}
585
586func owedOf(key string) int64 {
587 v, ok := owed.Get(key).(int64)
588 if !ok {
589 return 0
590 }
591 return v
592}
593
594func setOwed(key string, n int64) {
595 owed.Set(key, n)
596}
597
598func send(cur realm, to address, amt int64) {
599 if amt <= 0 {
600 panic("hearth: nothing to send")
601 }
602 bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
603 bnk.SendCoins(cur.Address(), to, chain.Coins{{Denom: "ugnot", Amount: amt}})
604}
605
606func cleanHandle(handle string) string {
607 h := strings.TrimPrefix(strings.TrimSpace(handle), "@")
608 if len(h) < 1 || len(h) > 15 {
609 panic("hearth: bad handle")
610 }
611 for i := 0; i < len(h); i++ {
612 c := h[i]
613 ok := (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'
614 if !ok {
615 panic("hearth: bad handle")
616 }
617 }
618 return h
619}
620
621const maxInt64 = int64(^uint64(0) >> 1)
622
623func mulDiv(x, y, d int64) int64 {
624 if x < 0 || y < 0 {
625 panic("hearth: muldiv sign")
626 }
627 if d <= 0 {
628 panic("hearth: muldiv divisor")
629 }
630 if x == 0 || y == 0 {
631 return 0
632 }
633 hi, lo := bits.Mul64(uint64(x), uint64(y))
634 if uint64(d) <= hi {
635 panic("hearth: muldiv overflow")
636 }
637 quo, _ := bits.Div64(hi, lo, uint64(d))
638 if quo > uint64(maxInt64) {
639 panic("hearth: muldiv overflow")
640 }
641 return int64(quo)
642}