Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions examples/gno.land/r/demo/multisig/gnomod.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
module = "gno.land/r/demo/multisig"
gno = "0.9"
146 changes: 146 additions & 0 deletions examples/gno.land/r/demo/multisig/multisig.gno
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package multisig

import (
"strconv"
"strings"

"gno.land/p/nt/avl/v0"

"chain"
"chain/banker"
"chain/runtime/unsafe"
)

type Proposal struct {
id int
description string
to address
amount int64
executed bool
approvals avl.Tree
approveCnt int
}
Comment on lines +14 to +22

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nit: Proposal is exported but every field is unexported and there are no accessors, so the only way for another realm to read a proposal is to scrape Render output. approveCnt also duplicates what approvals.Size() already knows, and Execute trusts the counter.


var (
owners avl.Tree
ownerCount int
threshold int
initialized bool
deployer address
proposals avl.Tree
nextID int
)

func init() {
deployer = unsafe.OriginCaller()
nextID = 1
}
Comment on lines +34 to +37

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggestion: realms under examples/ are uploaded at genesis, so on a public chain the account init records is the genesis deployer and no reader can run the demo. Setup refuses everyone else, so one owner set exists for the life of the realm. A factory that mints a multisig per caller would make it something people can try.


func Setup(cur realm, owner1 address, owner2 address, owner3 address, thresh int) {
caller := cur.Previous().Address()
if caller != deployer {
panic("only deployer can run setup")
}
if initialized {
panic("already initialized")
}
if thresh < 1 || thresh > 3 {
panic("invalid threshold")
}
owners.Set(owner1.String(), true)
owners.Set(owner2.String(), true)
owners.Set(owner3.String(), true)
ownerCount = 3
threshold = thresh
initialized = true
Comment on lines +47 to +55

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Setup accepts three owner addresses without checking they are distinct, then records ownerCount = 3 whatever the tree holds. Setup(A, A, A, 2) leaves one owner and an unreachable threshold, so anything sent to the treasury is locked for good behind the one-shot initialized. The threshold bound at line 47 checks the literal 3 rather than the owner set, so Setup(A, A, B, 3) fails the same way.

repro
# from a local clone of gnolang/gno:
gh pr checkout 5951 -R gnolang/gno
cd examples/gno.land/r/demo/multisig
mv multisig_test.gno /tmp/multisig_test.gno.bak

cat > dup_test.gno <<'EOF'
package multisig_test

import (
	"testing"

	"gno.land/r/demo/multisig"
)

func TestDupOwners(cur realm, t *testing.T) {
	a := address("g1owner1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx")
	multisig.Setup(cross(cur), a, a, a, 2)
	println("Setup(A,A,A,2) accepted")

	testing.SetRealm(testing.NewUserRealm(a))
	id := multisig.Propose(cross(cur), "payout", a, 100)
	multisig.Approve(cross(cur), id)
	println(multisig.Render(""))
	multisig.Execute(cross(cur), id)
}
EOF

gno test -v -run TestDupOwners .
rm dup_test.gno && mv /tmp/multisig_test.gno.bak multisig_test.gno
Setup(A,A,A,2) accepted
Multisig Treasury
Initialized: true
Owners: 3 | Threshold: 2

Proposal #1: payout | to: g1owner1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx | amount: 100 | approvals: 1/2 [pending]

=== RUN   TestDupOwners
panic: not enough approvals
# …

}

func assertOwner(caller address) {
if !initialized {
panic("multisig not initialized")
}
if owners.Get(caller.String()) == nil {
panic("caller is not an owner")
}
}

func Propose(cur realm, description string, to address, amount int64) int {
caller := cur.Previous().Address()
assertOwner(caller)

id := nextID
nextID++
p := &Proposal{
id: id,
description: description,
to: to,
amount: amount,
}
proposals.Set(strconv.Itoa(id), p)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nit: proposal ids are stored as decimal strings, so once there are ten proposals Render lists them as 1, 10, 11, 2.

return id
}
Comment on lines +67 to +81

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Propose stores the payout amount without checking its sign, so a negative or zero payout is only refused by the bank keeper inside Execute. Both owners pay for an approval transaction on a proposal that can never settle, and with no cancel path it stays in the tree and in Render for good.

