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

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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"