Governance

Panda supports on-chain governance through voting contracts, access control patterns, and proposal systems. This guide covers common patterns for managing decentralized decision-making.

DAO Voting

The DAO Voting contract implements token-weighted governance where voting power is determined by PRC-20 token balance. It uses cross-contract queries to look up each voter's balance at vote time.

# Source: contracts/patterns/dao_voting.py
"""
DAO Voting — On-chain governance with cross-contract token balance queries.

Voting power is determined by the voter's PRC-20 token balance at vote time
via ``PRC20`` queries to the governance token contract.
"""

from panda import contract, constructor, call, query, event, PRC20


@contract
class DAOVoting:
    """DAO governance contract with token-weighted voting."""

    class State:
        token_address: str = ""
        proposals: dict = {}
        next_proposal_id: int = 1
        quorum_bps: int = 1000  # 10% of supply must vote
        approval_bps: int = 5000  # 50% of votes must approve
        voting_period: int = 86400  # 1 day in seconds
        owner: str = ""

    @constructor
    def deploy(
        self,
        ctx,
        token_address: str,
        quorum_bps: int = 1000,
        approval_bps: int = 5000,
        voting_period: int = 86400,
    ):
        self.state.token_address = token_address
        self.state.quorum_bps = quorum_bps
        self.state.approval_bps = approval_bps
        self.state.voting_period = voting_period
        self.state.owner = ctx.sender
        print(
            f"DAOVoting deployed by {ctx.sender}, quorum={quorum_bps}bps, approval={approval_bps}bps"
        )

    @call
    def create_proposal(
        self, ctx, title: str, description: str, action: str = "", target: str = ""
    ):
        """Create a new proposal. Proposer must hold governance tokens."""
        # Check proposer has tokens via cross-contract query
        token = PRC20(self.state.token_address)
        balance = token.balance_of(owner=ctx.sender)
        if balance <= 0:
            raise ValueError("must hold governance tokens to propose")

        pid = self.state.next_proposal_id
        proposals = dict(self.state.proposals)
        proposals[str(pid)] = {
            "title": title,
            "description": description,
            "action": action,
            "target": target,
            "proposer": ctx.sender,
            "created_at": ctx.block_time,
            "deadline": ctx.block_time + self.state.voting_period,
            "votes_for": 0,
            "votes_against": 0,
            "voters": {},
            "status": "active",
        }
        self.state.proposals = proposals
        self.state.next_proposal_id = pid + 1

        self.emit(
            event.ProposalCreated(
                proposal_id=pid,
                proposer=ctx.sender,
                title=title,
            )
        )
        print(f"Proposal #{pid} created by {ctx.sender}: '{title}'")

    @call
    def vote(self, ctx, proposal_id: int, support: bool):
        """Vote on a proposal. Voting power = token balance at vote time."""
        key = str(proposal_id)
        proposals = dict(self.state.proposals)
        proposal = proposals.get(key)
        if not proposal:
            raise ValueError("proposal not found")
        if proposal["status"] != "active":
            raise ValueError("proposal is not active")
        if ctx.block_time > proposal["deadline"]:
            raise ValueError("voting period has ended")
        if ctx.sender in proposal["voters"]:
            raise ValueError("already voted")

        # Query voter's token balance via cross-contract call
        token = PRC20(self.state.token_address)
        voting_power = token.balance_of(owner=ctx.sender)
        if voting_power <= 0:
            raise ValueError("no voting power")

        voters = dict(proposal["voters"])
        voters[ctx.sender] = {
            "support": support,
            "power": voting_power,
        }
        proposal["voters"] = voters
        if support:
            proposal["votes_for"] = proposal["votes_for"] + voting_power
        else:
            proposal["votes_against"] = proposal["votes_against"] + voting_power
        proposals[key] = proposal
        self.state.proposals = proposals

        self.emit(
            event.VoteCast(
                proposal_id=proposal_id,
                voter=ctx.sender,
                support=support,
                power=voting_power,
            )
        )

    @call
    def finalize_proposal(self, ctx, proposal_id: int):
        """Finalize a proposal after the voting period ends."""
        key = str(proposal_id)
        proposals = dict(self.state.proposals)
        proposal = proposals.get(key)
        if not proposal:
            raise ValueError("proposal not found")
        if proposal["status"] != "active":
            raise ValueError("proposal is not active")
        if ctx.block_time < proposal["deadline"]:
            raise ValueError("voting period has not ended")

        # Check quorum: total votes vs total supply
        token = PRC20(self.state.token_address)
        total_supply = token.total_supply()
        total_votes = proposal["votes_for"] + proposal["votes_against"]
        quorum_needed = (total_supply * self.state.quorum_bps) // 10000

        if total_votes < quorum_needed:
            proposal["status"] = "defeated_no_quorum"
        elif proposal["votes_for"] * 10000 >= total_votes * self.state.approval_bps:
            proposal["status"] = "passed"
        else:
            proposal["status"] = "defeated"

        proposals[key] = proposal
        self.state.proposals = proposals

        self.emit(
            event.ProposalFinalized(
                proposal_id=proposal_id,
                status=proposal["status"],
                votes_for=proposal["votes_for"],
                votes_against=proposal["votes_against"],
            )
        )