repro
# from a local clone of gnolang/gno:
gh pr checkout 5951 -R gnolang/gno

cat > gno.land/pkg/integration/testdata/negamt.txtar <<'EOF'
adduser ownerA
adduser ownerB
loadpkg gno.land/r/demo/multisig
gnoland start
gnokey maketx call -pkgpath gno.land/r/demo/multisig -func Setup -args $test1_user_addr -args $ownerA_user_addr -args $ownerB_user_addr -args 2 -gas-fee 1000000ugnot -gas-wanted 10000000 -chainid=tendermint_test test1
gnokey maketx call -pkgpath gno.land/r/demo/multisig -func Propose -args 'drain' -args $ownerA_user_addr -args -500000 -gas-fee 1000000ugnot -gas-wanted 10000000 -chainid=tendermint_test test1
gnokey maketx call -pkgpath gno.land/r/demo/multisig -func Approve -args 1 -gas-fee 1000000ugnot -gas-wanted 10000000 -chainid=tendermint_test test1
gnokey maketx call -pkgpath gno.land/r/demo/multisig -func Approve -args 1 -gas-fee 1000000ugnot -gas-wanted 10000000 -chainid=tendermint_test ownerA
! gnokey maketx call -pkgpath gno.land/r/demo/multisig -func Execute -args 1 -gas-fee 1000000ugnot -gas-wanted 10000000 -chainid=tendermint_test test1
stderr 'invalid coins error'
EOF

go test -v -run 'TestTestdata/negamt' ./gno.land/pkg/integration/
rm gno.land/pkg/integration/testdata/negamt.txtar
> gnokey maketx call ... -func Propose -args 'drain' ... -args -500000 ... test1
(1 int)
OK!
> gnokey maketx call ... -func Approve -args 1 ... test1
OK!
> gnokey maketx call ... -func Approve -args 1 ... ownerA
OK!
> ! gnokey maketx call ... -func Execute -args 1 ... test1
Data: invalid coins error
Execute at gno.land/r/demo/multisig/multisig.gno:117


func Approve(cur realm, proposalID int) {
caller := cur.Previous().Address()
assertOwner(caller)

raw := proposals.Get(strconv.Itoa(proposalID))
if raw == nil {
panic("proposal does not exist")
}
p := raw.(*Proposal)
if p.executed {
panic("proposal already executed")
}
if p.approvals.Get(caller.String()) != nil {
panic("already approved")
}
p.approvals.Set(caller.String(), true)
p.approveCnt++
}
Comment on lines +83 to +100

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nothing in the realm removes an approval or a proposal. Once a payout reaches the threshold, Execute settles it at any later block for any caller, so a proposal approved while the treasury was empty fires the moment someone funds the address. cw3-flex-multisig, the model the description names, expires proposals for this reason.


func Execute(cur realm, proposalID int) {
raw := proposals.Get(strconv.Itoa(proposalID))
if raw == nil {
panic("proposal does not exist")
}
p := raw.(*Proposal)
if p.executed {
panic("proposal already executed")
}
if p.approveCnt < threshold {
panic("not enough approvals")
}
p.executed = true

bkr := banker.NewBanker(banker.BankerTypeRealmSend, cur)
bkr.SendCoins(cur.Address(), p.to, chain.Coins{{Denom: "ugnot", Amount: p.amount}})
}
Comment on lines +102 to +118

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggestion: nothing here emits an event, so following the treasury means polling Render. A chain.Emit on Propose and Execute would let a wallet or indexer show pending payouts.


