Transaction
2C40C73405C2C6…5203315C115E
Block 242,662 · index 1 · indexed
Summary
- Hash
- 2C40C73405C2C65AD1313282B2694B34332420AD5E2E17D6506C5203315C115E
- Block
- 242,662
- Size
- 23478 bytes
- Gas used
- 30,380,713 / 100,000,000
- Fee
- 1000000 ugnot
- Status
- success
Messages
- Attached funds
- 20000000 ugnot
Arguments · 11
- #1gnodraw
- #2README.md
- #3# gnodraw Realm `gno.land/r/g1v7s3j506wqul2zwt90awgjphpx4ztwga0926f7/gnodraw/v0`: a 1250 x 800 (one million pixels, 16:10) pay-per-pixel canvas built on `gno.land/p/g1v7s3j506wqul2zwt90awgjphpx4ztwga0926f7/canvas/v0`. Paint from the CLI: ```sh gnokey maketx call -pkgpath gno.land/r/g1v7s3j506wqul2zwt90awgjphpx4ztwga0926f7/gnodraw/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`, `SetMaxBatch`, `Withdraw` and `TransferOwnership` (all params are owner-settable at runtime). 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.
- #4gnodraw.gno
- #5// Package gnodraw 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/g1v7s3j506wqul2zwt90awgjphpx4ztwga0926f7/canvas/v0; this realm adds the // payment guard, the owner controls and the read API used by the web client. package gnodraw import ( "chain" "chain/banker" "chain/runtime/unsafe" "strconv" "strings" "gno.land/p/g1v7s3j506wqul2zwt90awgjphpx4ztwga0926f7/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 // DefaultMaxBatch bounds a single PaintBatch call so one tx cannot blow // the block gas limit or overflow the price arithmetic. The owner can // change it at runtime with SetMaxBatch, up to HardMaxBatch. DefaultMaxBatch = 5000 HardMaxBatch = 20000 // 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 = "g1v7s3j506wqul2zwt90awgjphpx4ztwga0926f7" Denom = "ugnot" ) var ( board = canvas.New(Width, Height) price = DefaultPrice maxBatch = DefaultMaxBatch 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 MaxBatchSize() 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 } // SetMaxBatch changes the maximum pixels per PaintBatch call, 1..HardMaxBatch. func SetMaxBatch(cur realm, n int) { assertOwner(0, cur) if n < 1 || n > HardMaxBatch { panic(ufmt.Sprintf("max batch must be between 1 and %d", HardMaxBatch)) } maxBatch = n } // 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/g1v7s3j506wqul2zwt90awgjphpx4ztwga0926f7/gnodraw/v0"
- #6gnodraw_test.gno
- #7package gnodraw 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/g1v7s3j506wqul2zwt90awgjphpx4ztwga0926f7/gnodraw/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, DefaultMaxBatch, 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 TestSetMaxBatch(cur realm, t *testing.T) { orig := MaxBatchSize() testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsWithMessage(t, cur, "owner only", func() { SetMaxBatch(cross(cur), 100) }) testing.SetRealm(testing.NewUserRealm(owner)) uassert.AbortsContains(t, cur, "max batch must be between", func() { SetMaxBatch(cross(cur), 0) }) uassert.AbortsContains(t, cur, "max batch must be between", func() { SetMaxBatch(cross(cur), HardMaxBatch+1) }) SetMaxBatch(cross(cur), 3) uassert.Equal(t, 3, MaxBatchSize()) // The new limit is enforced by PaintBatch. testing.SetRealm(testing.NewUserRealm(alice)) testing.SetOriginSend(ugnot(4 * price)) fund(4 * price) uassert.AbortsContains(t, cur, "too many pixels", func() { PaintBatch(cross(cur), "0,500,1;1,500,1;2,500,1;3,500,1") }) testing.SetRealm(testing.NewUserRealm(owner)) SetMaxBatch(cross(cur), orig) uassert.Equal(t, orig, MaxBatchSize()) } 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) }
- #8gnomod.toml
- #9module = "gno.land/r/g1v7s3j506wqul2zwt90awgjphpx4ztwga0926f7/gnodraw/v0" gno = "0.9"
- #10/gno.MemPackageType
- #11 MPUserAll
Result log
msg:0,success:true,log:,events:[]