diff --git a/examples/gno.land/r/demo/multisig/gnomod.toml b/examples/gno.land/r/demo/multisig/gnomod.toml new file mode 100644 index 00000000000..3d1b5e1133a --- /dev/null +++ b/examples/gno.land/r/demo/multisig/gnomod.toml @@ -0,0 +1,2 @@ +module = "gno.land/r/demo/multisig" +gno = "0.9" diff --git a/examples/gno.land/r/demo/multisig/multisig.gno b/examples/gno.land/r/demo/multisig/multisig.gno new file mode 100644 index 00000000000..43810b978d5 --- /dev/null +++ b/examples/gno.land/r/demo/multisig/multisig.gno @@ -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 +} + +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 +} + +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) + return id +} + +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++ +} + +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}}) +} + +func TreasuryAddress() address { + return unsafe.CurrentRealm().Address() +} + +func Render(path string) string { + 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() +} diff --git a/examples/gno.land/r/demo/multisig/multisig_test.gno b/examples/gno.land/r/demo/multisig/multisig_test.gno new file mode 100644 index 00000000000..b8d9ea798b2 --- /dev/null +++ b/examples/gno.land/r/demo/multisig/multisig_test.gno @@ -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) + } +} + +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) +}