Exploring Gno.land: Building Realms w/ Gnolang

Exploring Gno.land: Building Realms w/ Gnolang

A smooth introduction to Gno.land

Before starting, it is worth noting that this is NOT a paid content.

What is Gno.land?

Gno.land is a blockchain L1 solution concentrated on making smart contract development as easy as possible.

Why does it matter?

Because this way, a lot of web2 developers can easily contribute to web3 and build smart contracts.

Gnolang

Gnolang serves as the programming language for creating Realms, which are Smart Contracts on the Gnoland platform.

It's essentially an interpreted version of Golang, where developers upload their source code onto the blockchain and GnoVM executes the AST interpretation. By doing so, Gnoland emphasizes transparency since developers must share their source code instead of compiled bytecode. Furthermore, Gnolang is designed to support multi-threading, similar to Go routines and channels, for developing smart contracts.

EVM vs GnoVM

EVM
  1. Program code - Solidity

  2. EVM bytecode - another low-level code

  3. EVM implementation - C++, Go, or Rust

GnoVM
  1. Program code - Go

  2. Go AST (abstract syntax tree) - Go

  3. GnoVM implementation - Go

So, you can learn Go once, and then do everything :).

As I mentioned, Realms (smart contracts) are written in Gno.

A public function called Render(path string) string can be implemented by each Realm, which is responsible for generating valid markdown for the given path parameter. This function is aimed at enhancing the interactivity of Realms and simplifying their rendering process.

The gnodev cli toolsuite is provided to developers for developing Realms, which includes user-friendly commands for testing and building Realms.

Realms can employ Gno packages from the Gno standard library such as random, maths, avl, etc. or even from the community.

The variables of the Realm's packages are used to store the Realm's state.

Packages

In essence, a gno.land Package is a collection of features and capabilities that does not possess any state, which makes it akin to a library. Another significant aspect is that packages can be imported from various Realms and Packages, using the syntax: import "gno.land/p/...".

Realms

A Realm, written in Gno, is a Smart Contract located on gno.land. Unlike Packages, Realms have the ability to store values or state. To import a Realm, one can use the syntax: import "gno.land/r/...".

Now, it is time to say "Hello!" to the entire world (or, Cosmos 😄)

package demo

// As our function returns a string value, we indicate it while defining the function.

func Hello() string {
  return "Hello, World!"
}

Realm Development

State

As mentioned earlier, the main difference between packages /p and realms /r is that realms can manage a state.

States are basically defined as global variables (outside of functions) inside the Realm:

package updatename

var myName string = "Furkan"

func UpdateName(name string) {
  myName = name
}

Init

The Realm has the ability to define an init() function, which gets executed when the Realm is first called. This can be viewed as a kind of constructor for the Realm, allowing you to establish initial values and logic for the Realm.

Let's write a basic staking Realm!

package staking

import (
  "std"
)

var balance_of map[std.Address]uint64
var staked_amount map[std.Address]uint64

func Stake(pool std.Address, staking_amount uint64) {
  caller := std.GetOrigCaller()

  TransferFrom(caller, pool, amount)
  staked_amount[caller] += stkaing_amount
}

func TransferFrom(from std.Address, to std.Address, amount uint64) {
  if (balance_of[from] < amount) {
    panic("No sufficient balance!")
  }

  balance_of[from] -= amount
  balance_of[to] += amount
}

func assertAdmin() {
  if std.GetOrigCaller() != admin {
    panic("Nobody can call this function, but the admin!")
  }
}

Render()

The Render is a method on a Realm which can return some sort of data to a user browsing the specific Realm using Gno.land website.

When you try to view a Package, you see that the source code is displayed; but when you try to view a Realm, the thing being displayed is the output of its Render method. This is so natural, because recall that Packages cannot hold any state to display.

It does look like as follows:

func Render(path string) string

Now, let us write an example for a HelloWorld contract:

package hello

import (
  "strings"
)

func Hello() string {
  return "Hello World!"
}

func enterName(name string) string {
  return "Welcome, " + name + "!"
}

// Here it comes!

func Render(path string) string {
  if (path == "hello") {
    return Hello()
  } else {
    return "Nothing found!"
  }
}

ToDo App | Preliminaries

Let us clone the official Gno repository and run the build command:

git clone https://github.com/gnolang/gno.git

cd gno

make build

Now, we can create the project directories of our Package and Realm:

mkdir examples/gno.land/p/demo/todo

mkdir examples/gno.land/r/demo/todo

Let us start by building the Package and the test files 'task.gno', 'task_test.gno':

touch examples/gno.land/p/demo/todo/task.gno

touch examples/gno.land/p/demo/todo/task_test.gno

ToDo App | Package & Test

It is wise to use a struct to introduce the notion of task. 3 variables id, task, and isDone will represent any task. Let's define Task struct to make things easier:

package todo

import (
  "gno.land/p/demo/ufmt"
)

type Task struct {
  id uint
  task string
  isDone bool
}

It's time to define some functions! Let's define NewTask() that users will call when they want to create new tasks for their ToDo:

func NewTask(
  id uint,
  task string,
  isDone bool,
) *Task {
  return &Task{
    id: id,
    task: task,
    isDone: isDone,
  }
}

Now, we can define some useful functions that we can call inside our Realm:

func (t Task) GetId() uint {
  return t.id
}

func (t Task) GetIdString() uint {
  return ufmt.Sprintf("%d", t.GetId())
}

func (t Task) GetTask() string {
  return t.task
}

func (t Task) GetIsDone() bool {
  return t.isDone
}

func (t Task) GetInfo() string {
  return ufmt.Sprintf(
    "id: %d\nTask: %d\nisDone",
    t.id, t.Task, t.isDone,
  )
}

Our task.gno Package is ready! Let us write a test for it now and see if it is working properly:

