Governance: Hacking My Way To The Commonwealth With Gno

Governance: Hacking My Way To The Commonwealth With Gno

I'd intended to put a few more simple toys under my belt before attempting to refine anything related to governance, but instead... I've decided to dive into the deep end and start rolling the packages/realms I'll need to realize the most important element of Commonwealth: The Commune of Communes.

This won't end up any less a toy than anything else I was considering, but will at least give me something to keep expanding on as core principles are baked in, with more clearly defined mechanics emerging from the beauty of Go.

The prototype in mind:

  • will have a commune realm that leverages the dao package

  • communes will support Join, Leave, Vote and Member

  • communes will support ProposeChange, creatable by the admin, allowing members to cast a balanced ternary Vote

  • the admin can call ProposalResolve at any time, and the votes will be tallied. if < 2/3, final decision is a 0. if quorum wasn't reached (for now 100% of members), the final decision is 0. if 0 votes are cast, the final vote is -1.

There will be no sessions, or attempt at a social proof or reputation system in this build, simply 1 citizen = 1 vote. I'd started to rough out an IdentityRegistry package and a citizen realm for it, but have for now commented out the integrations between the commune/dao and the half cooked identity package. Will return to.

The Package

The dao package will house the bulk of the features described and will contain the following: idao.gno, dao.gno, and iproposal.gno, proposal.gno.

The Interfaces

idao.gno:

type IDAO interface {

    // Joins the DAO (must be a valid `identity`)
    Join(address_ std.Address) bool

    // Leaves the DAO (must be a valid `identity` and already a DAO member)
    Leave(address_ std.Address) bool

    // Returns membership status in the DAO
    Membership(address_ std.Address) bool

    // Returns count of members
    TotalMembers() uint64

    // Creates a proposal from the DAO (must be `admin`)
    Propose(proposer_ std.Address, value_ uint64) uint64

    // Fetchs the Proposal at a given `idx_`
    ProposalAtIndex(idx_ uint64) *Proposal

    // Fetchs the most recent Proposal
    CurrentProposal() *Proposal

    // Resolves a proposal and returns final vote (must be `admin`)
    ProposalResolve(caller_ std.Address, propId_ uint64) int8
}


// Emitted when `citizen` successfully calls `Join`.
type Joined struct {
    member  std.Address
}

// Emitted when `citizen` successfully calls `Leave`.
type Left struct {
    member   std.Address
}

iproposal.gno:

type IProposal interface {

    // Returns the `proposalValue`. Currently just a uint64 value
    Value() uint64    

    // Votes on a proposal (must be DAO `member`)
    Vote(voter_ std.Address, vote_ int8) bool

    // Ends a proposal and returns final vote (must be DAO `admin`)
    Resolve() int8    
}


// Emitted when `admin` calls `Start`.
type ProposalCreated struct {
    proposalId  uint64
}

// Emitted when citizen casts a `Vote`.
type Voted struct {
    proposalId uint64
    member   std.Address
}

// Emitted when `Resolve` of `Proposal` completed.
type ProposalResolved struct {
    proposalId uint64
    finalVote   uint8
}

Worth noting, while event types are defined, atm they're not being used.

The Implementations

Given the DAO membership has to be taken care of first it seems the best place to start. Keeping it simple, this version will just track an avl.Tree mapping address=>bool. I'm sure this will change in future iterations.

First, the important bits of the dao.DAO implementation of IDAO. Below is the struct type definition, along with the NewDAO package function that will be called by the realm. It also includes the Join function, with the other functions working similarly. Note: the enforceIdentity code is commented out in the function, but the enforceAdmin utility function has been included here.

// TODO: ideally instead of the Struct, identityRegistry should store interface type? noodle
type DAO struct {
    admin std.Address
    identityRegistry *identity.IdentityRegistry
    proposalIdx uint64
    proposals avl.Tree
    members avl.Tree
}

