Skip to content

feat: r/coins balance checker #3899

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 45 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
c59d58d
feat(examples): add r/gnoland/coins
moul Feb 25, 2025
a654e50
feat: TotalCoin
notJoon Mar 10, 2025
df1bf50
Merge branch 'master' into airdrop-checker
notJoon Mar 10, 2025
c0b825a
fix
notJoon Mar 10, 2025
b12bbe7
test: mock TotalCoin
notJoon Mar 10, 2025
ee072ef
fmt
notJoon Mar 10, 2025
9cb5c81
fix
notJoon Mar 10, 2025
dc636d3
fix: create proper denom
notJoon Mar 10, 2025
ef43e83
doc
notJoon Mar 10, 2025
695412e
fix
notJoon Mar 11, 2025
0e325f3
remove nav
notJoon Mar 11, 2025
0d751b6
use IterateAccounts
notJoon Mar 11, 2025
15296c5
airdrop things
notJoon Mar 11, 2025
f150093
Revert "airdrop things"
notJoon Mar 11, 2025
bd3eb64
render funcs
notJoon Mar 11, 2025
99fb5b7
Merge branch 'master' into airdrop-checker
notJoon Mar 11, 2025
0c8d344
use md package
notJoon Mar 11, 2025
6f00822
supply keeper
notJoon Mar 12, 2025
8b09a6a
Merge branch 'master' into airdrop-checker
notJoon Mar 12, 2025
5c175b2
docs
notJoon Mar 12, 2025
b535661
test: SDKBanker
notJoon Mar 12, 2025
bd710bd
remove unused
notJoon Mar 12, 2025
07584c3
Merge branch 'master' into airdrop-checker
notJoon Mar 13, 2025
aa509df
Merge branch 'master' into airdrop-checker
notJoon Mar 25, 2025
4bddb4a
fix
notJoon Mar 25, 2025
bfee6be
Revert "supply keeper"
notJoon Apr 3, 2025
c5a012b
Merge branch 'master' into airdrop-checker
notJoon Apr 3, 2025
4cbd61c
fix
notJoon Apr 3, 2025
cb9a437
fix
notJoon Apr 3, 2025
8efad65
use mux
notJoon Apr 3, 2025
468a717
fix
notJoon Apr 3, 2025
7b5d23e
Merge branch 'master' into airdrop-checker
notJoon Apr 14, 2025
41ecdaf
start from the beginning
notJoon Apr 16, 2025
1978a90
simple r/coins
notJoon Apr 16, 2025
b5893d6
disable totalsupply
notJoon Apr 16, 2025
f20a4d6
Merge branch 'master' into airdrop-checker
notJoon Apr 16, 2025
dc45336
fmt
notJoon Apr 16, 2025
b451b9a
Merge branch 'master' into airdrop-checker
moul Apr 22, 2025
a357a08
Update examples/gno.land/r/gnoland/coins/coins.gno
notJoon Apr 23, 2025
c1b246d
Apply suggestions from code review
notJoon Apr 23, 2025
16ed599
fix
notJoon Apr 23, 2025
b713afd
remove unnecessary package
notJoon Apr 23, 2025
1398f4f
Merge branch 'master' into airdrop-checker
notJoon Apr 23, 2025
28c00d1
fix
notJoon Apr 23, 2025
55d979d
remove redundant return
notJoon Apr 23, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions examples/gno.land/r/gnoland/coins/coins.gno
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// Package coins provides simple helpers to retrieve information about coins
// on the Gno.land blockchain.
//
// The primary goal of this realm is to allow users to check their token balances without
// relying on external tools or services. This is particularly valuable for new networks
// that aren't yet widely supported by public explorers or wallets. By using this realm,
// users can always access their balance information directly through the gnodev.
//
// While currently focused on basic balance checking functionality, this realm could
// potentially be extended to support other banker-related workflows in the future.
// However, we aim to keep it minimal and focused on its core purpose.
//
// This is a "Render-only realm" - it exposes only a Render function as its public
// interface and doesn't maintain any state of its own. This pattern allows for
// simple, stateless information retrieval directly through the blockchain's
// rendering capabilities.
//
// Example usage:
//
// /r/gnoland/coins:ugnot - shows the total supply of ugnot
// /r/gnoland/coins:ugnot/g1... - shows the ugnot balance of a specific address
package coins

import (
"std"
"strings"
)

// TotalSupply returns the total supply of the specified denomination
func TotalSupply(banker std.Banker, denom string) int64 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we have this API instead, and always use a readonly banker?

Suggested change
func TotalSupply(banker std.Banker, denom string) int64 {
func TotalSupply(denom string) int64 {

Copy link
Member Author

@notJoon notJoon Mar 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Modified it so that the banker object is created inside the function. I also considered placing the banker globally, but it seemed better to restrict the object's lifetime to within the function (It's also purer than global).
695412e

if denom == "" {
panic("empty denom")
}
// make the coin's denom to follow the correct format
qualifiedDenom := std.CurrentRealm().CoinDenom(denom)
return banker.TotalCoin(qualifiedDenom)
}

