Transaction

FC3C8E0CA07FE9…28A66BC7F177

Block 241,885 · index 0 · indexed

Summary

Hash
FC3C8E0CA07FE9B497722F49F1C7D2F8C2C9F049FFC63BA764AA28A66BC7F177
Block
241,885
Size
22055 bytes
Gas used
28,636,042 / 100,000,000
Fee
1000000ugnot
Status
success

Messages

Attached funds
20000000ugnot

Arguments · 11

  1. #1million
  2. #2README.md
  3. #3# million Realm `gno.land/r/g1sw5xklxjjuv0yvuxy5f5s3l3mnj0nqq626a9wr/million/v0`: a 1250 x 800 (one million pixels, 16:10) pay-per-pixel canvas built on `gno.land/p/g1sw5xklxjjuv0yvuxy5f5s3l3mnj0nqq626a9wr/canvas/v0`. Paint from the CLI: ```sh gnokey maketx call -pkgpath gno.land/r/g1sw5xklxjjuv0yvuxy5f5s3l3mnj0nqq626a9wr/million/v0 -func PaintBatch \ -args "10,10,6;11,10,6" -send 100000ugnot \ -gas-fee 1000000ugnot -gas-wanted 5000000 -broadcast -chainid dev -remote 127.0.0.1:26657 <key> ``` The amount sent must equal `Price()` times the number of pixels, and the call must come directly from a user transaction. Payments are forwarded to the admin wallet (`Owner()`) as they arrive; the admin paints for free and can `SetPrice`, `Withdraw` and `TransferOwnership`. Read functions used by clients: `GetWidth`, `GetHeight`, `Price`, `Palette`, `MaxBatchSize`, `Painted`, `Pixel(x, y)`, `Rows(from, to)`, `Version()`, `DirtyRows(since)`. `Version()` counts successful paint calls and `DirtyRows(since)` lists the rows touched after that version, so clients fetch only what changed.
  4. #4gnomod.toml
  5. #5module = "gno.land/r/g1sw5xklxjjuv0yvuxy5f5s3l3mnj0nqq626a9wr/million/v0" gno = "0.9"
  6. #6million.gno
  7. #7// Package million is a pay-per-pixel collaborative canvas: anyone can paint // any pixel of a Width x Height grid in one of 15 colors by sending exactly // Price() ugnot per pixel along with the call. Pixels can be repainted by // anyone, for the same price. Payments are forwarded to the admin wallet // as they arrive, and the admin paints for free. // // The grid logic lives in gno.land/p/g1sw5xklxjjuv0yvuxy5f5s3l3mnj0nqq626a9wr/canvas/v0; this realm adds the // payment guard, the owner controls and the read API used by the web client. package million import ( "chain" "chain/banker" "chain/runtime/unsafe" "strconv" "strings" "gno.land/p/g1sw5xklxjjuv0yvuxy5f5s3l3mnj0nqq626a9wr/canvas/v0" "gno.land/p/nt/ufmt/v0" ) const ( // Width and Height are fixed for the life of this version: changing // them would change the storage layout, so it means a new /vN. // 1250 x 800 is one million pixels in a 16:10 laptop aspect ratio. Width = 1250 Height = 800 // MaxBatch bounds a single PaintBatch call so one tx cannot blow the // block gas limit or overflow the price arithmetic. MaxBatch = 500 // DefaultPrice is the initial price per pixel, in ugnot (0.05 GNOT). DefaultPrice int64 = 50_000 // DefaultAdmin receives every payment, paints for free and holds the // owner controls. Change it before deploying; TransferOwnership moves // it afterwards. DefaultAdmin = "g1sw5xklxjjuv0yvuxy5f5s3l3mnj0nqq626a9wr" Denom = "ugnot" ) var ( board = canvas.New(Width, Height) price = DefaultPrice owner = address(DefaultAdmin) // Stats. revenue int64 // total ugnot ever paid for pixels paints int64 // number of successful Paint/PaintBatch calls // Change tracking, so clients fetch only the rows that moved. // version bumps once per successful paint call; rowVersion holds the // version of the last paint that touched each row (0 = never painted). version int64 rowVersion = make([]int64, Height) ) // Paint colors one pixel. Send exactly Price() ugnot (nothing for the admin). func Paint(cur realm, x, y, color int) { if color <= 0 || color > int(canvas.MaxColor) { panic(canvas.ErrInvalidColor) } paint([]canvas.Pixel{{X: x, Y: y, Color: byte(color)}}, cur) } // PaintBatch colors up to MaxBatch pixels in one call. The argument is // "x,y,color;x,y,color;...". Send exactly Price() * count ugnot (nothing // for the admin). func PaintBatch(cur realm, pixels string) { px, err := canvas.ParsePixels(pixels) if err != nil { panic(err) } paint(px, cur) } // paint takes the realm as its LAST parameter on purpose: a realm first // parameter would make it a crossing function and Previous() would then // be this realm instead of the user. func paint(px []canvas.Pixel, rlm realm) { if !rlm.IsCurrent() { panic("spoofed realm") } // Payment guard: unsafe.OriginSend() describes the coins attached to // the transaction, which only provably landed here when the caller is // a plain user call (no intermediate or ephemeral realm). Keep the two // checks together. unsafe is imported for OriginSend only; caller // identity always comes from the realm handle. if !rlm.Previous().IsUserCall() { panic("must be called directly by a user (maketx call)") } painter := rlm.Previous().Address() n := len(px) if n == 0 { panic("nothing to paint") } if n > MaxBatch { panic(ufmt.Sprintf("too many pixels: %d > %d", n, MaxBatch)) } for _, p := range px { if err := board.Check(p); err != nil { panic(err) } } cost := price * int64(n) if painter == owner { cost = 0 // the admin paints for free } if sent := sentUgnot(unsafe.OriginSend()); sent != cost { panic(ufmt.Sprintf("must send exactly %d%s for %d pixel(s), got %d%s", cost, Denom, n, sent, Denom)) } if cost > 0 { // Forward the payment to the admin right away, so nothing // accumulates in the realm. BankerTypeOriginSend can only move // what this very message sent, which is exactly cost. bnk := banker.NewBanker(banker.BankerTypeOriginSend, rlm) bnk.SendCoins(rlm.Address(), owner, chain.NewCoins(chain.NewCoin(Denom, cost))) } version++ for _, p := range px { board.Set(p) rowVersion[p.Y] = version } revenue += cost paints++ // The pixel list itself is not emitted: event attribute values are // size-capped by the node and a full batch exceeds it. Clients re-read // Rows() after a paint. chain.Emit("Paint", "painter", painter.String(), "count", strconv.Itoa(n), "paid", strconv.Itoa(int(cost))+Denom, ) } // sentUgnot returns the ugnot amount in coins and rejects any other denom, // so a caller cannot pay with something the realm does not price. func sentUgnot(coins chain.Coins) int64 { var amount int64 for _, c := range coins { if c.Denom != Denom { panic("only " + Denom + " is accepted") } amount += c.Amount } return amount } // --- owner controls --- // assertOwner panics unless the verified caller behind rlm is the owner. // The realm is deliberately not the first parameter (see paint); the // leading blank int is the same idiom gno.land/p/nt/ownable uses. func assertOwner(_ int, rlm realm) { if !rlm.IsCurrent() { panic("spoofed realm") } if rlm.Previous().Address() != owner { panic("owner only") } } // SetPrice changes the price per pixel, in ugnot. Zero makes painting free // for everyone. func SetPrice(cur realm, ugnot int64) { assertOwner(0, cur) if ugnot < 0 { panic("price must not be negative") } price = ugnot } // Withdraw sends amount ugnot held by the realm to the given address. // Payments are forwarded on arrival, so this only matters for coins sent // to the realm outside of a paint call. func Withdraw(cur realm, to address, amount int64) { assertOwner(0, cur) if !to.IsValid() { panic("invalid address") } if amount <= 0 { panic("amount must be positive") } bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur) bnk.SendCoins(cur.Address(), to, chain.NewCoins(chain.NewCoin(Denom, amount))) } // TransferOwnership hands the admin role (payments, free painting and the // owner controls) to newOwner. func TransferOwnership(cur realm, newOwner address) { assertOwner(0, cur) if !newOwner.IsValid() { panic("invalid address") } owner = newOwner } // --- read API (used by the web client through vm/qeval) --- func GetWidth() int { return Width } func GetHeight() int { return Height } func Price() int64 { return price } func Owner() address { return owner } func Painted() int { return board.Painted() } func Revenue() int64 { return revenue } func Paints() int64 { return paints } func MaxBatchSize() int { return MaxBatch } // Pixel returns the color index at (x, y); 0 means never painted. func Pixel(x, y int) int { return int(board.Get(x, y)) } // Rows returns rows [from, to) as one hex digit per pixel, Width digits per // row, with no separators. Clients page through the grid with it. func Rows(from, to int) string { return board.Rows(from, to) } // Version is the number of successful paint calls so far. Clients remember // it and ask DirtyRows for what changed since. func Version() int64 { return version } // DirtyRows lists the rows painted after version `since`, as "y,y,y" in // ascending order. DirtyRows(0) is every row that was ever painted, which // is all a fresh client needs to load: the others are blank. func DirtyRows(since int64) string { if since < 0 { since = 0 } var sb strings.Builder for y, v := range rowVersion { if v <= since { continue } if sb.Len() > 0 { sb.WriteByte(',') } sb.WriteString(strconv.Itoa(y)) } return sb.String() } // Palette returns the 16 palette colors as comma-separated CSS hex values, // index 0 being the blank color. func Palette() string { return strings.Join(canvas.Palette[:], ",") } // Render shows the game stats on gnoweb. The path is ignored. func Render(_ string) string { var sb strings.Builder sb.WriteString("# Million Gno\n\n") 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") sb.WriteString("| | |\n|---|---|\n") sb.WriteString(ufmt.Sprintf("| Size | %d x %d |\n", Width, Height)) sb.WriteString(ufmt.Sprintf("| Price per pixel | %d%s |\n", price, Denom)) sb.WriteString(ufmt.Sprintf("| Pixels painted | %d / %d |\n", board.Painted(), Width*Height)) sb.WriteString(ufmt.Sprintf("| Paint calls | %d |\n", paints)) sb.WriteString(ufmt.Sprintf("| Version | %d |\n", version)) sb.WriteString(ufmt.Sprintf("| Revenue | %d%s |\n", revenue, Denom)) sb.WriteString(ufmt.Sprintf("| Admin | %s |\n", owner.String())) sb.WriteString("\n## Palette\n\n") for i := 1; i <= int(canvas.MaxColor); i++ { sb.WriteString(ufmt.Sprintf("- %d: `%s`\n", i, canvas.Palette[i])) } sb.WriteString("\n## Paint\n\n") sb.WriteString("- [Paint one pixel](" + Realm + "$help&func=Paint): `Paint(x, y, color)` with `-send <price>ugnot`\n") sb.WriteString("- [Paint a batch](" + Realm + "$help&func=PaintBatch): `PaintBatch(\"x,y,color;x,y,color\")` with `-send <price*count>ugnot`\n") return sb.String() } // Realm is this realm's gnoweb path. const Realm = "/r/g1sw5xklxjjuv0yvuxy5f5s3l3mnj0nqq626a9wr/million/v0"
  8. #8million_test.gno
  9. #9package million import ( "strings" "testing" "chain" "chain/banker" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) var ( deployer = testutils.TestAddress("deployer") alice = testutils.TestAddress("alice") bob = testutils.TestAddress("bob") ) var realmAddr = chain.PackageAddress("gno.land/r/g1sw5xklxjjuv0yvuxy5f5s3l3mnj0nqq626a9wr/million/v0") // Tests own the admin role through a test address rather than DefaultAdmin. func init() { owner = deployer } // fund credits the realm with what the next paint call claims to send, as // the chain would before running the message, and resets the per-message // origin-send spending limit that the previous call used up. func fund(n int64) { testing.SetOriginSpend(nil) if n > 0 { testing.IssueCoins(realmAddr, ugnot(n)) } } func balanceOf(a address) int64 { return banker.NewReadonlyBanker().GetCoin(a, Denom) } func ugnot(n int64) chain.Coins { return chain.NewCoins(chain.NewCoin(Denom, n)) } // testing.SetRealm only affects the frame that calls it, so it cannot be // wrapped in a helper: every test sets the caller inline. func TestInitialState(t *testing.T) { uassert.Equal(t, Width, GetWidth()) uassert.Equal(t, Height, GetHeight()) uassert.Equal(t, DefaultPrice, Price()) uassert.Equal(t, MaxBatch, MaxBatchSize()) uassert.Equal(t, 0, Pixel(0, 0)) uassert.Equal(t, deployer, Owner()) uassert.Equal(t, 16, len(strings.Split(Palette(), ","))) } func TestPaintOnePixel(cur realm, t *testing.T) { adminBefore := balanceOf(owner) testing.SetRealm(testing.NewUserRealm(alice)) testing.SetOriginSend(ugnot(price)) fund(price) Paint(cross(cur), 10, 20, 6) uassert.Equal(t, 6, Pixel(10, 20)) uassert.Equal(t, 1, Painted()) uassert.Equal(t, price, Revenue()) uassert.Equal(t, int64(1), Paints()) // The payment went straight to the admin, nothing stayed in the realm. uassert.Equal(t, adminBefore+price, balanceOf(owner)) uassert.Equal(t, int64(0), balanceOf(realmAddr)) // Anyone can repaint for the same price; the count does not grow. testing.SetRealm(testing.NewUserRealm(bob)) testing.SetOriginSend(ugnot(price)) fund(price) Paint(cross(cur), 10, 20, 15) uassert.Equal(t, 15, Pixel(10, 20)) uassert.Equal(t, 1, Painted()) uassert.Equal(t, 2*price, Revenue()) uassert.Equal(t, adminBefore+2*price, balanceOf(owner)) } func TestAdminPaintsForFree(cur realm, t *testing.T) { adminBefore := balanceOf(owner) revenueBefore := Revenue() testing.SetRealm(testing.NewUserRealm(owner)) testing.SetOriginSend(nil) Paint(cross(cur), 400, 400, 8) uassert.Equal(t, 8, Pixel(400, 400)) testing.SetRealm(testing.NewUserRealm(owner)) testing.SetOriginSend(nil) PaintBatch(cross(cur), "401,400,9;402,400,10") uassert.Equal(t, 10, Pixel(402, 400)) // Free means exactly free: the admin must not attach coins either. testing.SetRealm(testing.NewUserRealm(owner)) testing.SetOriginSend(ugnot(price)) uassert.AbortsContains(t, cur, "must send exactly 0ugnot", func() { Paint(cross(cur), 403, 400, 1) }) uassert.Equal(t, revenueBefore, Revenue()) uassert.Equal(t, adminBefore, balanceOf(owner)) } func TestPaintRejectsWrongPayment(cur realm, t *testing.T) { before := Painted() testing.SetRealm(testing.NewUserRealm(alice)) testing.SetOriginSend(ugnot(price-1)) uassert.AbortsContains(t, cur, "must send exactly", func() { Paint(cross(cur), 0, 0, 1) }) testing.SetRealm(testing.NewUserRealm(alice)) testing.SetOriginSend(ugnot(price+1)) uassert.AbortsContains(t, cur, "must send exactly", func() { Paint(cross(cur), 0, 0, 1) }) testing.SetRealm(testing.NewUserRealm(alice)) testing.SetOriginSend(nil) uassert.AbortsContains(t, cur, "must send exactly", func() { Paint(cross(cur), 0, 0, 1) }) testing.SetRealm(testing.NewUserRealm(alice)) testing.SetOriginSend(chain.NewCoins(chain.NewCoin("uatom", price))) uassert.AbortsContains(t, cur, "only ugnot is accepted", func() { Paint(cross(cur), 0, 0, 1) }) uassert.Equal(t, before, Painted()) uassert.Equal(t, 0, Pixel(0, 0)) } func TestPaintRejectsInvalidPixels(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(alice)) testing.SetOriginSend(ugnot(price)) uassert.AbortsContains(t, cur, "out of range", func() { Paint(cross(cur), Width, 0, 1) }) uassert.AbortsContains(t, cur, "out of range", func() { Paint(cross(cur), 0, Height, 1) }) uassert.AbortsContains(t, cur, "out of range", func() { Paint(cross(cur), -1, 0, 1) }) uassert.AbortsContains(t, cur, "color must be", func() { Paint(cross(cur), 0, 0, 0) }) uassert.AbortsContains(t, cur, "color must be", func() { Paint(cross(cur), 0, 0, 16) }) uassert.AbortsContains(t, cur, "color must be", func() { Paint(cross(cur), 0, 0, -1) }) } func TestPaintRejectsCodeRealm(cur realm, t *testing.T) { // Regression for the OriginSend bypass: an intermediate realm forwards // the call while the envelope claims the coins were sent. testing.SetRealm(testing.NewCodeRealm("gno.land/r/attacker/wrapper")) testing.SetOriginSend(ugnot(price)) uassert.AbortsContains(t, cur, "directly by a user", func() { Paint(cross(cur), 1, 1, 1) }) uassert.Equal(t, 0, Pixel(1, 1)) } func TestPaintBatch(cur realm, t *testing.T) { before := Painted() testing.SetRealm(testing.NewUserRealm(alice)) testing.SetOriginSend(ugnot(3 * price)) fund(3 * price) PaintBatch(cross(cur), "100,100,1;101,100,2;102,100,3;") uassert.Equal(t, 1, Pixel(100, 100)) uassert.Equal(t, 2, Pixel(101, 100)) uassert.Equal(t, 3, Pixel(102, 100)) uassert.Equal(t, before+3, Painted()) row := Rows(100, 101) uassert.Equal(t, Width, len(row)) uassert.Equal(t, "123", row[100:103]) } func TestPaintBatchIsAtomic(cur realm, t *testing.T) { // One bad pixel rejects the whole batch before anything is written. testing.SetRealm(testing.NewUserRealm(alice)) testing.SetOriginSend(ugnot(2*price)) uassert.AbortsContains(t, cur, "out of range", func() { PaintBatch(cross(cur), "200,200,1;9999,0,1") }) uassert.Equal(t, 0, Pixel(200, 200)) testing.SetRealm(testing.NewUserRealm(alice)) testing.SetOriginSend(ugnot(price)) uassert.AbortsContains(t, cur, "must send exactly", func() { PaintBatch(cross(cur), "200,200,1;201,200,1") }) uassert.Equal(t, 0, Pixel(200, 200)) testing.SetRealm(testing.NewUserRealm(alice)) testing.SetOriginSend(ugnot(price)) uassert.AbortsContains(t, cur, "x,y,color", func() { PaintBatch(cross(cur), "garbage") }) uassert.AbortsContains(t, cur, "x,y,color", func() { PaintBatch(cross(cur), "") }) } func TestPaintBatchTooLarge(cur realm, t *testing.T) { var sb strings.Builder for i := 0; i <= MaxBatch; i++ { if i > 0 { sb.WriteByte(';') } sb.WriteString("0,") sb.WriteString(itoa(i)) sb.WriteString(",1") } testing.SetRealm(testing.NewUserRealm(alice)) testing.SetOriginSend(ugnot(int64(MaxBatch+1)*price)) uassert.AbortsContains(t, cur, "too many pixels", func() { PaintBatch(cross(cur), sb.String()) }) } func TestSetPrice(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(alice)) testing.SetOriginSend(nil) uassert.AbortsWithMessage(t, cur, "owner only", func() { SetPrice(cross(cur), 1) }) testing.SetRealm(testing.NewUserRealm(owner)) testing.SetOriginSend(nil) uassert.AbortsWithMessage(t, cur, "price must not be negative", func() { SetPrice(cross(cur), -1) }) SetPrice(cross(cur), 0) uassert.Equal(t, int64(0), Price()) // Free painting requires sending nothing. testing.SetRealm(testing.NewUserRealm(alice)) testing.SetOriginSend(nil) Paint(cross(cur), 300, 300, 4) uassert.Equal(t, 4, Pixel(300, 300)) testing.SetRealm(testing.NewUserRealm(alice)) testing.SetOriginSend(ugnot(1)) uassert.AbortsContains(t, cur, "must send exactly", func() { Paint(cross(cur), 300, 301, 4) }) testing.SetRealm(testing.NewUserRealm(owner)) testing.SetOriginSend(nil) SetPrice(cross(cur), DefaultPrice) } func TestWithdraw(cur realm, t *testing.T) { testing.IssueCoins(realmAddr, ugnot(1_000)) ro := banker.NewReadonlyBanker() bobBefore := ro.GetCoin(bob, Denom) testing.SetRealm(testing.NewUserRealm(alice)) testing.SetOriginSend(nil) uassert.AbortsWithMessage(t, cur, "owner only", func() { Withdraw(cross(cur), alice, 1) }) testing.SetRealm(testing.NewUserRealm(owner)) testing.SetOriginSend(nil) uassert.AbortsWithMessage(t, cur, "amount must be positive", func() { Withdraw(cross(cur), bob, 0) }) uassert.AbortsWithMessage(t, cur, "invalid address", func() { Withdraw(cross(cur), address("nope"), 1) }) Withdraw(cross(cur), bob, 600) uassert.Equal(t, bobBefore+600, ro.GetCoin(bob, Denom)) uassert.Equal(t, int64(400), ro.GetCoin(realmAddr, Denom)) // Leave the realm empty for the tests that assert on it. Withdraw(cross(cur), bob, 400) } func TestTransferOwnership(cur realm, t *testing.T) { original := owner testing.SetRealm(testing.NewUserRealm(alice)) testing.SetOriginSend(nil) uassert.AbortsWithMessage(t, cur, "owner only", func() { TransferOwnership(cross(cur), alice) }) testing.SetRealm(testing.NewUserRealm(original)) testing.SetOriginSend(nil) uassert.AbortsWithMessage(t, cur, "invalid address", func() { TransferOwnership(cross(cur), address("nope")) }) TransferOwnership(cross(cur), alice) uassert.Equal(t, alice, Owner()) testing.SetRealm(testing.NewUserRealm(alice)) testing.SetOriginSend(nil) TransferOwnership(cross(cur), original) uassert.Equal(t, original, Owner()) } func TestVersionAndDirtyRows(cur realm, t *testing.T) { v := Version() uassert.Equal(t, "", DirtyRows(v)) testing.SetRealm(testing.NewUserRealm(alice)) testing.SetOriginSend(ugnot(3 * price)) fund(3 * price) PaintBatch(cross(cur), "0,700,1;1,700,2;0,701,3") uassert.Equal(t, v+1, Version()) uassert.Equal(t, "700,701", DirtyRows(v)) uassert.Equal(t, "", DirtyRows(Version())) // A second call on one of those rows moves only that row forward. testing.SetRealm(testing.NewUserRealm(bob)) testing.SetOriginSend(ugnot(price)) fund(price) Paint(cross(cur), 5, 701, 4) uassert.Equal(t, v+2, Version()) uassert.Equal(t, "701", DirtyRows(v+1)) uassert.Equal(t, "700,701", DirtyRows(v)) // A rejected paint changes nothing. testing.SetRealm(testing.NewUserRealm(bob)) testing.SetOriginSend(ugnot(1)) uassert.AbortsContains(t, cur, "must send exactly", func() { Paint(cross(cur), 5, 702, 4) }) uassert.Equal(t, v+2, Version()) uassert.Equal(t, "", DirtyRows(v+2)) // Negative "since" behaves like zero: painted rows only, never blank ones. uassert.Equal(t, DirtyRows(0), DirtyRows(-1)) uassert.True(t, !strings.Contains(","+DirtyRows(0)+",", ",799,"), "row 799 was never painted") } func TestRender(t *testing.T) { out := Render("") uassert.True(t, strings.Contains(out, "# Million Gno"), "title") uassert.True(t, strings.Contains(out, "| Size | 1250 x 800 |"), "size row") uassert.True(t, strings.Contains(out, "| Price per pixel | 50000ugnot |"), "price row") uassert.True(t, strings.Contains(out, "| Admin | "+owner.String()+" |"), "admin row") uassert.True(t, strings.Contains(out, "$help&func=PaintBatch"), "help link") } func itoa(n int) string { if n == 0 { return "0" } var b []byte for n > 0 { b = append([]byte{byte('0' + n%10)}, b...) n /= 10 } return string(b) }
  10. #10/gno.MemPackageType
  11. #11 MPUserAll

Result log

msg:0,success:true,log:,events:[]

← Back to block 241,885