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

million.gno

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