// AddressBalance returns the balance of the specified token for the given address
func AddressBalance(banker std.Banker, denom string, address std.Address) int64 {
if denom == "" {
panic("empty denom")
}
if !address.IsValid() {
panic("invalid address")
}
return banker.GetCoins(address).AmountOf(denom)
}

// Path parsing logic:
// - Empty path: Show homepage with general info and usage instructions
// - "<denom>": Show total supply and info about the specified coin
// - "<denom>/<address>": Show specific address balance for the given coin
func Render(path string) string {
banker := std.NewBanker(std.BankerTypeReadonly)

if path == "" {
return renderHomepage()
}

if !strings.Contains(path, "/") {
denom := path
totalSupply := TotalSupply(banker, denom)
return renderCoinInfo(denom, totalSupply)
}

parts := strings.Split(path, "/")
if len(parts) != 2 {
panic("invalid path")
}

denom, addr := parts[0], parts[1]
return renderAddressBalance(banker, denom, std.Address(addr))

// TODO: implement an explanatory homepage that shows usage instructions
// TODO: implement a coin info page showing total supply, and other metrics if we have them
// TODO: implement an address page showing balance for specified coin
// TODO: create navigation links between pages to enable intuitive exploration without typing URLs
}

func renderHomepage() string {
return `# Gno.land Coins
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

homepage should give some information, and provide default links so that we can try the features with just a few clicks.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is currently a placeholder. It is must be updated later.

`
}

func renderCoinInfo(denom string, totalSupply int64) string {
return `# Coin Info

## Denom
` + denom + `

## Total Supply
` + std.NewCoin(denom, totalSupply).String() + `

[Back to Home](/r/gnoland/coins:)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

back is already available in the navigation breadcrumb.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed 695412e

`
}

func renderAddressBalance(banker std.Banker, denom string, address std.Address) string {
balance := AddressBalance(banker, denom, address)
return `# Address Balance

## Address
` + address.String() + `

## Balance
` + std.NewCoin(denom, balance).String() + `

[Back to Coin Info](/r/gnoland/coins:` + denom + `)
[Back to Home](/r/gnoland/coins:)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove this, we should use the navigation breadcrumb for this.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

`
}
67 changes: 67 additions & 0 deletions examples/gno.land/r/gnoland/coins/coins_test.gno
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package coins

import (
"std"
"strings"
"testing"

"gno.land/p/demo/testutils"
)

func TestAirdropChecker(t *testing.T) {
addr1 := testutils.TestAddress("addr1")
addr2 := testutils.TestAddress("addr2")

realm := std.CurrentRealm()
testDenom := realm.CoinDenom("test")
test2Denom := realm.CoinDenom("test2")

tests := []struct {
name string
path string
setupFunc func(std.Banker)
expected string
wantPanic bool
}{
{
name: "homepage shows supported tokens",
path: "",
setupFunc: func(banker std.Banker) {
banker.IssueCoin(addr1, test2Denom, 1000)
banker.IssueCoin(addr2, test2Denom, 1000)
},
expected: `# Gno.land Coins`,
},
{
name: "show total supply for test token",
path: "test",
setupFunc: func(banker std.Banker) {
banker.IssueCoin(addr1, testDenom, 1000000)
banker.IssueCoin(addr2, testDenom, 500000)

println("setup amount", banker.TotalCoin(testDenom))
},
expected: `1500000test`,
},
}

for _, tt := range tests {
banker := std.NewBanker(std.BankerTypeRealmIssue)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we need to initialize a banker from this realm?

(if there is a good reason, why not a readonly one?)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In std.BankerTypeReadonly mode, issuing coins are blocked, so I use std.BankerTypeRealmIssue mode only when setting up the test environment.

I think there won't be any issues because the inside of the TotalSupply or AddressBalance functions are set to read-only (modified to create a banker internally). please correct me, if I wrong.

tt.setupFunc(banker)

if tt.wantPanic {
defer func() {
if r := recover(); r == nil {
t.Errorf("expected panic for %s", tt.name)
}
}()
}

result := Render(tt.path)
if !tt.wantPanic {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I highly recommend even asserting on the panic by making it a string and checking if != ""

if !strings.Contains(result, tt.expected) {
t.Errorf("expected %s to contain %s", result, tt.expected)
}
}
}
}
1 change: 1 addition & 0 deletions examples/gno.land/r/gnoland/coins/gno.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module gno.land/r/gnoland/coins
14 changes: 13 additions & 1 deletion gno.land/pkg/sdk/vm/builtins.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,19 @@
}

