1// Package hearth escrows a single ecosystem raise in ugnot and later2// splits revenue deposits across contributors by paid share.3//4// Contribute accepts ugnot while the raise is open. Each call must be at5// least MinContribute. The running total cannot pass HardCap; excess ugnot6// is sent back to the caller. Hitting HardCap closes the raise.7//8// Raise principal (WithdrawRaise) and revenue (DepositRevenue) are separate9// ledgers. Withdrawing the raise does not change shares. After Close,10// DepositRevenue opens an epoch. Settle pays the next batch of unpaid11// wallets for that epoch. Claim pays one caller. Integer dust stays in the12// realm and is not withdrawable.13package hearth1415import (16 "chain"17 "chain/banker"18 "chain/runtime/unsafe"19 "math/bits"20 "strconv"21 "strings"22 "time"2324 "gno.land/p/nt/avl/v0"25)2627const (28 // MinContribute is the smallest ugnot a Contribute call may accept (500 GNOT).29 MinContribute int64 = 500_000_00030 // HardCap is the maximum ugnot the raise will accept (150_000 GNOT).31 HardCap int64 = 150_000_000_00032 // MaxSeats is HardCap / MinContribute. A new address past this panics.33 MaxSeats int = 30034 // MaxSettle is the most wallets one Settle call will pay.35 MaxSettle int = 2036)3738// 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 = 179018280042 raiseEnd int64 = 179078760043)4445type seat struct {46 who address47 ugnot int6448}4950var (51 admin address52 ready bool53 closed bool54 raised int6455 raisedBank int6456 revenueBank int6457 rewardBank int6458 rewardOwed int6459 epochCount int60 seats *avl.Tree61 epochs *avl.Tree62 claimed *avl.Tree63 handles *avl.Tree64 owed *avl.Tree65)6667func init() {68 seats = avl.NewTree()69 epochs = avl.NewTree()70 claimed = avl.NewTree()71 handles = avl.NewTree()72 owed = avl.NewTree()73}7475// 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 = true84 chain.Emit("Init", "admin", admin.String())85}8687// 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 return96 }97 handles.Set(key, h)98 chain.Emit("Handle", "addr", key, "handle", h)99}100101// 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 += accept117 } else {118 if seats.Size() >= MaxSeats {119 panic("hearth: seats full")120 }121 seats.Set(key, &seat{who: caller, ugnot: accept})122 }123 raised += accept124 raisedBank += accept125 if raised == HardCap {126 closed = true127 }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}139140// 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 = true152 chain.Emit("Closed", "raised", strconv.FormatInt(raised, 10))153}154155// 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 := raisedBank165 raisedBank = 0166 send(cur, admin, amt)167 chain.Emit("RaiseWithdrawn", "amount", strconv.FormatInt(amt, 10))168}169170// DepositReward takes the operator's ugnot and credits each contributor171// 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 false189 }190 setOwed(key, owedOf(key)+share)191 credited += share192 return false193 })194 if credited > got {195 panic("hearth: credit")196 }197 rewardOwed += credited198 rewardBank += got - credited199 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}206207// 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 -= share221 send(cur, cur.Previous().Address(), share)222 chain.Emit("RewardPaid", "addr", key, "share", strconv.FormatInt(share, 10))223}224225// 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 = rewardBank236 }237 if amt <= 0 || amt > rewardBank {238 panic("hearth: pool short")239 }240 rewardBank -= amt241 send(cur, admin, amt)242 chain.Emit("RewardOut", "amount", strconv.FormatInt(amt, 10), "dust", strconv.FormatInt(rewardBank, 10))243}244245// 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 += got260 chain.Emit("Revenue", "epoch", id, "amount", strconv.FormatInt(got, 10))261}262263// 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}270271// 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 := 0281 seats.Iterate("", "", func(key string, _ any) bool {282 if n >= maxN {283 return true284 }285 if isClaimed(epoch, key) {286 return false287 }288 pay(cur, epoch, key, false)289 n++290 return false291 })292 chain.Emit("Settled", "epoch", strconv.Itoa(epoch), "n", strconv.Itoa(n))293 return n294}295296// 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 = next305 chain.Emit("Admin", "admin", admin.String())306}307308func 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 return327 }328 revenueBank -= share329 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}337338// Raised is the ugnot accepted into shares.339func Raised() int64 { return raised }340341// RaisedBank is principal still held for WithdrawRaise.342func RaisedBank() int64 { return raisedBank }343344// RevenueBank is deposited revenue not yet paid out, including dust.345func RevenueBank() int64 { return revenueBank }346347// RewardBank is the operator reward pool still on the realm.348func RewardBank() int64 { return rewardBank }349350// 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 0355 }356 return mulDiv(raised, 5, 100)357}358359// RewardOwed is ugnot credited to contributors and not yet claimed.360func RewardOwed() int64 { return rewardOwed }361362// PoolShare is the ugnot still owed to addr from deposits already split.363func PoolShare(addr address) int64 { return owedOf(addr.String()) }364365// Closed reports whether new contributions are rejected.366func Closed() bool { return closed }367368// Ready reports whether Init has run.369func Ready() bool { return ready }370371// RaiseStartUnix is the first second contributions are accepted, UTC.372func RaiseStartUnix() int64 { return raiseStart }373374// RaiseEndUnix is the first second contributions are rejected, UTC.375func RaiseEndUnix() int64 { return raiseEnd }376377// Admin is the operator address.378func Admin() address { return admin }379380// SeatCount is the number of contributor addresses.381func SeatCount() int { return seats.Size() }382383// EpochCount is the number of revenue deposits.384func EpochCount() int { return epochCount }385386// 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 0391 }392 return s.ugnot393}394395// 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 h402}403404// EpochAmount returns the ugnot deposited for a 1-based epoch.405func EpochAmount(epoch int) int64 { return epochAmount(epoch) }406407// UserCall reports whether this crossing was a direct EOA MsgCall.408func UserCall(cur realm) bool {409 return cur.Previous().IsUserCall()410}411412// AdminCall reports whether the direct caller is the operator.413func AdminCall(cur realm) bool {414 return ready && cur.Previous().IsUserCall() && cur.Previous().Address() == admin415}416417// 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}421422// 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 0426 }427 s, ok := seats.Get(addr.String()).(*seat)428 if !ok {429 return 0430 }431 return mulDiv(epochAmount(epoch), s.ugnot, raised)432}433434// 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}438439// Summary is a single line for clients:440// admin|raised|raisedBank|revenueBank|closed|seats|epochs|cap|min|rewardDust|rewardOwed441func 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}460461// 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}486487func 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 - raised505 if room < MinContribute {506 return 0, 0, "cap"507 }508 accept = got509 if accept > room {510 accept = room511 }512 if accept < MinContribute {513 return 0, 0, "below min"514 }515 return accept, got - accept, ""516}517518func mustUser(cur realm) {519 if !cur.Previous().IsUserCall() {520 panic("hearth: direct user call only")521 }522}523524func mustReady() {525 if !ready {526 panic("hearth: not init")527 }528}529530func mustAdmin(cur realm) {531 mustReady()532 if cur.Previous().Address() != admin {533 panic("hearth: operator only")534 }535}536537func mustNoCoins() {538 if ugnotSent() != 0 || foreignDenom() {539 panic("hearth: unexpected coins")540 }541}542543func foreignDenom() bool {544 for _, c := range unsafe.OriginSend() {545 if c.Denom != "ugnot" {546 return true547 }548 }549 return false550}551552func 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}561562func 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 v571}572573func claimKey(epoch int, addr string) string {574 return strconv.Itoa(epoch) + "|" + addr575}576577func isClaimed(epoch int, addr string) bool {578 v, ok := claimed.Get(claimKey(epoch, addr)).(bool)579 return ok && v580}581582func markClaimed(epoch int, addr string) {583 claimed.Set(claimKey(epoch, addr), true)584}585586func owedOf(key string) int64 {587 v, ok := owed.Get(key).(int64)588 if !ok {589 return 0590 }591 return v592}593594func setOwed(key string, n int64) {595 owed.Set(key, n)596}597598func 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}605606func 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 h619}620621const maxInt64 = int64(^uint64(0) >> 1)622623func 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 0632 }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}643Admin() string
AdminCall(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}) bool
Claim(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, epoch int)
Claimed(epoch int, addr string) bool
ClaimReward(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string})
Close(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string})
Closed() bool
Contribute(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string})
DepositRevenue(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string})
DepositReward(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string})
EpochAmount(epoch int) int64
EpochCount() int
HandleOf(addr string) string
Init(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string})
MinMonthlyUgnot() int64
PlanAccept(got int64) (accept int64, refund int64, reason string)
PoolShare(addr string) int64
Raised() int64
RaisedBank() int64
RaiseEndUnix() int64
RaiseStartUnix() int64
Ready() bool
Render(string) string
RevenueBank() int64
RewardBank() int64
RewardOwed() int64
SeatCount() int
SeatOf(addr string) int64
SetHandle(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, handle string)
Settle(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, epoch int, maxN int) int
ShareOf(epoch int, addr string) int64
Summary() string
TransferAdmin(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, next string)
UserCall(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}) bool
WithdrawRaise(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string})
WithdrawReward(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, amt int64)
Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.
vm/qrender output, sanitized (docs/render-security.md) and displayed in an empty-sandbox iframe — scripts, forms and popups cannot run. Links stay inert in-preview; right-click to open.