Skip to main content

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 import chain/runtime/unsafe, which provides access to the function caller, and use unsafe.PreviousRealm().Address(). Check out the realm concept page and the chain/runtime/unsafe package 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
}