func TreasuryAddress() address {
return unsafe.CurrentRealm().Address()
}
Comment on lines +120 to +122

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Critical: TreasuryAddress returns the caller's realm address, not the treasury's, so a realm that composes with this one funds itself. unsafe.CurrentRealm() stack-walks, so it resolves to whoever asked. Deriving the address from this package's own pkgpath, or from a threaded cur realm, makes both read paths agree.

repro
# from a local clone of gnolang/gno:
gh pr checkout 5951 -R gnolang/gno

cat > gno.land/pkg/integration/testdata/treasury_addr.txtar <<'EOF'
loadpkg gno.land/r/demo/multisig
gnoland start
gnokey query vm/qeval --data 'gno.land/r/demo/multisig.TreasuryAddress()'
gnokey maketx addpkg -pkgdir $WORK/wrapper -pkgpath gno.land/r/demo/wrapper -gas-fee 1000000ugnot -gas-wanted 10000000 -chainid=tendermint_test test1
gnokey query vm/qeval --data 'gno.land/r/demo/wrapper.Treasury()'
-- wrapper/gnomod.toml --
module = "gno.land/r/demo/wrapper"
gno = "0.9"
-- wrapper/wrapper.gno --
package wrapper
import "gno.land/r/demo/multisig"
func Treasury() string { return multisig.TreasuryAddress().String() }
EOF

go test -v -run 'TestTestdata/treasury_addr' ./gno.land/pkg/integration/ 2>&1 | grep -E 'qeval|data:'
rm gno.land/pkg/integration/testdata/treasury_addr.txtar
> gnokey query vm/qeval --data 'gno.land/r/demo/multisig.TreasuryAddress()'
data: ("g17vd2lug0kdaeahm9sv3y0udz7pac9kc0kqs0aa" .uverse.address)
> gnokey query vm/qeval --data 'gno.land/r/demo/wrapper.Treasury()'
data: ("g1ndz9e3hkgyz2xgzs9zuj5au9lf8hfncftf7vwh" string)

The second address is the wrapper's own package address.


func Render(path string) string {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nit: Render takes path and never reads it, so :1 and :anything both return the full list.

var sb strings.Builder
sb.WriteString("Multisig Treasury\n")
sb.WriteString("Initialized: " + strconv.FormatBool(initialized) + "\n")
if initialized {
sb.WriteString("Owners: " + strconv.Itoa(ownerCount) + " | Threshold: " + strconv.Itoa(threshold) + "\n")
}
sb.WriteString("\n")
proposals.Iterate("", "", func(key string, value interface{}) bool {
p := value.(*Proposal)
status := "pending"
if p.executed {
status = "executed"
}
sb.WriteString("Proposal #" + strconv.Itoa(p.id) + ": " + p.description +
" | to: " + p.to.String() +
" | amount: " + strconv.FormatInt(p.amount, 10) +
" | approvals: " + strconv.Itoa(p.approveCnt) + "/" + strconv.Itoa(threshold) +
" [" + status + "]\n")
return false
})
return sb.String()
}
101 changes: 101 additions & 0 deletions examples/gno.land/r/demo/multisig/multisig_test.gno
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package multisig

import (
"strconv"
"testing"
)

func TestSetupInitializesCorrectly(cur realm, t *testing.T) {
owner1 := cur.Previous().Address()
owner2 := address("g1dummyaddressfortestpurposesonlyxxxxxx1")
owner3 := address("g1dummyaddressfortestpurposesonlyxxxxxx2")

Setup(cur, owner1, owner2, owner3, 2)

if !initialized {
t.Fatal("expected initialized to be true")
}
if threshold != 2 {
t.Fatalf("expected threshold 2, got %d", threshold)
}
if ownerCount != 3 {
t.Fatalf("expected 3 owners, got %d", ownerCount)
}
}
Comment on lines +8 to +24

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Missing test: a threshold reached by distinct owners, and a payout that moves coins. Every test here is in-package and calls through the same cur, so TestApproveIncrementsCount never passes 1 of 2 and only the manual Test13 run exercises the state machine. The suite is also order-dependent: gno test -run TestProposeCreatesProposal panics with multisig not initialized, and six of the eight fail standalone.

