adtreasury.gno
6.61 Kb · 198 lines
1// Package adtreasury is the ad station's own treasury (jae, his terminal, 2026-10-14: "it goes to a treasury
2// specifically for the ad station with its own controls/governance" → "just me for now but make it upgradeable").
3//
4// It holds GNOT: what the ad station's weekly auctions earn (r/…/adstation sends each settled week's winning bids
5// here), and whatever anyone sends to its address. One controller spends it, each spend on the record with its memo.
6// The controller is Jae's cold key at first; the role moves by a two-step hand-off (SetController names, AcceptController
7// takes), and the one named may be a realm — a council or a vote later calls AcceptController and Spend through a
8// crossing call, so the governance can be upgraded without moving the money.
9//
10// Immutable, not private (gnomod.toml).
11package adtreasury
12
13import (
14 "chain"
15 "chain/banker"
16 "strconv"
17 "strings"
18 "time"
19)
20
21const (
22 pkgPath = "gno.land/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/adtreasury" // must match gnomod.toml
23 GNOT = "ugnot"
24 maxMemo = 120
25 listMax = 50 // spends the page lists
26)
27
28// Payment is one spend out of the treasury, on the record for good.
29type Payment struct {
30 ID int64
31 To address
32 Amount int64 // ugnot
33 Memo string
34 By address // the controller that sent it
35 At int64 // unix seconds
36}
37
38var (
39 controller = address("g1ecsuj0q572jr0dhu29q9njtnmw03hyu7tyyvv6") // Jae's cold key (the realm ownership rule)
40 nextController address
41 spends []Payment
42 spent int64
43)
44
45func init(cur realm) {
46 if cur.Address() != chain.PackageAddress(pkgPath) {
47 panic("adtreasury: deployed at a path other than pkgPath")
48 }
49}
50
51// ---------------------------------------------------------------- reading --
52
53// Address is where the treasury receives: send GNOT here.
54func Address() address { return chain.PackageAddress(pkgPath) }
55
56// Balance is the GNOT the treasury holds, in ugnot.
57func Balance() int64 {
58 return banker.NewReadonlyBanker().GetCoin(Address(), GNOT)
59}
60
61func Controller() address { return controller }
62func NextController() address { return nextController }
63func Spent() int64 { return spent }
64func Count() int { return len(spends) }
65
66// Get is a copy of spend id (1-based).
67func Get(id int64) Payment {
68 if id < 1 || id > int64(len(spends)) {
69 panic("adtreasury: no such spend")
70 }
71 return spends[id-1]
72}
73
74// ---------------------------------------------------------------- writing --
75
76func caller(cur realm) address {
77 if !cur.IsCurrent() {
78 panic("adtreasury: spoofed realm")
79 }
80 return cur.Previous().Address()
81}
82
83func mustControl(cur realm) address {
84 who := caller(cur)
85 if who != controller {
86 panic("adtreasury: only the controller")
87 }
88 return who
89}
90
91// Spend pays ugnot to an address, by the controller, with a memo kept on the record (plain text, at most 120
92// characters). Returns the spend's id.
93func Spend(cur realm, to address, ugnot int64, memo string) int64 {
94 by := mustControl(cur)
95 if !to.IsValid() {
96 panic("adtreasury: not an address")
97 }
98 if to == Address() {
99 panic("adtreasury: the treasury cannot pay itself")
100 }
101 if ugnot <= 0 {
102 panic("adtreasury: the amount must be positive")
103 }
104 if ugnot > Balance() {
105 panic("adtreasury: the treasury holds only " + strconv.FormatInt(Balance(), 10) + " ugnot")
106 }
107 plain(memo)
108 banker.NewBanker(banker.BankerTypeRealmSend, cur).SendCoins(Address(), to, chain.Coins{{GNOT, ugnot}})
109 spent += ugnot
110 id := int64(len(spends)) + 1
111 spends = append(spends, Payment{ID: id, To: to, Amount: ugnot, Memo: memo, By: by, At: time.Now().Unix()})
112 chain.Emit("Spent", "id", strconv.FormatInt(id, 10), "to", to.String(), "ugnot", strconv.FormatInt(ugnot, 10), "by", by.String())
113 return id
114}
115
116// SetController names the next controller; nothing changes until they AcceptController. "" withdraws the offer.
117func SetController(cur realm, to address) {
118 mustControl(cur)
119 if to != "" && !to.IsValid() {
120 panic("adtreasury: not an address")
121 }
122 nextController = to
123 chain.Emit("ControllerNamed", "next", to.String())
124}
125
126// AcceptController takes the controller role, by the one named (a user or a realm).
127func AcceptController(cur realm) {
128 who := caller(cur)
129 if nextController == "" || who != nextController {
130 panic("adtreasury: not the one named")
131 }
132 prev := controller
133 controller, nextController = who, ""
134 chain.Emit("ControllerChanged", "from", prev.String(), "to", who.String())
135}
136
137// plain refuses text the page would render as markup.
138func plain(s string) {
139 if len(s) > maxMemo {
140 panic("adtreasury: the memo is at most " + strconv.Itoa(maxMemo) + " characters")
141 }
142 for i := 0; i < len(s); i++ {
143 if s[i] < 0x20 || s[i] == 0x7f || strings.IndexByte("[]()<>`*_#|\\!~", s[i]) >= 0 {
144 panic("adtreasury: the memo is plain text without markup")
145 }
146 }
147}
148
149// ---------------------------------------------------------------- pages --
150
151// Render: "" the treasury and its last spends; "json" its figures.
152func Render(path string) string {
153 switch path {
154 case "":
155 return home()
156 case "json":
157 return `{"address":"` + Address().String() + `","balance":` + strconv.FormatInt(Balance(), 10) + `,"spent":` + strconv.FormatInt(spent, 10) +
158 `,"controller":"` + controller.String() + `","next":"` + nextController.String() + `","spends":` + strconv.Itoa(len(spends)) + `}`
159 }
160 return "404"
161}
162
163func home() string {
164 var b strings.Builder
165 b.WriteString("# The ad station's treasury\n\n")
166 b.WriteString("What gno.world's ad station earns: each week's winning bids, from the station's auction. One controller spends it, every spend on the record below.\n\n")
167 b.WriteString("- holds: " + gnot(Balance()) + "\n")
168 b.WriteString("- spent so far: " + gnot(spent) + "\n")
169 b.WriteString("- controller: " + controller.String() + "\n")
170 if nextController != "" {
171 b.WriteString("- named to take over: " + nextController.String() + "\n")
172 }
173 b.WriteString("- address: " + Address().String() + "\n\n")
174 if len(spends) == 0 {
175 b.WriteString("_Nothing spent yet._\n")
176 return b.String()
177 }
178 b.WriteString("## Spends\n\n")
179 for i, n := len(spends)-1, 0; i >= 0 && n < listMax; i, n = i-1, n+1 {
180 s := spends[i]
181 b.WriteString("- #" + strconv.FormatInt(s.ID, 10) + " " + time.Unix(s.At, 0).UTC().Format("2006-01-02") + " · " + gnot(s.Amount) + " to " + s.To.String())
182 if s.Memo != "" {
183 b.WriteString(" · " + s.Memo)
184 }
185 b.WriteString("\n")
186 }
187 return b.String()
188}
189
190// gnot formats ugnot as GNOT, the trailing zeros trimmed.
191func gnot(ugnot int64) string {
192 whole, frac := ugnot/1_000_000, ugnot%1_000_000
193 if frac == 0 {
194 return strconv.FormatInt(whole, 10) + " GNOT"
195 }
196 f := strconv.FormatInt(1_000_000+frac, 10)[1:]
197 return strconv.FormatInt(whole, 10) + "." + strings.TrimRight(f, "0") + " GNOT"
198}