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

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}