1// Package million is a pay-per-pixel collaborative canvas: anyone can paint2// any pixel of a Width x Height grid in one of 15 colors by sending exactly3// Price() ugnot per pixel along with the call. Pixels can be repainted by4// anyone, for the same price. Payments are forwarded to the admin wallet5// 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 the8// payment guard, the owner controls and the read API used by the web client.9package million1011import (12 "chain"13 "chain/banker"14 "chain/runtime/unsafe"15 "strconv"16 "strings"1718 "gno.land/p/g1sw5xklxjjuv0yvuxy5f5s3l3mnj0nqq626a9wr/canvas/v0"19 "gno.land/p/nt/ufmt/v0"20)2122const (23 // Width and Height are fixed for the life of this version: changing24 // 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 = 125027 Height = 8002829 // MaxBatch bounds a single PaintBatch call so one tx cannot blow the30 // block gas limit or overflow the price arithmetic.31 MaxBatch = 5003233 // DefaultPrice is the initial price per pixel, in ugnot (0.05 GNOT).34 DefaultPrice int64 = 50_0003536 // DefaultAdmin receives every payment, paints for free and holds the37 // owner controls. Change it before deploying; TransferOwnership moves38 // it afterwards.39 DefaultAdmin = "g1sw5xklxjjuv0yvuxy5f5s3l3mnj0nqq626a9wr"4041 Denom = "ugnot"42)4344var (45 board = canvas.New(Width, Height)46 price = DefaultPrice47 owner = address(DefaultAdmin)4849 // Stats.50 revenue int64 // total ugnot ever paid for pixels51 paints int64 // number of successful Paint/PaintBatch calls5253 // Change tracking, so clients fetch only the rows that moved.54 // version bumps once per successful paint call; rowVersion holds the55 // version of the last paint that touched each row (0 = never painted).56 version int6457 rowVersion = make([]int64, Height)58)5960// 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}6768// PaintBatch colors up to MaxBatch pixels in one call. The argument is69// "x,y,color;x,y,color;...". Send exactly Price() * count ugnot (nothing70// 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}7879// paint takes the realm as its LAST parameter on purpose: a realm first80// parameter would make it a crossing function and Previous() would then81// 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 to87 // the transaction, which only provably landed here when the caller is88 // a plain user call (no intermediate or ephemeral realm). Keep the two89 // checks together. unsafe is imported for OriginSend only; caller90 // 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 }107108 cost := price * int64(n)109 if painter == owner {110 cost = 0 // the admin paints for free111 }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 nothing117 // accumulates in the realm. BankerTypeOriginSend can only move118 // 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 }122123 version++124 for _, p := range px {125 board.Set(p)126 rowVersion[p.Y] = version127 }128 revenue += cost129 paints++130131 // The pixel list itself is not emitted: event attribute values are132 // size-capped by the node and a full batch exceeds it. Clients re-read133 // 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}140141// 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 int64145 for _, c := range coins {146 if c.Denom != Denom {147 panic("only " + Denom + " is accepted")148 }149 amount += c.Amount150 }151 return amount152}153154// --- owner controls ---155156// assertOwner panics unless the verified caller behind rlm is the owner.157// The realm is deliberately not the first parameter (see paint); the158// 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}167168// SetPrice changes the price per pixel, in ugnot. Zero makes painting free169// 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 = ugnot176}177178// Withdraw sends amount ugnot held by the realm to the given address.179// Payments are forwarded on arrival, so this only matters for coins sent180// 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}192193// TransferOwnership hands the admin role (payments, free painting and the194// 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 = newOwner201}202203// --- read API (used by the web client through vm/qeval) ---204205func 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 }213214// Pixel returns the color index at (x, y); 0 means never painted.215func Pixel(x, y int) int { return int(board.Get(x, y)) }216217// Rows returns rows [from, to) as one hex digit per pixel, Width digits per218// row, with no separators. Clients page through the grid with it.219func Rows(from, to int) string { return board.Rows(from, to) }220221// Version is the number of successful paint calls so far. Clients remember222// it and ask DirtyRows for what changed since.223func Version() int64 { return version }224225// DirtyRows lists the rows painted after version `since`, as "y,y,y" in226// ascending order. DirtyRows(0) is every row that was ever painted, which227// is all a fresh client needs to load: the others are blank.228func DirtyRows(since int64) string {229 if since < 0 {230 since = 0231 }232 var sb strings.Builder233 for y, v := range rowVersion {234 if v <= since {235 continue236 }237 if sb.Len() > 0 {238 sb.WriteByte(',')239 }240 sb.WriteString(strconv.Itoa(y))241 }242 return sb.String()243}244245// 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}250251// Render shows the game stats on gnoweb. The path is ignored.252func Render(_ string) string {253 var sb strings.Builder254 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}273274// Realm is this realm's gnoweb path.275const Realm = "/r/g1sw5xklxjjuv0yvuxy5f5s3l3mnj0nqq626a9wr/million/v0"276DirtyRows(since int64) string
GetHeight() int
GetWidth() int
MaxBatchSize() int
Owner() string
Paint(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}, x int, y int, color int)
PaintBatch(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}, pixels string)
Painted() int
Paints() int64
Palette() string
Pixel(x int, y int) int
Price() int64
Render(string) string
Revenue() int64
Rows(from int, to int) string
SetPrice(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}, ugnot int64)
TransferOwnership(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}, newOwner string)
Version() int64
Withdraw(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}, to string, amount 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.