Tutorial: minisocial dApp
We will create a MiniSocial realm, a minimalist social media application. This tutorial showcases the full local development flow for Gno.
Find the full app at staging.gno.land/r/docs/minisocial/v1.
Prerequisites
Complete Getting started first. See
gnodev for deeper local-node detail.
Setup
Create a folder, initialize a module, and create three files:
mkdir minisocial && cd minisocial
gno mod init gno.land/r/example/minisocial
touch types.gno posts.gno render.gno
Swap example for your own namespace if you plan to deploy this realm. See
Namespaces.
While all code can be stored in a single file, separating types, business logic, and rendering makes a realm easier to read as it grows.
Core functionality
types.gno
We use types.gno for our types. Let's declare a Post struct that will hold
all the data of a single post, importing the time package to handle
time-related functionality.
package minisocial
import (
"time" // For handling time operations
)
// Post defines the main data we keep about each post
type Post struct {
text string // Main text body
author address // Address of the post author, provided by the execution context
createdAt time.Time // When the post was created
}
The address keyword is a built-in type that represents a Gno address.
Standard libraries such as time are ported over directly from Go. Check out the
Go-Gno Compatibility page for more info.
posts.gno
posts.gno holds two things: a posts slice that stores every post, and a
CreatePost function that anyone can call via a transaction to add to it.
Top-level variables like posts are persisted to storage after each
transaction. Create the file like this:
package minisocial
import (
"chain/runtime/unsafe" // Stack-walking primitives (see unsafe.PreviousRealm docs)
"errors" // For handling errors
"time" // For handling time
)
var posts []*Post
// CreatePost creates a new post
// As the function modifies state (i.e. the `posts` slice),
// it needs to be crossing. This is defined by the first argument being of type `realm`
func CreatePost(_ realm, text string) error {
// If the body of the post is empty, return an error
if text == "" {
return errors.New("empty post text")
}
// Append the new post to the list
posts = append(posts, &Post{
text: text, // Set the input text
author: unsafe.PreviousRealm().Address(), // The author of the address is the previous realm, the realm that called this one
createdAt: time.Now(), // Capture the time of the transaction, in this case the block timestamp
})
return nil
}
A few things to note:
- In Gno, returning errors does not revert any state changes. Follow Go's
best practices: return early in your code and modify state only after you are sure all
security checks in your code have passed. To discard (revert) state changes,
use
panic(). - To get the caller of
CreatePost, we need to importchain/runtime/unsafe, which provides access to the function caller, and useunsafe.PreviousRealm().Address(). Check out the realm concept page and thechain/runtime/unsafepackage reference page for more info. - In Gno,
time.Now()returns the timestamp of the block the transaction was included in, instead of the system time.
Rendering
Let's build the "front end" of our app.
A core feature of Gno is that developers can serve a Markdown view of their realm state directly from on-chain code, removing the need for a separate frontend framework. To learn more, see Exploring Gno.land.
Start gnodev in the minisocial/
folder and open 127.0.0.1:8888. Since a Render()
function is not defined yet, gnoweb returns an error. Let's fix that in
render.gno:
package minisocial
func Render(_ string) string {
return "# MiniSocial"
}
gnodev hot-reloads and you should see MiniSocial as a Header 1 in gnoweb 🎉
Let's gradually add more functionality:
package minisocial
func Render(_ string) string {
output := "# MiniSocial\n\n" // \n is needed just like in standard Markdown
// Handle the edge case
if len(posts) == 0 {
output += "No posts.\n"
return output
}
// Let's append the text of each post to the output
for _, post := range posts {
output += post.text + "\n\n"
}
return output
}
We can now use gnokey to call the CreatePost function and see how our posts
look rendered on gnoweb. From the gnoweb realm page, the Docs tab gives
you a ready-to-copy gnokey command:
gnokey maketx call \
-pkgpath "gno.land/r/example/minisocial" \
-func "CreatePost" \
-args "This is my first post" \
-gas-fee 1000000ugnot -gas-wanted 5000000 \
-chainid "dev" \
-remote "tcp://127.0.0.1:26657" \
MyKey
If the transaction went through, we should see This is my first post under the
header.
We can make this prettier by adding a custom String() method on the Post
struct. Update types.gno to:
package minisocial
import (
"time" // For handling time operations
"gno.land/p/nt/ufmt/v0"
)
// Post defines the main data we keep about each post
type Post struct {
text string
author address
createdAt time.Time
}
// String stringifies a Post
func (p Post) String() string {
out := p.text + "\n\n"
// We can use `ufmt` to format strings, and the built-in time library formatting function
out += ufmt.Sprintf("_by %s on %s_\n\n", p.author, p.createdAt.Format("02 Jan 2006, 15:04"))
return out
}
Here, package ufmt is used to provide string formatting functionality. It can
be imported as gno.land/p/nt/ufmt/v0.
With this, we can expand our Render() function in render.gno as follows:
package minisocial
import "gno.land/p/nt/ufmt/v0" // Gno counterpart to `fmt`, for formatting strings
func Render(_ string) string {
output := "# MiniSocial\n\n" // \n is needed just like in standard Markdown
// Handle the edge case
if len(posts) == 0 {
output += "No posts.\n"
return output
}
// Let's append the text of each post to the output
for i, post := range posts {
// Let's append some post metadata
output += ufmt.Sprintf("#### Post #%d\n\n", i)
// Add the stringified post
output += post.String()
// Add a line break for cleaner UI
output += "---\n\n"
}
return output
}
Now, try publishing a few more posts to see that the rendering works properly.
Testing our code
Testing is an essential part of developing reliable applications. Here we will cover a simple test case and then showcase a more advanced approach using Table-Driven Tests (TDT), a pattern commonly used in Go.
Let's create a posts_test.gno file, and add the following code:
package minisocial
import (
"strings"
"testing"
"gno.land/p/nt/testutils/v0" // Provides testing utilities
)
func TestCreatePostSingle(cur realm, t *testing.T) {
// Get a test address for alice
aliceAddr := testutils.TestAddress("alice")
// TestSetRealm sets the realm caller, in this case Alice
testing.SetRealm(testing.NewUserRealm(aliceAddr))
text1 := "Hello World!"
// To call a crossing function, we pass cross(cur) as the first
// argument; cross(rlm) validates rlm is the current realm and
// flows through to the callee's `cur realm` parameter.
err := CreatePost(cross(cur), text1)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
// Get the rendered page
got := Render("")
// Content should have the text and alice's address in it
if !(strings.Contains(got, text1) && strings.Contains(got, aliceAddr.String())) {
t.Fatal("expected render to contain text & alice's address")
}
}
We can add the following test showcasing how TDT works in Gno:
func TestCreatePostMultiple(cur realm, t *testing.T) {
// Initialize a slice to hold the test posts and their authors
posts := []struct {
text string
author string
}{
{"Hello World!", "alice"},
{"This is some new text!", "bob"},
{"Another post by alice", "alice"},
{"A post by charlie!", "charlie"},
}
for _, p := range posts {
// Set the appropriate caller realm based on the author
authorAddr := testutils.TestAddress(p.author)
testing.SetRealm(testing.NewUserRealm(authorAddr))
// Create the post via cross(cur) so the realm capability flows
// to CreatePost's `cur realm` parameter.
err := CreatePost(cross(cur), p.text)
if err != nil {
t.Fatalf("expected no error for post '%s', got %v", p.text, err)
}
}
// Get the rendered page
got := Render("")
// Check that all posts and their authors are present in the rendered output
for _, p := range posts {
expectedText := p.text
expectedAuthor := testutils.TestAddress(p.author).String() // Get the address for the author
if !(strings.Contains(got, expectedText) && strings.Contains(got, expectedAuthor)) {
t.Fatalf("expected render to contain text '%s' and address '%s'", expectedText, expectedAuthor)
}
}
}
Running gno test . -v in the minisocial/ folder should show the tests passing:
❯ gno test . -v
=== RUN TestCreatePostSingle
--- PASS: TestCreatePostSingle (0.00s)
=== RUN TestCreatePostMultiple
--- PASS: TestCreatePostMultiple (0.00s)
ok . 0.87s
Conclusion
You've built a multi-file realm with persistent state, markdown rendering, and unit tests. Congratulations!
Full code of this app can be found on the Staging network, at staging.gno.land/r/docs/minisocial.
Bonus - resolving usernames
Let's make our MiniSocial app even better by resolving addresses to potential usernames registered in the Gno.land User Registry.
The gno.land/r/sys/users realm provides user data. Update types.gno to use
it when resolving addresses:
package minisocial
import (
"time" // For handling time operations
"gno.land/p/nt/ufmt/v0"
"gno.land/r/sys/users"
)
// Post defines the main data we keep about each post
type Post struct {
text string
author address
createdAt time.Time
}
// String stringifies a Post
func (p Post) String() string {
out := p.text + "\n\n"
author := p.author.String()
// We can import and use the r/sys/users package to resolve addresses
user := users.ResolveAddress(p.author)
if user != nil {
// RenderLink provides a link that is clickable
// The link goes to the user's profile page
author = user.RenderLink("")
}
out += ufmt.Sprintf("_by %s on %s_\n\n", author, p.createdAt.Format("02 Jan 2006, 15:04"))
return out
}