func NewDAO(admin_ std.Address, registry_ *identity.IdentityRegistry) *DAO{    
    enforceIdentity(registry_, admin_)    

    return &DAO{
        admin: admin_, 
        identityRegistry: registry_,
        proposalIdx: 0,
        proposals: avl.Tree{},
        members: avl.Tree{},
    }
}

// Joins the DAO (must be a valid `citizen`)
func (d *DAO) Join(address_ std.Address) bool {
    enforceIdentity(d.identityRegistry, address_)
    addressStr := string(address_)
    if d.members.Has(addressStr) {
        return false
    }
    d.members.Set(addressStr,true)
    return true 
}

--- 

func (d *DAO) enforceAdmin(caller_ std.Address) {
    if caller_ != d.admin {
        panic("Must be admin")
    }    
}

Next is the dao.Proposal implementation of IProposal. It's been included entirely (except imports) because it's both small, and the most important part for our testing.

type Proposal struct {
    id uint64
    dao *DAO
    proposer std.Address
    proposalValue uint64
    voters avl.Tree    
    yays uint64 
    nays uint64
    undecideds uint64
}

func NewProposal(dao_ *DAO, proposalIdx_ uint64, proposer_ std.Address, value_ uint64) *Proposal{    
    return &Proposal{
        id: proposalIdx_,
        dao: dao_,
        proposer: proposer_,
        proposalValue: value_, 
        voters: avl.Tree{},
        yays: 0, 
        nays: 0, 
        undecideds: 0,
    }
}

func (p *Proposal) Value() uint64 { return p.proposalValue }

// Casts Vote for the proposal
func (p *Proposal) Vote(voter_ std.Address, vote_ int8) bool {
    if !p.dao.Membership(voter_){
        panic("Must be a member to vote.")
    }
    _voterStr := string(voter_)
    if p.voters.Has(_voterStr) {
        panic("Vote already cast. Can only vote once per-Proposal.")
    }
    if vote_ < -1 || vote_ > 1 {
        panic("Invalid vote value.")
    }    
    p.voters.Set(_voterStr,true)

    switch vote_ {
        case -1:
            p.nays++
        case 1:
            p.yays++
        default:        
            p.undecideds++
    }

    return true
}

// Stops the proposal, returning the final vote (balanced ternary)
func (p *Proposal) Resolve() int8 {
    _totalVotes := (p.yays + p.nays + p.undecideds).(uint64)
    if _totalVotes != p.voters.Size() {
        panic(ufmt.Sprintf("Vote/Voter count mismatch. Votes: %d; Voters: %d",_totalVotes, p.voters.Size()))
    }

    _holdRound := math.Ceil((float64(_totalVotes) * 2 / 3))
    _threshhold := uint64(_holdRound)    

    // no votes = fails
    if _totalVotes == 0 {
        return -1
    }    
    // missing votes = undecided
    if _totalVotes < p.dao.TotalMembers() {
        return 0    
    }

    // return value if 2/3 threshhold is met, otherwise undecided
    if p.yays >= _threshhold {
        return 1
    } else if p.nays >= _threshhold {
        return -1
    } else {
        return 0
    }
}

That covers the dao package. Onto the commune realm to use it.

The Realm

The commune realm is where the DAO interactions live. I've included the import because the IdentityRegistry is used from the citizen realm during init, but not actually leaned on after commenting out the enforceIdentity block in the DAO implementation.

Similar to the foo20 implementation, the admin and commune variables are defined, and then initialized in init. It then auto-joins the admin.

package commune

import (
    "std"
    "strings"

    "gno.land/p/demo/dao"
    "gno.land/p/demo/ufmt"
    "gno.land/r/demo/citizen"
)

var (    
    admin std.Address = "g138j0um48nm4ppcz0tr6az4qu3yjwjl3l6ntqdx" // TODO: helper to change admin
    commune *dao.DAO 
)