func (bnk *SDKBanker) TotalCoin(denom string) int64 {
panic("not yet implemented")
if denom == "" {
panic("empty denom")
}
accounts := bnk.vmk.acck.GetAllAccounts(bnk.ctx)
var totalAmount int64

for _, acc := range accounts {
if acc == nil {
continue

Check warning on line 52 in gno.land/pkg/sdk/vm/builtins.go

View check run for this annotation

Codecov / codecov/patch

gno.land/pkg/sdk/vm/builtins.go#L52

Added line #L52 was not covered by tests
Copy link
Member

@moul moul Mar 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not scalable. We need to find a more efficient solution, or we should refrain from offering this feature for now.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I modified this function to process in a single loop. This is what I could think of for now. What do you think?

0d751b6

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs a change in the underlying banker module, so that it keeps track of the available tokens, rather than iterating on the accounts here.

}
totalAmount += acc.GetCoins().AmountOf(denom)
}
return totalAmount
}

func (bnk *SDKBanker) IssueCoin(b32addr crypto.Bech32Address, denom string, amount int64) {
Expand Down
50 changes: 49 additions & 1 deletion gno.land/pkg/sdk/vm/builtins_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,16 @@ package vm
import (
"testing"

"github.com/gnolang/gno/tm2/pkg/crypto"
"github.com/gnolang/gno/tm2/pkg/std"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestParamsKeeper(t *testing.T) {
env := setupTestEnv()
params := NewSDKParams(env.vmk.prmk, env.ctx)
params := NewSDKParams(env.prmk, env.ctx)

testCases := []struct {
name string
Expand Down Expand Up @@ -65,3 +69,47 @@ func TestParamsKeeper(t *testing.T) {
})
}
}

func TestSDKBankerTotalCoin(t *testing.T) {
env := setupTestEnv()
ctx := env.vmk.MakeGnoTransactionStore(env.ctx)
banker := NewSDKBanker(env.vmk, ctx)

// create test accounts and set coins
addr1 := crypto.AddressFromPreimage([]byte("addr1"))
addr2 := crypto.AddressFromPreimage([]byte("addr2"))

acc1 := env.acck.NewAccountWithAddress(ctx, addr1)
env.acck.SetAccount(ctx, acc1)
env.bankk.SetCoins(ctx, addr1, std.MustParseCoins("1000ugnot,500atom"))

acc2 := env.acck.NewAccountWithAddress(ctx, addr2)
env.acck.SetAccount(ctx, acc2)
env.bankk.SetCoins(ctx, addr2, std.MustParseCoins("2000ugnot,1500atom"))

tests := []struct {
name string
denom string
expected int64
mustPanic bool
}{
{"ugnot total", "ugnot", 3000, false},
{"atom total", "atom", 2000, false},
{"non-existent denom", "foo", 0, false},
{"zero balance accounts included", "ugnot", 3000, false},
{"empty string denom", "", 0, true},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.mustPanic {
assert.Panics(t, func() {
banker.TotalCoin(tt.denom)
})
} else {
total := banker.TotalCoin(tt.denom)
assert.Equal(t, tt.expected, total)
}
})
}
}
1 change: 1 addition & 0 deletions gnovm/stdlibs/std/banker.gno
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ func (b banker) SendCoins(from, to Address, amt Coins) {
}

func (b banker) TotalCoin(denom string) int64 {
assertCoinDenom(denom)
return bankerTotalCoin(uint8(b.bt), denom)
}

Expand Down
15 changes: 11 additions & 4 deletions gnovm/tests/stdlibs/std/std.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,26 +209,33 @@ func (tb *TestBanker) SendCoins(from, to crypto.Bech32Address, amt tm2std.Coins)
tb.CoinTable[from] = frest
// Second, add to 'to'.
// NOTE: even works when from==to, due to 2-step isolation.
tcoins, _ := tb.CoinTable[to]
tcoins := tb.CoinTable[to]
tsum := tcoins.Add(amt)
tb.CoinTable[to] = tsum
}

// TotalCoin implements the Banker interface.
func (tb *TestBanker) TotalCoin(denom string) int64 {
panic("not yet implemented")
if denom == "" {
panic("empty denom")
}
var total int64
for _, coins := range tb.CoinTable {
total += coins.AmountOf(denom)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

still not good; we should not iterate through the whole table at each read, we should do the opposite and maintain a totalsupply value somewhere else; or just remove this feature.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

6f00822

Instead of iterating, I added a supplyStore and modified it to directly retrieve the total supply of each denom from that store. Also, this will automatically update total supply when functions that affects the supply, such as IssueCoin and RemoveCoin, are called.

So, it only needs to retrieve values from a kv store, the operation of TotalCoin itself is reduce to constant time.

}
return total
}

// IssueCoin implements the Banker interface.
func (tb *TestBanker) IssueCoin(addr crypto.Bech32Address, denom string, amt int64) {
coins, _ := tb.CoinTable[addr]
coins := tb.CoinTable[addr]
sum := coins.Add(tm2std.Coins{{Denom: denom, Amount: amt}})
tb.CoinTable[addr] = sum
}

// RemoveCoin implements the Banker interface.
func (tb *TestBanker) RemoveCoin(addr crypto.Bech32Address, denom string, amt int64) {
coins, _ := tb.CoinTable[addr]
coins := tb.CoinTable[addr]
rest := coins.Sub(tm2std.Coins{{Denom: denom, Amount: amt}})
tb.CoinTable[addr] = rest
}
Expand Down
Loading