Quorum and Approval Thresholds

The contract uses basis points (bps) for precision:

  • quorum_bps = 1000 means 10% of total token supply must participate for the vote to be valid
  • approval_bps = 5000 means 50% of cast votes must be in favor for the proposal to pass

A proposal passes when:

  1. Quorum is met: total_votes >= (total_supply * quorum_bps) / 10000
  2. Approval threshold is met: votes_for * 10000 >= total_votes * approval_bps

Three possible outcomes: passed, defeated, or defeated_no_quorum.

Proposal Lifecycle

create_proposal -> vote (during voting_period) -> finalize_proposal -> passed / defeated / defeated_no_quorum

Cross-Contract Integration

The DAO contract queries a PRC-20 governance token contract for two things:

  • balance_of during create_proposal and vote -- ensures proposers and voters hold tokens
  • total_supply during finalize_proposal -- calculates the quorum threshold

This means governance power is always derived from live token balances, not a separate registry.

Access Control

Ownable: Two-Step Ownership Transfer

The Ownable pattern provides a safe two-step ownership transfer. Instead of directly assigning a new owner (which risks typos sending ownership to an inaccessible address), it requires the new owner to explicitly accept.

# Source: contracts/patterns/ownable.py
from panda import contract, constructor, call, query, event


@contract
class Ownable:
    """Base contract with single-owner access control."""

    class State:
        owner: str = ""
        pending_owner: str = ""

    @constructor
    def deploy(self, ctx):
        self.state.owner = ctx.sender
        self.emit(
            event.OwnershipTransferred(
                previous_owner="",
                new_owner=ctx.sender,
            )
        )
        print(f"Ownable deployed, owner={ctx.sender}")

    def _only_owner(self, ctx):
        """Internal check: revert if caller is not the owner."""
        if ctx.sender != self.state.owner:
            raise ValueError(f"Ownable: caller {ctx.sender} is not the owner")

    @call
    def transfer_ownership(self, ctx, new_owner: str):
        """Initiate ownership transfer (two-step for safety)."""
        self._only_owner(ctx)
        if not new_owner:
            raise ValueError("Ownable: new owner is the zero address")
        self.state.pending_owner = new_owner
        self.emit(
            event.OwnershipTransferStarted(
                current_owner=ctx.sender,
                pending_owner=new_owner,
            )
        )
        print(f"Ownership transfer initiated by {ctx.sender} to {new_owner}")

    @call
    def accept_ownership(self, ctx):
        """Accept a pending ownership transfer."""
        if ctx.sender != self.state.pending_owner:
            raise ValueError("Ownable: caller is not the pending owner")
        previous = self.state.owner
        self.state.owner = ctx.sender
        self.state.pending_owner = ""
        self.emit(
            event.OwnershipTransferred(
                previous_owner=previous,
                new_owner=ctx.sender,
            )
        )
        print(f"Ownership accepted by {ctx.sender}, previous owner={previous}")

    @call
    def renounce_ownership(self, ctx):
        """Permanently renounce ownership. Cannot be undone."""
        self._only_owner(ctx)
        previous = self.state.owner
        self.state.owner = ""
        self.state.pending_owner = ""
        self.emit(
            event.OwnershipTransferred(
                previous_owner=previous,
                new_owner="",
            )
        )
        print(f"Ownership renounced by {previous}, contract is now unowned")

    @query
    def get_owner(self) -> str:
        return self.state.owner

    @query
    def get_pending_owner(self) -> str:
        return self.state.pending_owner

