million.gno
9.02 Kb · 275 lines
1// Package million is a pay-per-pixel collaborative canvas: anyone can paint
2// any pixel of a Width x Height grid in one of 15 colors by sending exactly
3// Price() ugnot per pixel along with the call. Pixels can be repainted by
4// anyone, for the same price. Payments are forwarded to the admin wallet
5// as they arrive, and the admin paints for free.
6//
7// The grid logic lives in gno.land/p/g1sw5xklxjjuv0yvuxy5f5s3l3mnj0nqq626a9wr/canvas/v0; this realm adds the
8// payment guard, the owner controls and the read API used by the web client.
9package million
10
11import (
12 "chain"
13 "chain/banker"
14 "chain/runtime/unsafe"
15 "strconv"
16 "strings"
17
18 "gno.land/p/g1sw5xklxjjuv0yvuxy5f5s3l3mnj0nqq626a9wr/canvas/v0"
19 "gno.land/p/nt/ufmt/v0"
20)
21
22const (
23 // Width and Height are fixed for the life of this version: changing
24 // them would change the storage layout, so it means a new /vN.
25 // 1250 x 800 is one million pixels in a 16:10 laptop aspect ratio.
26 Width = 1250
27 Height = 800
28
29 // MaxBatch bounds a single PaintBatch call so one tx cannot blow the
30 // block gas limit or overflow the price arithmetic.
31 MaxBatch = 500
32
33 // DefaultPrice is the initial price per pixel, in ugnot (0.05 GNOT).
34 DefaultPrice int64 = 50_000
35
36 // DefaultAdmin receives every payment, paints for free and holds the
37 // owner controls. Change it before deploying; TransferOwnership moves
38 // it afterwards.
39 DefaultAdmin = "g1sw5xklxjjuv0yvuxy5f5s3l3mnj0nqq626a9wr"
40
41 Denom = "ugnot"
42)
43
44var (
45 board = canvas.New(Width, Height)
46 price = DefaultPrice
47 owner = address(DefaultAdmin)
48
49 // Stats.
50 revenue int64 // total ugnot ever paid for pixels
51 paints int64 // number of successful Paint/PaintBatch calls
52
53 // Change tracking, so clients fetch only the rows that moved.
54 // version bumps once per successful paint call; rowVersion holds the
55 // version of the last paint that touched each row (0 = never painted).
56 version int64
57 rowVersion = make([]int64, Height)
58)
59
60// Paint colors one pixel. Send exactly Price() ugnot (nothing for the admin).
61func Paint(cur realm, x, y, color int) {
62 if color <= 0 || color > int(canvas.MaxColor) {
63 panic(canvas.ErrInvalidColor)
64 }
65 paint([]canvas.Pixel{{X: x, Y: y, Color: byte(color)}}, cur)
66}
67
68// PaintBatch colors up to MaxBatch pixels in one call. The argument is
69// "x,y,color;x,y,color;...". Send exactly Price() * count ugnot (nothing
70// for the admin).
71func PaintBatch(cur realm, pixels string) {
72 px, err := canvas.ParsePixels(pixels)
73 if err != nil {
74 panic(err)
75 }
76 paint(px, cur)
77}
78
79// paint takes the realm as its LAST parameter on purpose: a realm first
80// parameter would make it a crossing function and Previous() would then
81// be this realm instead of the user.
82func paint(px []canvas.Pixel, rlm realm) {
83 if !rlm.IsCurrent() {
84 panic("spoofed realm")
85 }
86 // Payment guard: unsafe.OriginSend() describes the coins attached to
87 // the transaction, which only provably landed here when the caller is
88 // a plain user call (no intermediate or ephemeral realm). Keep the two
89 // checks together. unsafe is imported for OriginSend only; caller
90 // identity always comes from the realm handle.
91 if !rlm.Previous().IsUserCall() {
92 panic("must be called directly by a user (maketx call)")
93 }
94 painter := rlm.Previous().Address()
95 n := len(px)
96 if n == 0 {
97 panic("nothing to paint")
98 }
99 if n > MaxBatch {
100 panic(ufmt.Sprintf("too many pixels: %d > %d", n, MaxBatch))
101 }
102 for _, p := range px {
103 if err := board.Check(p); err != nil {
104 panic(err)
105 }
106 }
107
108 cost := price * int64(n)
109 if painter == owner {
110 cost = 0 // the admin paints for free
111 }
112 if sent := sentUgnot(unsafe.OriginSend()); sent != cost {
113 panic(ufmt.Sprintf("must send exactly %d%s for %d pixel(s), got %d%s", cost, Denom, n, sent, Denom))
114 }
115 if cost > 0 {
116 // Forward the payment to the admin right away, so nothing
117 // accumulates in the realm. BankerTypeOriginSend can only move
118 // what this very message sent, which is exactly cost.
119 bnk := banker.NewBanker(banker.BankerTypeOriginSend, rlm)
120 bnk.SendCoins(rlm.Address(), owner, chain.NewCoins(chain.NewCoin(Denom, cost)))
121 }
122
123 version++
124 for _, p := range px {
125 board.Set(p)
126 rowVersion[p.Y] = version
127 }
128 revenue += cost
129 paints++
130
131 // The pixel list itself is not emitted: event attribute values are
132 // size-capped by the node and a full batch exceeds it. Clients re-read
133 // Rows() after a paint.
134 chain.Emit("Paint",
135 "painter", painter.String(),
136 "count", strconv.Itoa(n),
137 "paid", strconv.Itoa(int(cost))+Denom,
138 )
139}
140
141// sentUgnot returns the ugnot amount in coins and rejects any other denom,
142// so a caller cannot pay with something the realm does not price.
143func sentUgnot(coins chain.Coins) int64 {
144 var amount int64
145 for _, c := range coins {
146 if c.Denom != Denom {
147 panic("only " + Denom + " is accepted")
148 }
149 amount += c.Amount
150 }
151 return amount
152}
153
154// --- owner controls ---
155
156// assertOwner panics unless the verified caller behind rlm is the owner.
157// The realm is deliberately not the first parameter (see paint); the
158// leading blank int is the same idiom gno.land/p/nt/ownable uses.
159func assertOwner(_ int, rlm realm) {
160 if !rlm.IsCurrent() {
161 panic("spoofed realm")
162 }
163 if rlm.Previous().Address() != owner {
164 panic("owner only")
165 }
166}
167
168// SetPrice changes the price per pixel, in ugnot. Zero makes painting free
169// for everyone.
170func SetPrice(cur realm, ugnot int64) {
171 assertOwner(0, cur)
172 if ugnot < 0 {
173 panic("price must not be negative")
174 }
175 price = ugnot
176}
177
178// Withdraw sends amount ugnot held by the realm to the given address.
179// Payments are forwarded on arrival, so this only matters for coins sent
180// to the realm outside of a paint call.
181func Withdraw(cur realm, to address, amount int64) {
182 assertOwner(0, cur)
183 if !to.IsValid() {
184 panic("invalid address")
185 }
186 if amount <= 0 {
187 panic("amount must be positive")
188 }
189 bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
190 bnk.SendCoins(cur.Address(), to, chain.NewCoins(chain.NewCoin(Denom, amount)))
191}
192
193// TransferOwnership hands the admin role (payments, free painting and the
194// owner controls) to newOwner.
195func TransferOwnership(cur realm, newOwner address) {
196 assertOwner(0, cur)
197 if !newOwner.IsValid() {
198 panic("invalid address")
199 }
200 owner = newOwner
201}
202
203// --- read API (used by the web client through vm/qeval) ---
204
205func GetWidth() int { return Width }
206func GetHeight() int { return Height }
207func Price() int64 { return price }
208func Owner() address { return owner }
209func Painted() int { return board.Painted() }
210func Revenue() int64 { return revenue }
211func Paints() int64 { return paints }
212func MaxBatchSize() int { return MaxBatch }
213
214// Pixel returns the color index at (x, y); 0 means never painted.
215func Pixel(x, y int) int { return int(board.Get(x, y)) }
216
217// Rows returns rows [from, to) as one hex digit per pixel, Width digits per
218// row, with no separators. Clients page through the grid with it.
219func Rows(from, to int) string { return board.Rows(from, to) }
220
221// Version is the number of successful paint calls so far. Clients remember
222// it and ask DirtyRows for what changed since.
223func Version() int64 { return version }
224
225// DirtyRows lists the rows painted after version `since`, as "y,y,y" in
226// ascending order. DirtyRows(0) is every row that was ever painted, which
227// is all a fresh client needs to load: the others are blank.
228func DirtyRows(since int64) string {
229 if since < 0 {
230 since = 0
231 }
232 var sb strings.Builder
233 for y, v := range rowVersion {
234 if v <= since {
235 continue
236 }
237 if sb.Len() > 0 {
238 sb.WriteByte(',')
239 }
240 sb.WriteString(strconv.Itoa(y))
241 }
242 return sb.String()
243}
244
245// Palette returns the 16 palette colors as comma-separated CSS hex values,
246// index 0 being the blank color.
247func Palette() string {
248 return strings.Join(canvas.Palette[:], ",")
249}
250
251// Render shows the game stats on gnoweb. The path is ignored.
252func Render(_ string) string {
253 var sb strings.Builder
254 sb.WriteString("# Million Gno\n\n")
255 sb.WriteString("A pay-per-pixel canvas. Pick a color, pay the price, own the pixel until someone repaints it. Payments go to the admin wallet, which paints for free.\n\n")
256 sb.WriteString("| | |\n|---|---|\n")
257 sb.WriteString(ufmt.Sprintf("| Size | %d x %d |\n", Width, Height))
258 sb.WriteString(ufmt.Sprintf("| Price per pixel | %d%s |\n", price, Denom))
259 sb.WriteString(ufmt.Sprintf("| Pixels painted | %d / %d |\n", board.Painted(), Width*Height))
260 sb.WriteString(ufmt.Sprintf("| Paint calls | %d |\n", paints))
261 sb.WriteString(ufmt.Sprintf("| Version | %d |\n", version))
262 sb.WriteString(ufmt.Sprintf("| Revenue | %d%s |\n", revenue, Denom))
263 sb.WriteString(ufmt.Sprintf("| Admin | %s |\n", owner.String()))
264 sb.WriteString("\n## Palette\n\n")
265 for i := 1; i <= int(canvas.MaxColor); i++ {
266 sb.WriteString(ufmt.Sprintf("- %d: `%s`\n", i, canvas.Palette[i]))
267 }
268 sb.WriteString("\n## Paint\n\n")
269 sb.WriteString("- [Paint one pixel](" + Realm + "$help&func=Paint): `Paint(x, y, color)` with `-send <price>ugnot`\n")
270 sb.WriteString("- [Paint a batch](" + Realm + "$help&func=PaintBatch): `PaintBatch(\"x,y,color;x,y,color\")` with `-send <price*count>ugnot`\n")
271 return sb.String()
272}
273
274// Realm is this realm's gnoweb path.
275const Realm = "/r/g1sw5xklxjjuv0yvuxy5f5s3l3mnj0nqq626a9wr/million/v0"