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 = 1000means 10% of total token supply must participate for the vote to be validapproval_bps = 5000means 50% of cast votes must be in favor for the proposal to pass
A proposal passes when:
- Quorum is met:
total_votes >= (total_supply * quorum_bps) / 10000 - 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_ofduringcreate_proposalandvote-- ensures proposers and voters hold tokenstotal_supplyduringfinalize_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 unpausingpause_counttracks how many times the contract has been paused (useful for auditing)- The Ownable pattern is composed in directly (State includes
ownerandpending_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_ROLEadministers all roles renounce_rolelets users voluntarily give up a role -- they can only renounce their own roles, not others'set_role_adminallows restructuring the admin hierarchy (e.g., makingMINTER_ROLEadministered by a different role thanADMIN_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:
- DAO + Token: Voting weight derived from PRC-20 token balance via cross-contract queries (as shown in
DAOVoting) - DAO + Timelock: Passed proposals are queued in a
TimelockVaultwith a delay before execution - DAO + AccessControl: Role-based permissions control who can create proposals, execute actions, or manage the DAO itself
- 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