Transfer flow: transfer_ownership(new) sets pending_owner -> new owner calls accept_ownership() -> ownership transfers. This prevents accidental loss of contract control.

Pausable

The Pausable contract extends the Ownable pattern with emergency stop functionality. Methods that should respect the pause state call self._when_not_paused().

# Source: contracts/patterns/pausable.py
from panda import contract, constructor, call, query, event


@contract
class Pausable:
    """Pausable contract with owner-only pause control.

    Inherits the Ownable pattern: only the owner can pause/unpause.
    Other methods should call self._when_not_paused() to respect pause state.
    """

    class State:
        owner: str = ""
        pending_owner: str = ""
        paused: bool = False
        pause_count: int = 0

    # ---- Ownable methods (inherited pattern) ----

    def _only_owner(self, ctx):
        if ctx.sender != self.state.owner:
            raise ValueError(f"Ownable: caller {ctx.sender} is not the owner")

    @constructor
    def deploy(self, ctx):
        self.state.owner = ctx.sender
        self.emit(
            event.OwnershipTransferred(
                previous_owner="",
                new_owner=ctx.sender,
            )
        )
        print(f"Pausable deployed, owner={ctx.sender}")

    # ---- Pausable extension ----

    def _when_not_paused(self):
        """Internal check: revert if contract is paused."""
        if self.state.paused:
            raise ValueError("Pausable: contract is paused")

    def _when_paused(self):
        """Internal check: revert if contract is NOT paused."""
        if not self.state.paused:
            raise ValueError("Pausable: contract is not paused")

    @call
    def pause(self, ctx):
        """Pause the contract. Only owner."""
        self._only_owner(ctx)
        self._when_not_paused()
        self.state.paused = True
        self.state.pause_count = self.state.pause_count + 1
        self.emit(event.Paused(account=ctx.sender))
        print(f"Contract paused by {ctx.sender}, pause count={self.state.pause_count}")

    @call
    def unpause(self, ctx):
        """Unpause the contract. Only owner."""
        self._only_owner(ctx)
        self._when_paused()
        self.state.paused = False
        self.emit(event.Unpaused(account=ctx.sender))
        print(f"Contract unpaused by {ctx.sender}")

    @query
    def is_paused(self) -> bool:
        return self.state.paused

    # ---- Example pausable method ----

    @call
    def protected_action(self, ctx, data: str):
        """Example method that respects pause state."""
        self._when_not_paused()
        self.emit(event.ActionPerformed(sender=ctx.sender, data=data))

Key details:

  • _when_not_paused() and _when_paused() are reciprocal guards -- pause() checks not-paused before pausing, unpause() checks paused before unpausing
  • pause_count tracks how many times the contract has been paused (useful for auditing)
  • The Ownable pattern is composed in directly (State includes owner and pending_owner)

Role-Based Access Control

For contracts needing multiple permission levels, the AccessControl contract provides a full RBAC system with admin hierarchies and SHA-256 hashed role identifiers.

# Source: contracts/patterns/access_control.py
from panda import contract, constructor, call, query, event
from panda.crypto import sha256


# Well-known role identifiers (hashed for consistency)
ADMIN_ROLE = sha256("ADMIN_ROLE")
MINTER_ROLE = sha256("MINTER_ROLE")
PAUSER_ROLE = sha256("PAUSER_ROLE")
UPGRADER_ROLE = sha256("UPGRADER_ROLE")