func init() {
    commune = dao.NewDAO(admin,citizen.Registry())    
    commune.Join(admin)
}

---- 

func enforceAdmin(caller_ std.Address) {
    if caller_ != admin {
        panic("Must be admin")
    }    
}

The rest is mostly just delegate functions from the realm exposed functions of Join, Leave, Member, ProposeChange, ProposalValue, Proposalresolve and Vote.

The Tests

The tests are still only half cooked, since I'll be circling back to the identity registry element and then adding more coverage. For the moment it's limited to public facing coverage found in commune_test of the realm. But the tests show the following steps passing (and then erroring just to see the log):

  • Confirm admin is auto-joined

  • Confirm a non-member is not a member

  • Confirm a non-member joining Joins

  • Confirm joined member is joined

  • Confirm joined member cannot join

  • Adds a third member to test 2/3 threshold

  • Creates a new proposal from the admin account

  • Casts votes (commented out to let them be toggled on/off

  • Resolves a Vote with the expected results

    const admin std.Address = "g138j0um48nm4ppcz0tr6az4qu3yjwjl3l6ntqdx"
    const gnoadmin std.Address = "g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj"
    const manfred std.Address = "g1u7y667z64x2h7vc6fmpcprgey4ck233jaww9zq"

    type BooleanTest struct {
        name    string
        response bool
        fn      func() bool
    }

// Check that DAO membership calls respond as expected. Register 3 Citizens to test 2/3 threshhold.
    {
        std.TestSetOrigCaller(admin)
        membershipTests := []BooleanTest{
            {"Admin Auto Member", true, func() bool { return Member(admin) }},
            {"Non Member Is False", false, func() bool { return Member(gnoadmin) }},
            {"Non Member 1  Joins", true, func() bool { 
                std.TestSetOrigCaller(gnoadmin)
                return Join() 
            }},
            {"Joined Is Member", true, func() bool { return Member(gnoadmin) }},
            {"Joined Cannot Join", false, func() bool { 
                std.TestSetOrigCaller(gnoadmin)
                return Join() 
            }},
            {"Non Member 2 Joins", true, func() bool { 
                std.TestSetOrigCaller(manfred)
                return Join() 
            }},
        }
        for _, tc := range membershipTests {
            tr := tc.fn()
            if tr != tc.response {
                t.Errorf("%s: have: %t want: %t", tc.name, tr, tc.response)
            }else{
                t.Logf("%s: PASSED; have: %t, want %t",tc.name, tr, tc.response)
            }
        }
    }        

    // Check that DAO Proposal/Vote calls respond as expected.
    {


        std.TestSetOrigCaller(admin)
        _propId := ProposeChange(9002)
        t.Logf("Proposal %d Created", _propId)
        // Vote(_propId.(uint64),1)
        // t.Logf("Admin Voted %d on %d", 1, _propId)

        // std.TestSetOrigCaller(gnoadmin)
        // Vote(_propId.(uint64),-1)
        // t.Logf("Gnoadmin Voted %d on %d", -1, _propId)

        // std.TestSetOrigCaller(manfred)
        // Vote(_propId.(uint64),0)
        // t.Logf("Manfred Voted %d on %d", 0, _propId)

        std.TestSetOrigCaller(admin)
        finalVote := ProposalResolve(_propId)
        t.Logf("Final Vote: %d", finalVote)
        t.Errorf("SUCCESS! FAILING NOW!")
    }

Wrap-Up & Next Steps

And that is a toy DAO in Gno... a very toy DAO lol. But it gives me the start of something to play with as I bake more of the Commonwealth's "Commune of Communes" feature set, and perhaps enable DAO-of-DAOs more generally. The full code is available at the commune branch of my fork.

While I'm not entirely decided on it, I'd really like to roll the Federation ability in early so next pass I may focus on roughing out a better take on the culled identity component and perhaps begin defining an OrganicIdentity and OrganizationalIdentity construct to lean into that early.