diff --git a/examples/gno.land/r/stefann/home/gno.mod b/examples/gno.land/r/stefann/home/gno.mod
new file mode 100644
index 00000000000..dd556e7f817
--- /dev/null
+++ b/examples/gno.land/r/stefann/home/gno.mod
@@ -0,0 +1,9 @@
+module gno.land/r/stefann/home
+
+require (
+ gno.land/p/demo/avl v0.0.0-latest
+ gno.land/p/demo/ownable v0.0.0-latest
+ gno.land/p/demo/testutils v0.0.0-latest
+ gno.land/p/demo/ufmt v0.0.0-latest
+ gno.land/r/stefann/registry v0.0.0-latest
+)
diff --git a/examples/gno.land/r/stefann/home/home.gno b/examples/gno.land/r/stefann/home/home.gno
new file mode 100644
index 00000000000..f40329ebf7e
--- /dev/null
+++ b/examples/gno.land/r/stefann/home/home.gno
@@ -0,0 +1,303 @@
+package home
+
+import (
+ "sort"
+ "std"
+ "strings"
+
+ "gno.land/p/demo/avl"
+ "gno.land/p/demo/ownable"
+ "gno.land/p/demo/ufmt"
+
+ "gno.land/r/stefann/registry"
+)
+
+type City struct {
+ Name string
+ URL string
+}
+
+type Sponsor struct {
+ Address std.Address
+ Amount std.Coins
+}
+
+type Profile struct {
+ pfp string
+ aboutMe []string
+}
+
+type Travel struct {
+ cities []City
+ currentCityIndex int
+ jarLink string
+}
+
+type Sponsorship struct {
+ maxSponsors int
+ sponsors *avl.Tree
+ DonationsCount int
+ sponsorsCount int
+}
+
+var (
+ profile Profile
+ travel Travel
+ sponsorship Sponsorship
+ owner *ownable.Ownable
+)
+
+func init() {
+ owner = ownable.NewWithAddress(registry.MainAddr())
+
+ profile = Profile{
+ pfp: "https://i.ibb.co/Bc5YNCx/DSC-0095a.jpg",
+ aboutMe: []string{
+ `### About Me`,
+ `Hey there! I’m Stefan, a student of Computer Science. I’m all about exploring and adventure — whether it’s diving into the latest tech or discovering a new city, I’m always up for the challenge!`,
+
+ `### Contributions`,
+ `I'm just getting started, but you can follow my journey through Gno.land right [here](https://github.com/gnolang/hackerspace/issues/94) 🔗`,
+ },
+ }
+
+ travel = Travel{
+ cities: []City{
+ {Name: "Venice", URL: "https://i.ibb.co/1mcZ7b1/venice.jpg"},
+ {Name: "Tokyo", URL: "https://i.ibb.co/wNDJv3H/tokyo.jpg"},
+ {Name: "São Paulo", URL: "https://i.ibb.co/yWMq2Sn/sao-paulo.jpg"},
+ {Name: "Toronto", URL: "https://i.ibb.co/pb95HJB/toronto.jpg"},
+ {Name: "Bangkok", URL: "https://i.ibb.co/pQy3w2g/bangkok.jpg"},
+ {Name: "New York", URL: "https://i.ibb.co/6JWLm0h/new-york.jpg"},
+ {Name: "Paris", URL: "https://i.ibb.co/q9vf6Hs/paris.jpg"},
+ {Name: "Kandersteg", URL: "https://i.ibb.co/60DzywD/kandersteg.jpg"},
+ {Name: "Rothenburg", URL: "https://i.ibb.co/cr8d2rQ/rothenburg.jpg"},
+ {Name: "Capetown", URL: "https://i.ibb.co/bPGn0v3/capetown.jpg"},
+ {Name: "Sydney", URL: "https://i.ibb.co/TBNzqfy/sydney.jpg"},
+ {Name: "Oeschinen Lake", URL: "https://i.ibb.co/QJQwp2y/oeschinen-lake.jpg"},
+ {Name: "Barra Grande", URL: "https://i.ibb.co/z4RXKc1/barra-grande.jpg"},
+ {Name: "London", URL: "https://i.ibb.co/CPGtvgr/london.jpg"},
+ },
+ currentCityIndex: 0,
+ jarLink: "https://TODO", // This value should be injected through UpdateJarLink after deployment.
+ }
+
+ sponsorship = Sponsorship{
+ maxSponsors: 5,
+ sponsors: avl.NewTree(),
+ DonationsCount: 0,
+ sponsorsCount: 0,
+ }
+}
+
+func UpdateCities(newCities []City) {
+ owner.AssertCallerIsOwner()
+ travel.cities = newCities
+}
+
+func AddCities(newCities ...City) {
+ owner.AssertCallerIsOwner()
+
+ travel.cities = append(travel.cities, newCities...)
+}
+
+func UpdateJarLink(newLink string) {
+ owner.AssertCallerIsOwner()
+ travel.jarLink = newLink
+}
+
+func UpdatePFP(url string) {
+ owner.AssertCallerIsOwner()
+ profile.pfp = url
+}
+
+func UpdateAboutMe(aboutMeStr string) {
+ owner.AssertCallerIsOwner()
+ profile.aboutMe = strings.Split(aboutMeStr, "|")
+}
+
+func AddAboutMeRows(newRows ...string) {
+ owner.AssertCallerIsOwner()
+
+ profile.aboutMe = append(profile.aboutMe, newRows...)
+}
+
+func UpdateMaxSponsors(newMax int) {
+ owner.AssertCallerIsOwner()
+ if newMax <= 0 {
+ panic("maxSponsors must be greater than zero")
+ }
+ sponsorship.maxSponsors = newMax
+}
+
+func Donate() {
+ address := std.GetOrigCaller()
+ amount := std.GetOrigSend()
+
+ if amount.AmountOf("ugnot") == 0 {
+ panic("Donation must include GNOT")
+ }
+
+ existingAmount, exists := sponsorship.sponsors.Get(address.String())
+ if exists {
+ updatedAmount := existingAmount.(std.Coins).Add(amount)
+ sponsorship.sponsors.Set(address.String(), updatedAmount)
+ } else {
+ sponsorship.sponsors.Set(address.String(), amount)
+ sponsorship.sponsorsCount++
+ }
+
+ travel.currentCityIndex++
+ sponsorship.DonationsCount++
+
+ banker := std.GetBanker(std.BankerTypeRealmSend)
+ ownerAddr := registry.MainAddr()
+ banker.SendCoins(std.CurrentRealm().Addr(), ownerAddr, banker.GetCoins(std.CurrentRealm().Addr()))
+}
+
+type SponsorSlice []Sponsor
+
+func (s SponsorSlice) Len() int {
+ return len(s)
+}
+
+func (s SponsorSlice) Less(i, j int) bool {
+ return s[i].Amount.AmountOf("ugnot") > s[j].Amount.AmountOf("ugnot")
+}
+
+func (s SponsorSlice) Swap(i, j int) {
+ s[i], s[j] = s[j], s[i]
+}
+
+func GetTopSponsors() []Sponsor {
+ var sponsorSlice SponsorSlice
+
+ sponsorship.sponsors.Iterate("", "", func(key string, value interface{}) bool {
+ addr := std.Address(key)
+ amount := value.(std.Coins)
+ sponsorSlice = append(sponsorSlice, Sponsor{Address: addr, Amount: amount})
+ return false
+ })
+
+ sort.Sort(sponsorSlice)
+ return sponsorSlice
+}
+
+func GetTotalDonations() int {
+ total := 0
+ sponsorship.sponsors.Iterate("", "", func(key string, value interface{}) bool {
+ total += int(value.(std.Coins).AmountOf("ugnot"))
+ return false
+ })
+ return total
+}
+
+func Render(path string) string {
+ out := ufmt.Sprintf("# Exploring %s!\n\n", travel.cities[travel.currentCityIndex].Name)
+
+ out += renderAboutMe()
+ out += "\n\n"
+ out += renderTips()
+
+ return out
+}
+
+func renderAboutMe() string {
+ out := "
"
+
+ out += "
\n\n"
+
+ out += ufmt.Sprintf("
\n\n", travel.cities[travel.currentCityIndex%len(travel.cities)].URL)
+
+ out += ufmt.Sprintf("
\n\n", profile.pfp)
+
+ out += "
\n\n"
+
+ for _, rows := range profile.aboutMe {
+ out += "
\n\n"
+ out += rows + "\n\n"
+ out += "
\n\n"
+ }
+
+ out += "
\n\n"
+
+ return out
+}
+
+func renderTips() string {
+ out := `
` + "\n\n"
+
+ out += `
` + "\n"
+
+ out += `
Help Me Travel The World
` + "\n\n"
+
+ out += renderTipsJar() + "\n"
+
+ out += ufmt.Sprintf(`I am currently in %s,
tip the jar to send me somewhere else!`, travel.cities[travel.currentCityIndex].Name)
+
+ out += `
Click the jar, tip in GNOT coins, and watch my background change as I head to a new adventure!` + "\n\n"
+
+ out += renderSponsors()
+
+ out += `` + "\n\n"
+
+ out += `
` + "\n"
+
+ return out
+}
+
+func formatAddress(address string) string {
+ if len(address) <= 8 {
+ return address
+ }
+ return address[:4] + "..." + address[len(address)-4:]
+}
+
+func renderSponsors() string {
+ out := `
Sponsor Leaderboard
` + "\n"
+
+ if sponsorship.sponsorsCount == 0 {
+ return out + `
No sponsors yet. Be the first to tip the jar!
` + "\n"
+ }
+
+ topSponsors := GetTopSponsors()
+ numSponsors := len(topSponsors)
+ if numSponsors > sponsorship.maxSponsors {
+ numSponsors = sponsorship.maxSponsors
+ }
+
+ out += `
` + "\n"
+
+ for i := 0; i < numSponsors; i++ {
+ sponsor := topSponsors[i]
+ isLastItem := (i == numSponsors-1)
+
+ padding := "10px 5px"
+ border := "border-bottom: 1px solid #ddd;"
+
+ if isLastItem {
+ padding = "8px 5px"
+ border = ""
+ }
+
+ out += ufmt.Sprintf(
+ `-
+ %d. %s
+ %s
+
`,
+ padding, border, i+1, formatAddress(sponsor.Address.String()), sponsor.Amount.String(),
+ )
+ }
+
+ return out
+}
+
+func renderTipsJar() string {
+ out := ufmt.Sprintf(``, travel.jarLink) + "\n"
+
+ out += `` + "\n"
+
+ out += `` + "\n"
+
+ return out
+}
diff --git a/examples/gno.land/r/stefann/home/home_test.gno b/examples/gno.land/r/stefann/home/home_test.gno
new file mode 100644
index 00000000000..ca146b9eb13
--- /dev/null
+++ b/examples/gno.land/r/stefann/home/home_test.gno
@@ -0,0 +1,291 @@
+package home
+
+import (
+ "std"
+ "strings"
+ "testing"
+
+ "gno.land/p/demo/avl"
+ "gno.land/p/demo/testutils"
+)
+
+func TestUpdatePFP(t *testing.T) {
+ var owner = std.Address("g1sd5ezmxt4rwpy52u6wl3l3y085n8x0p6nllxm8")
+ std.TestSetOrigCaller(owner)
+
+ profile.pfp = ""
+
+ UpdatePFP("https://example.com/pic.png")
+
+ if profile.pfp != "https://example.com/pic.png" {
+ t.Fatalf("expected pfp to be https://example.com/pic.png, got %s", profile.pfp)
+ }
+}
+
+func TestUpdateAboutMe(t *testing.T) {
+ var owner = std.Address("g1sd5ezmxt4rwpy52u6wl3l3y085n8x0p6nllxm8")
+ std.TestSetOrigCaller(owner)
+
+ profile.aboutMe = []string{}
+
+ UpdateAboutMe("This is my new bio.|I love coding!")
+
+ expected := []string{"This is my new bio.", "I love coding!"}
+
+ if len(profile.aboutMe) != len(expected) {
+ t.Fatalf("expected aboutMe to have length %d, got %d", len(expected), len(profile.aboutMe))
+ }
+
+ for i := range profile.aboutMe {
+ if profile.aboutMe[i] != expected[i] {
+ t.Fatalf("expected aboutMe[%d] to be %s, got %s", i, expected[i], profile.aboutMe[i])
+ }
+ }
+}
+
+func TestUpdateCities(t *testing.T) {
+ var owner = std.Address("g1sd5ezmxt4rwpy52u6wl3l3y085n8x0p6nllxm8")
+ std.TestSetOrigCaller(owner)
+
+ travel.cities = []City{}
+
+ newCities := []City{
+ {Name: "Berlin", URL: "https://example.com/berlin.jpg"},
+ {Name: "Vienna", URL: "https://example.com/vienna.jpg"},
+ }
+
+ UpdateCities(newCities)
+
+ if len(travel.cities) != 2 {
+ t.Fatalf("expected 2 cities, got %d", len(travel.cities))
+ }
+
+ if travel.cities[0].Name != "Berlin" || travel.cities[1].Name != "Vienna" {
+ t.Fatalf("expected cities to be updated to Berlin and Vienna, got %+v", travel.cities)
+ }
+}
+
+func TestUpdateJarLink(t *testing.T) {
+ var owner = std.Address("g1sd5ezmxt4rwpy52u6wl3l3y085n8x0p6nllxm8")
+ std.TestSetOrigCaller(owner)
+
+ travel.jarLink = ""
+
+ UpdateJarLink("https://example.com/jar")
+
+ if travel.jarLink != "https://example.com/jar" {
+ t.Fatalf("expected jarLink to be https://example.com/jar, got %s", travel.jarLink)
+ }
+}
+
+func TestUpdateMaxSponsors(t *testing.T) {
+ var owner = std.Address("g1sd5ezmxt4rwpy52u6wl3l3y085n8x0p6nllxm8")
+ std.TestSetOrigCaller(owner)
+
+ sponsorship.maxSponsors = 0
+
+ UpdateMaxSponsors(10)
+
+ if sponsorship.maxSponsors != 10 {
+ t.Fatalf("expected maxSponsors to be 10, got %d", sponsorship.maxSponsors)
+ }
+
+ defer func() {
+ if r := recover(); r == nil {
+ t.Fatalf("expected panic for setting maxSponsors to 0")
+ }
+ }()
+ UpdateMaxSponsors(0)
+}
+
+func TestAddCities(t *testing.T) {
+ var owner = std.Address("g1sd5ezmxt4rwpy52u6wl3l3y085n8x0p6nllxm8")
+ std.TestSetOrigCaller(owner)
+
+ travel.cities = []City{}
+
+ AddCities(City{Name: "Berlin", URL: "https://example.com/berlin.jpg"})
+
+ if len(travel.cities) != 1 {
+ t.Fatalf("expected 1 city, got %d", len(travel.cities))
+ }
+ if travel.cities[0].Name != "Berlin" || travel.cities[0].URL != "https://example.com/berlin.jpg" {
+ t.Fatalf("expected city to be Berlin, got %+v", travel.cities[0])
+ }
+
+ AddCities(
+ City{Name: "Paris", URL: "https://example.com/paris.jpg"},
+ City{Name: "Tokyo", URL: "https://example.com/tokyo.jpg"},
+ )
+
+ if len(travel.cities) != 3 {
+ t.Fatalf("expected 3 cities, got %d", len(travel.cities))
+ }
+ if travel.cities[1].Name != "Paris" || travel.cities[2].Name != "Tokyo" {
+ t.Fatalf("expected cities to be Paris and Tokyo, got %+v", travel.cities[1:])
+ }
+}
+
+func TestAddAboutMeRows(t *testing.T) {
+ var owner = std.Address("g1sd5ezmxt4rwpy52u6wl3l3y085n8x0p6nllxm8")
+ std.TestSetOrigCaller(owner)
+
+ profile.aboutMe = []string{}
+
+ AddAboutMeRows("I love exploring new technologies!")
+
+ if len(profile.aboutMe) != 1 {
+ t.Fatalf("expected 1 aboutMe row, got %d", len(profile.aboutMe))
+ }
+ if profile.aboutMe[0] != "I love exploring new technologies!" {
+ t.Fatalf("expected first aboutMe row to be 'I love exploring new technologies!', got %s", profile.aboutMe[0])
+ }
+
+ AddAboutMeRows("Travel is my passion!", "Always learning.")
+
+ if len(profile.aboutMe) != 3 {
+ t.Fatalf("expected 3 aboutMe rows, got %d", len(profile.aboutMe))
+ }
+ if profile.aboutMe[1] != "Travel is my passion!" || profile.aboutMe[2] != "Always learning." {
+ t.Fatalf("expected aboutMe rows to be 'Travel is my passion!' and 'Always learning.', got %+v", profile.aboutMe[1:])
+ }
+}
+
+func TestDonate(t *testing.T) {
+ var user = testutils.TestAddress("user")
+ std.TestSetOrigCaller(user)
+
+ sponsorship.sponsors = avl.NewTree()
+ sponsorship.DonationsCount = 0
+ sponsorship.sponsorsCount = 0
+ travel.currentCityIndex = 0
+
+ coinsSent := std.NewCoins(std.NewCoin("ugnot", 500))
+ std.TestSetOrigSend(coinsSent, std.NewCoins())
+ Donate()
+
+ existingAmount, exists := sponsorship.sponsors.Get(string(user))
+ if !exists {
+ t.Fatalf("expected sponsor to be added, but it was not found")
+ }
+
+ if existingAmount.(std.Coins).AmountOf("ugnot") != 500 {
+ t.Fatalf("expected donation amount to be 500ugnot, got %d", existingAmount.(std.Coins).AmountOf("ugnot"))
+ }
+
+ if sponsorship.DonationsCount != 1 {
+ t.Fatalf("expected DonationsCount to be 1, got %d", sponsorship.DonationsCount)
+ }
+
+ if sponsorship.sponsorsCount != 1 {
+ t.Fatalf("expected sponsorsCount to be 1, got %d", sponsorship.sponsorsCount)
+ }
+
+ if travel.currentCityIndex != 1 {
+ t.Fatalf("expected currentCityIndex to be 1, got %d", travel.currentCityIndex)
+ }
+
+ coinsSent = std.NewCoins(std.NewCoin("ugnot", 300))
+ std.TestSetOrigSend(coinsSent, std.NewCoins())
+ Donate()
+
+ existingAmount, exists = sponsorship.sponsors.Get(string(user))
+ if !exists {
+ t.Fatalf("expected sponsor to exist after second donation, but it was not found")
+ }
+
+ if existingAmount.(std.Coins).AmountOf("ugnot") != 800 {
+ t.Fatalf("expected total donation amount to be 800ugnot, got %d", existingAmount.(std.Coins).AmountOf("ugnot"))
+ }
+
+ if sponsorship.DonationsCount != 2 {
+ t.Fatalf("expected DonationsCount to be 2 after second donation, got %d", sponsorship.DonationsCount)
+ }
+
+ if travel.currentCityIndex != 2 {
+ t.Fatalf("expected currentCityIndex to be 2 after second donation, got %d", travel.currentCityIndex)
+ }
+}
+
+func TestGetTopSponsors(t *testing.T) {
+ var user = testutils.TestAddress("user")
+ std.TestSetOrigCaller(user)
+
+ sponsorship.sponsors = avl.NewTree()
+ sponsorship.sponsorsCount = 0
+
+ sponsorship.sponsors.Set("g1address1", std.NewCoins(std.NewCoin("ugnot", 300)))
+ sponsorship.sponsors.Set("g1address2", std.NewCoins(std.NewCoin("ugnot", 500)))
+ sponsorship.sponsors.Set("g1address3", std.NewCoins(std.NewCoin("ugnot", 200)))
+ sponsorship.sponsorsCount = 3
+
+ topSponsors := GetTopSponsors()
+
+ if len(topSponsors) != 3 {
+ t.Fatalf("expected 3 sponsors, got %d", len(topSponsors))
+ }
+
+ if topSponsors[0].Address.String() != "g1address2" || topSponsors[0].Amount.AmountOf("ugnot") != 500 {
+ t.Fatalf("expected top sponsor to be g1address2 with 500ugnot, got %s with %dugnot", topSponsors[0].Address.String(), topSponsors[0].Amount.AmountOf("ugnot"))
+ }
+
+ if topSponsors[1].Address.String() != "g1address1" || topSponsors[1].Amount.AmountOf("ugnot") != 300 {
+ t.Fatalf("expected second sponsor to be g1address1 with 300ugnot, got %s with %dugnot", topSponsors[1].Address.String(), topSponsors[1].Amount.AmountOf("ugnot"))
+ }
+
+ if topSponsors[2].Address.String() != "g1address3" || topSponsors[2].Amount.AmountOf("ugnot") != 200 {
+ t.Fatalf("expected third sponsor to be g1address3 with 200ugnot, got %s with %dugnot", topSponsors[2].Address.String(), topSponsors[2].Amount.AmountOf("ugnot"))
+ }
+}
+
+func TestGetTotalDonations(t *testing.T) {
+ var user = testutils.TestAddress("user")
+ std.TestSetOrigCaller(user)
+
+ sponsorship.sponsors = avl.NewTree()
+ sponsorship.sponsorsCount = 0
+
+ sponsorship.sponsors.Set("g1address1", std.NewCoins(std.NewCoin("ugnot", 300)))
+ sponsorship.sponsors.Set("g1address2", std.NewCoins(std.NewCoin("ugnot", 500)))
+ sponsorship.sponsors.Set("g1address3", std.NewCoins(std.NewCoin("ugnot", 200)))
+ sponsorship.sponsorsCount = 3
+
+ totalDonations := GetTotalDonations()
+
+ if totalDonations != 1000 {
+ t.Fatalf("expected total donations to be 1000ugnot, got %dugnot", totalDonations)
+ }
+}
+
+func TestRender(t *testing.T) {
+ travel.currentCityIndex = 0
+ travel.cities = []City{
+ {Name: "Venice", URL: "https://example.com/venice.jpg"},
+ {Name: "Paris", URL: "https://example.com/paris.jpg"},
+ }
+
+ output := Render("")
+
+ expectedCity := "Venice"
+ if !strings.Contains(output, expectedCity) {
+ t.Fatalf("expected output to contain city name '%s', got %s", expectedCity, output)
+ }
+
+ expectedURL := "https://example.com/venice.jpg"
+ if !strings.Contains(output, expectedURL) {
+ t.Fatalf("expected output to contain city URL '%s', got %s", expectedURL, output)
+ }
+
+ travel.currentCityIndex = 1
+ output = Render("")
+
+ expectedCity = "Paris"
+ if !strings.Contains(output, expectedCity) {
+ t.Fatalf("expected output to contain city name '%s', got %s", expectedCity, output)
+ }
+
+ expectedURL = "https://example.com/paris.jpg"
+ if !strings.Contains(output, expectedURL) {
+ t.Fatalf("expected output to contain city URL '%s', got %s", expectedURL, output)
+ }
+}
diff --git a/examples/gno.land/r/stefann/registry/gno.mod b/examples/gno.land/r/stefann/registry/gno.mod
new file mode 100644
index 00000000000..5ed3e4916e2
--- /dev/null
+++ b/examples/gno.land/r/stefann/registry/gno.mod
@@ -0,0 +1,3 @@
+module gno.land/r/stefann/registry
+
+require gno.land/p/demo/ownable v0.0.0-latest
diff --git a/examples/gno.land/r/stefann/registry/registry.gno b/examples/gno.land/r/stefann/registry/registry.gno
new file mode 100644
index 00000000000..6f56d105e4b
--- /dev/null
+++ b/examples/gno.land/r/stefann/registry/registry.gno
@@ -0,0 +1,51 @@
+package registry
+
+import (
+ "errors"
+ "std"
+
+ "gno.land/p/demo/ownable"
+)
+
+var (
+ mainAddr std.Address
+ backupAddr std.Address
+ owner *ownable.Ownable
+)
+
+func init() {
+ mainAddr = "g1sd5ezmxt4rwpy52u6wl3l3y085n8x0p6nllxm8"
+ backupAddr = "g13awn2575t8s2vf3svlprc4dg0e9z5wchejdxk8"
+
+ owner = ownable.NewWithAddress(mainAddr)
+}
+
+func MainAddr() std.Address {
+ return mainAddr
+}
+
+func BackupAddr() std.Address {
+ return backupAddr
+}
+
+func SetMainAddr(addr std.Address) error {
+ if !addr.IsValid() {
+ return errors.New("config: invalid address")
+ }
+
+ owner.AssertCallerIsOwner()
+
+ mainAddr = addr
+ return nil
+}
+
+func SetBackupAddr(addr std.Address) error {
+ if !addr.IsValid() {
+ return errors.New("config: invalid address")
+ }
+
+ owner.AssertCallerIsOwner()
+
+ backupAddr = addr
+ return nil
+}