package todo

import (
  "testing"
)

func TestNewTask(t *testing.T) {
  const (
    id uint = 1
    task string = "Debug the code!"
    isDone bool = false
  )

  j := NewTask(id, task, isDone)

  if (j.GetId() != id) {
    t.Fatalf("Wrong ID!")
  }

  if (j.GetTask() != task) {
    t.Fatalf("Wrong Task!")
  }

  if (j.GetIsDone() != isDone) {
    t.Fatalf("Wrong Status!")
  }
}

Before running the test command, we need to execute some commands:

make install gnodev

gnodev precompile examples/gno.land/p/demo/todo/task.gno

gnodev test examples/gno.land/p/demo/todo/task_test.gno

The terminal output:

=== RUN TestNewTask

--- PASS: TestNewItem (0.00s)

ok ./examples/gno.land/p/demo/todo/ 2.01s

ToDo App | Realm

Let us start by creating the Realm:

touch examples/gno.land/r/demo/todo/todo.gno

As our contract will have many functions and we want to log if functions will work properly, it is useful to define some messages after importing relevant libraries and also we can initially define a bunch of constants:

package todo

import (
  "bytes"
  "strings"
  "std"
  "gno.land/p/demo/todo"
  "gno.land/p/demo/avl"
)

const (
  successMessage = "New task is added successfully!"
  markMessage = "Good job!"
  failMessage = "Unable to add the new task!"
  noTaskMessage = "There is no task listed!"
  notFoundMessage = "Not found!"
)

var (
  admin std.Address = "?"
  tasks avl.Tree
  idCounter uint
)

We will add the admin address later when we generate it :).

Now, let's add some useful functions:

func isAdmin(address std.Address) bool {
  return address == admin
}

func getTaskById(id string) *todo.Task {
  task, found := task.Get(id)

  if !found {
    return nil
  }

  return task.(*todo.Task)
}

You can think of isAdmin() as similar as the onlyOwner modifier in Solidity.

It's time to add our Render() method:

func Render(path string) string {
  parts: = strings.Split(path, "/")​

  switch {
    case path == "":
      return renderTasks()

    case len(parts) == 2 && parts[0] == "task":
      task: = getTaskById(parts[1])

    if task == nil {
      return notFoundMessage
    }

    return task.GetInfo()

    default:
      return notFoundMessage
  }
}

func renderTasks() string {
  if tasks.Size() < 1 {
    return noTaskMessage
  }

  var buffer bytes.Buffer

  tasks.Iterate("", "", func(t * avl.Node) bool {

    task,
    _: = t.Value().( * todo.Task)

    buffer.WriteString(task.GetInfo())
    buffer.WriteString("\n\n")

    return false
  })

  return buffer.String()
}

Let's define our final two functions: AddTask() and MarkDone(). The first one will be used to add new tasks to the ToDo. The latter will be used to mark tasks to be done.

func AddTask(
  task string,
  isDone bool,
) string {
  if !isAdmin(std.GetOrigCaller()) {
    return failMessage
  }​

  newTask: = todo.NewTask(
    idCounter,
    task,
    isDone,
  )

  tasks.Set(
    newTask.GetIdString(),
    newTask,
  )​

  idCounter++

  return successMessage
}

func MarkDone(id string) string {
  task: = getTaskById(id)

  if task == nil {
    return notFoundMessage
  }

  task.isDone = true

  return markMessage
}

ToDo App | CLI Operations

In this page, we will create an account, deploy our code, and finally do some operations. We start by generating a Gno.land account:

./build/gnokey generate

The terminal output:

pupil great rule input annual sunny west quick bunker borrow tomorrow exact couple vital air cluster glare fire nest timber shadow point dilemma sight

This is the mnemonics that we will use to obtain our account!
Let 's do it:

./build/gnokey add--recover Admin

After entering our mnemonics, the terminal output is as follows:

Admin(local) - addr: g1m2znmyqgd32r9zcts6lp3yapmj4h832fn73n2n pub: gpub1pgfj7ard9eg82cjtv4u4xetrwqer2dntxyfzxz3pqgfsdc7vfka27rget2qxxxrdxs8we3zfu0qm7glkq2lu3hqrt5u6kk5xcst, path: < nil >

This is the address that we 'd replace in our contract:

var (
    admin std.Address = "g1m2znmyqgd32r9zcts6lp3yapmj4h832fn73n2n"
    tasks avl.Tree idCounter uint
)

After getting some test tokens from the faucet, we are ready to deploy our code. But first, let's start the local blockchain:

./build/gnoland

Now, in another terminal, we can deploy our code:

./build/gnokey maketx addpkg - deposit "100000000ugnot" - gas - fee "1000000ugnot" - gas - wanted "5000000" - remote "localhost:26657" - chainid "dev" - pkgdir "examples/gno.land/p/demo/todo" - pkgpath "gno.land/p/demo/todo" Admin

./build/gnokey maketx addpkg - deposit "100000000ugnot" - gas - fee "1000000ugnot" - gas - wanted "5000000" - remote "localhost:26657" - chainid "dev" - pkgdir "examples/gno.land/r/demo/todo" - pkgpath "gno.land/r/demo/todo" Admin

The terminal outputs:

OK!
OK!

The Realm and the Package have been successfully deployed! Now, let's make a contract call:

./build/gnokey maketx call - func AddTask - args "Deploy your Realm" - args false - deposit "100000000ugnot" - gas - fee "1000000ugnot" - gas - wanted "5000000" - remote "localhost:26657" - chainid "dev" - pkgpath "gno.land/r/demo/todo" Admin

The terminal output:

("New task is added successfully!", string)
OK!​

Thanks for reading Furkan’s Substack! Subscribe for free to receive new posts and support my work.