test cases

An external test package lets callers differ. Setup is one-shot and package state is shared across files, so this replaces multisig_test.gno rather than sitting beside it.

package multisig_test

import (
	"chain"
	"chain/banker"
	"testing"

	"gno.land/r/demo/multisig"
)

func TestExecutePaysRecipient(cur realm, t *testing.T) {
	owner1 := address("g1owner1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx")
	owner2 := address("g1owner2xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx")
	owner3 := address("g1owner3xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx")
	recipient := address("g1recipientxxxxxxxxxxxxxxxxxxxxxxxxxxxx")

	multisig.Setup(cross(cur), owner1, owner2, owner3, 2)

	treasury := chain.PackageAddress("gno.land/r/demo/multisig")
	testing.IssueCoins(treasury, chain.Coins{{Denom: "ugnot", Amount: 1000}})

	testing.SetRealm(testing.NewUserRealm(owner1))
	id := multisig.Propose(cross(cur), "payout", recipient, 400)
	multisig.Approve(cross(cur), id)

	testing.SetRealm(testing.NewUserRealm(owner2))
	multisig.Approve(cross(cur), id)

	testing.SetRealm(testing.NewUserRealm(owner3))
	multisig.Execute(cross(cur), id)

	got := banker.NewReadonlyBanker().GetCoins(recipient)
	if got.String() != "400ugnot" {
		t.Fatalf("recipient balance = %s, want 400ugnot", got.String())
	}
}

The same flow across three real keys on a node, asserting bank/balances, is at tests/multisig_flow.txtar.


func TestSetupTwiceFails(cur realm, t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Fatal("expected panic on second setup")
}
}()
Setup(cur, cur.Previous().Address(), address("g1dummyaddressfortestpurposesonlyxxxxxx1"), address("g1dummyaddressfortestpurposesonlyxxxxxx2"), 2)
}

func TestProposeCreatesProposal(cur realm, t *testing.T) {
id := Propose(cur, "test proposal", address("g1dummyaddressfortestpurposesonlyxxxxxx1"), 1000)

raw := proposals.Get(strconv.Itoa(id))
if raw == nil {
t.Fatal("proposal was not stored")
}
p := raw.(*Proposal)
if p.executed {
t.Fatal("new proposal should not be executed")
}
if p.approveCnt != 0 {
t.Fatalf("expected 0 approvals, got %d", p.approveCnt)
}
}

func TestExecuteWithoutApprovalsFails(cur realm, t *testing.T) {
id := Propose(cur, "underfunded approval test", address("g1dummyaddressfortestpurposesonlyxxxxxx1"), 500)

defer func() {
if r := recover(); r == nil {
t.Fatal("expected panic executing with 0/2 approvals")
}
}()
Execute(cur, id)
}

func TestApproveIncrementsCount(cur realm, t *testing.T) {
id := Propose(cur, "approval count test", address("g1dummyaddressfortestpurposesonlyxxxxxx1"), 250)
Approve(cur, id)

raw := proposals.Get(strconv.Itoa(id))
p := raw.(*Proposal)
if p.approveCnt != 1 {
t.Fatalf("expected 1 approval, got %d", p.approveCnt)
}
}

func TestDoubleApproveFails(cur realm, t *testing.T) {
id := Propose(cur, "double approve test", address("g1dummyaddressfortestpurposesonlyxxxxxx1"), 250)
Approve(cur, id)

defer func() {
if r := recover(); r == nil {
t.Fatal("expected panic on double approve from same owner")
}
}()
Approve(cur, id)
}

func TestApproveNonExistentProposalFails(cur realm, t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Fatal("expected panic approving non-existent proposal")
}
}()
Approve(cur, 9999)
}

func TestExecuteNonExistentProposalFails(cur realm, t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Fatal("expected panic executing non-existent proposal")
}
}()
Execute(cur, 9999)
}
Loading