@contract
class AccessControl:
    """Role-based access control with admin hierarchy."""

    class State:
        owner: str = ""
        roles: dict = {}
        role_admins: dict = {}

    @constructor
    def deploy(self, ctx):
        self.state.owner = ctx.sender
        # Grant deployer the admin role
        roles = {}
        roles[ADMIN_ROLE] = {ctx.sender: True}
        self.state.roles = roles
        # Admin role is its own admin
        self.state.role_admins = {ADMIN_ROLE: ADMIN_ROLE}
        self.emit(
            event.RoleGranted(
                role=ADMIN_ROLE,
                account=ctx.sender,
                sender=ctx.sender,
            )
        )
        print(f"AccessControl deployed, admin={ctx.sender}")

    def _has_role(self, role: str, account: str) -> bool:
        """Check if account has the given role."""
        role_members = self.state.roles.get(role, {})
        return bool(role_members.get(account, False))

    def _check_role(self, role: str, account: str):
        """Revert if account does not have the role."""
        if not self._has_role(role, account):
            raise ValueError(f"AccessControl: account {account} is missing role {role[:16]}...")

    def _get_role_admin(self, role: str) -> str:
        """Get the admin role for a given role."""
        return self.state.role_admins.get(role, ADMIN_ROLE)

    @call
    def grant_role(self, ctx, role: str, account: str):
        """Grant a role to an account. Caller must have the role's admin role."""
        admin_role = self._get_role_admin(role)
        self._check_role(admin_role, ctx.sender)
        if self._has_role(role, account):
            return  # Already has role, no-op
        roles = dict(self.state.roles)
        members = dict(roles.get(role, {}))
        members[account] = True
        roles[role] = members
        self.state.roles = roles
        self.emit(
            event.RoleGranted(
                role=role,
                account=account,
                sender=ctx.sender,
            )
        )
        print(f"Role {role[:16]}... granted to {account} by {ctx.sender}")

    @call
    def revoke_role(self, ctx, role: str, account: str):
        """Revoke a role from an account. Caller must have the role's admin role."""
        admin_role = self._get_role_admin(role)
        self._check_role(admin_role, ctx.sender)
        if not self._has_role(role, account):
            return  # Doesn't have role, no-op
        roles = dict(self.state.roles)
        members = dict(roles.get(role, {}))
        if account in members:
            del members[account]
        roles[role] = members
        self.state.roles = roles
        self.emit(
            event.RoleRevoked(
                role=role,
                account=account,
                sender=ctx.sender,
            )
        )

    @call
    def renounce_role(self, ctx, role: str):
        """Renounce a role from yourself. Cannot renounce for others."""
        if not self._has_role(role, ctx.sender):
            return
        roles = dict(self.state.roles)
        members = dict(roles.get(role, {}))
        if ctx.sender in members:
            del members[ctx.sender]
        roles[role] = members
        self.state.roles = roles
        self.emit(
            event.RoleRevoked(
                role=role,
                account=ctx.sender,
                sender=ctx.sender,
            )
        )

    @call
    def set_role_admin(self, ctx, role: str, admin_role: str):
        """Set the admin role for a role. Only current admin can change this."""
        current_admin = self._get_role_admin(role)
        self._check_role(current_admin, ctx.sender)
        admins = dict(self.state.role_admins)
        admins[role] = admin_role
        self.state.role_admins = admins
        self.emit(
            event.RoleAdminChanged(
                role=role,
                previous_admin=current_admin,
                new_admin=admin_role,
            )
        )

    @query
    def has_role(self, role: str, account: str) -> bool:
        return self._has_role(role, account)

    @query
    def get_role_admin(self, role: str) -> str:
        return self._get_role_admin(role)

    @query
    def get_role_members(self, role: str) -> list:
        members = self.state.roles.get(role, {})
        return [addr for addr, active in members.items() if active]

Key design decisions:

  • Hashed role IDs (sha256("ADMIN_ROLE")) provide consistent, collision-resistant identifiers
  • Admin hierarchy: each role has an admin role that controls who can grant/revoke it. By default, ADMIN_ROLE administers all roles
  • renounce_role lets users voluntarily give up a role -- they can only renounce their own roles, not others'
  • set_role_admin allows restructuring the admin hierarchy (e.g., making MINTER_ROLE administered by a different role than ADMIN_ROLE)

