-
Notifications
You must be signed in to change notification settings - Fork 460
feat(examples): add N-of-M multisig treasury demo realm #5951
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
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| module = "gno.land/r/demo/multisig" | ||
| gno = "0.9" |
| 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 | ||
| } | ||
|
|
||
| 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: realms under |
||
|
|
||
| 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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 |
||
| } | ||
|
|
||
| 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) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| return id | ||
| } | ||
|
Comment on lines
+67
to
+81
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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 |
||
|
|
||
| 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, |
||
|
|
||
| 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: nothing here emits an event, so following the treasury means polling |
||
|
|
||
| func TreasuryAddress() address { | ||
| return unsafe.CurrentRealm().Address() | ||
| } | ||
|
Comment on lines
+120
to
+122
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Critical: 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.txtarThe second address is the wrapper's own package address. |
||
|
|
||
| func Render(path string) string { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: |
||
| 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() | ||
| } | ||
| 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 test casesAn external test package lets callers differ. Setup is one-shot and package state is shared across files, so this replaces 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 |
||
|
|
||
| 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) | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit:
Proposalis exported but every field is unexported and there are no accessors, so the only way for another realm to read a proposal is to scrapeRenderoutput.approveCntalso duplicates whatapprovals.Size()already knows, andExecutetrusts the counter.