Timelock

The TimelockVault demonstrates time-locked operations with cryptographic key derivation for secure withdrawals.

# Source: contracts/crypto/timelock_vault.py (excerpt)
from panda import contract, constructor, call, query, event
from panda.crypto import sha256


@contract
class TimelockVault:
    """Time-locked vault with derived-key withdrawal and HMAC recovery."""

    class State:
        deposits: dict = {}
        next_deposit_id: int = 1
        recovery_key_hash: str = ""
        owner: str = ""
        total_deposited: int = 0
        total_withdrawn: int = 0

    @constructor
    def deploy(self, ctx, recovery_key_hash: str):
        """Deploy with a hashed recovery key: sha256(recovery_secret)."""
        self.state.owner = ctx.sender
        self.state.recovery_key_hash = recovery_key_hash
        print(f"TimelockVault deployed by {ctx.sender}")

    @call
    def deposit(self, ctx, amount: int, unlock_time: int, key_hash: str):
        """Create a time-locked deposit.

        key_hash = sha256(derive_key(user_secret, "vault-withdraw", rounds=1000))
        The depositor must remember their secret to withdraw later.
        """
        if amount <= 0:
            raise ValueError("amount must be positive")
        if unlock_time <= ctx.block_time:
            raise ValueError("unlock time must be in the future")

        did = self.state.next_deposit_id
        deposits = dict(self.state.deposits)
        deposits[str(did)] = {
            "depositor": ctx.sender,
            "amount": amount,
            "unlock_time": unlock_time,
            "key_hash": key_hash,
            "withdrawn": False,
            "created_block": ctx.block_height,
        }
        self.state.deposits = deposits
        self.state.next_deposit_id = did + 1
        self.state.total_deposited = self.state.total_deposited + amount

        self.emit(
            event.Deposited(
                deposit_id=did,
                depositor=ctx.sender,
                amount=amount,
                unlock_time=unlock_time,
            )
        )

    @call
    def withdraw(self, ctx, deposit_id: int, derived_key: str):
        """Withdraw a deposit after unlock time.

        derived_key = derive_key(user_secret, "vault-withdraw", rounds=1000)
        The contract verifies sha256(derived_key) matches the stored key_hash.
        """
        key = str(deposit_id)
        deposits = dict(self.state.deposits)
        deposit = deposits.get(key)
        if not deposit:
            raise ValueError("deposit not found")
        if deposit["withdrawn"]:
            raise ValueError("already withdrawn")
        if ctx.block_time < deposit["unlock_time"]:
            raise ValueError("deposit is still locked")
        if deposit["depositor"] != ctx.sender:
            raise ValueError("only depositor can withdraw")

        # Verify derived key
        if sha256(derived_key) != deposit["key_hash"]:
            raise ValueError("invalid withdrawal key")

        deposit["withdrawn"] = True
        deposits[key] = deposit
        self.state.deposits = deposits
        self.state.total_withdrawn = self.state.total_withdrawn + deposit["amount"]

        self.emit(
            event.Withdrawn(
                deposit_id=deposit_id,
                depositor=ctx.sender,
                amount=deposit["amount"],
            )
        )

Two-factor security: withdrawal requires both time expiry AND a derived key. Even if an attacker knows the unlock time, they cannot withdraw without the depositor's secret.

Combining Patterns

Real governance systems combine multiple patterns:

  1. DAO + Token: Voting weight derived from PRC-20 token balance via cross-contract queries (as shown in DAOVoting)
  2. DAO + Timelock: Passed proposals are queued in a TimelockVault with a delay before execution
  3. DAO + AccessControl: Role-based permissions control who can create proposals, execute actions, or manage the DAO itself
  4. Ownable + Pausable: Emergency stop mechanism with safe two-step ownership transfer

Try It

  • Open the Playground and select the DAO Voting template
  • Follow the DAO Governance tutorial for a guided walkthrough
  • See the Crypto & Security guide for multisig wallets