Staking
Panda has a native staking system built as a system contract. Validators register on-chain, delegators stake tokens to them, and block rewards are distributed proportionally using a MasterChef-style accumulator. The panda.staking SDK module provides a Pythonic interface for all staking operations -- delegate, undelegate, claim rewards, and query validator state -- directly from your contracts.
Overview
The staking system has four participants:
- Validators -- register on-chain with a commission rate and self-delegation. They produce blocks and earn rewards.
- Delegators -- stake tokens to validators. Their tokens increase the validator's voting power, and they earn a share of block rewards minus the validator's commission.
- Consensus layer -- calls
accrue_block_rewardevery block to distribute rewards into the accumulator. Only the consensus sentinel (panda-consensus) can invoke this. - Admin -- can slash validators for misbehavior, adjust staking parameters, and pause/unpause the registry. Cannot drain user funds.
All staking logic lives in the StakingRegistry system contract. The panda.staking SDK module wraps it with a clean Python API.
Quick Start
Here is a contract that auto-delegates incoming deposits to a validator and lets users claim their rewards:
# Source: contracts/examples/staking_vault.py
from panda import contract, constructor, call, query, event
from panda import staking
@contract
class StakingVault:
"""A vault that delegates deposits to a validator and distributes rewards."""
class State:
validator: str = ""
owner: str = ""
depositors: dict = {}
total_deposited: int = 0
@constructor
def deploy(self, ctx, validator: str):
"""Deploy the vault targeting a specific validator."""
self.state.validator = validator
self.state.owner = ctx.sender
# Verify the validator exists and is active
info = staking.validator(validator)
if info.status != "active":
raise ValueError("validator is not active")
self.emit(event.VaultDeployed(
owner=ctx.sender,
validator=validator,
))
@call
def deposit(self, ctx, amount: int):
"""Deposit tokens and delegate them to the vault's validator."""
if amount <= 0:
raise ValueError("amount must be positive")
# Delegate to the validator
delegation_id = staking.delegate(
validator=self.state.validator,
amount=amount,
ctx=ctx,
)
# Track the depositor
depositors = dict(self.state.depositors)
depositors[ctx.sender] = depositors.get(ctx.sender, 0) + amount
self.state.depositors = depositors
self.state.total_deposited = self.state.total_deposited + amount
self.emit(event.Deposited(
depositor=ctx.sender,
amount=amount,
delegation_id=delegation_id,
))
@call
def claim(self, ctx):
"""Claim accumulated staking rewards from the validator."""
claimed = staking.claim_rewards(
validator=self.state.validator,
ctx=ctx,
)
self.emit(event.RewardsClaimed(
claimer=ctx.sender,
amount=claimed,
))
return claimed
@call
def withdraw(self, ctx, amount: int):
"""Begin unbonding tokens from the validator."""
if amount <= 0:
raise ValueError("amount must be positive")
depositors = dict(self.state.depositors)
balance = depositors.get(ctx.sender, 0)
if amount > balance:
raise ValueError("insufficient balance")
record_id = staking.undelegate(
validator=self.state.validator,
amount=amount,
ctx=ctx,
)
depositors[ctx.sender] = balance - amount
self.state.depositors = depositors
self.state.total_deposited = self.state.total_deposited - amount
self.emit(event.WithdrawalStarted(
depositor=ctx.sender,
amount=amount,
record_id=record_id,
))
return record_id
@call
def complete_withdrawal(self, ctx, record_id: str):
"""Complete an unbonding after the waiting period."""
released = staking.complete_unbonding(
record_id=record_id,
ctx=ctx,
)
self.emit(event.WithdrawalCompleted(
depositor=ctx.sender,
amount=released,
))
return released
@query
def get_validator_info(self) -> dict:
"""Return info about the vault's validator."""
info = staking.validator(self.state.validator)
return {
"address": info.address,
"moniker": info.moniker,
"commission_bps": info.commission_bps,
"total_delegated": info.total_delegated,
"uptime_bps": info.uptime_bps,
"status": info.status,
}
@query
def get_depositor(self, address: str) -> int:
"""Return how much an address has deposited."""
return self.state.depositors.get(address, 0)
@query
def get_staking_stats(self) -> dict:
"""Return global staking parameters and reward pool info."""
params = staking.get_staking_params()
pool = staking.get_rewards_pool()
return {
"unbonding_period_blocks": params.unbonding_period_blocks,
"slashing_bps_dishonesty": params.slashing_bps_dishonesty,
"rewards_pool": pool,
"total_deposited": self.state.total_deposited,
}
Key concepts demonstrated:
staking.delegate()-- stake tokens to a validator, returns a delegation IDstaking.undelegate()-- begin unbonding, returns a record IDstaking.complete_unbonding()-- release tokens after the unbonding periodstaking.claim_rewards()-- pull accumulated rewardsstaking.validator()-- read-only query for validator infostaking.get_staking_params()-- read-only query for chain-wide parameters
Validator Registration
Validators register by calling register_validator on the StakingRegistry system contract. This is typically done via a governance transaction or the CLI, not from application contracts, but the SDK exposes it for completeness.
Registration Requirements
- Commission rate in basis points (bps). For example,
commission_bps=500means 5% commission on delegator rewards. - Moniker -- a human-readable name for the validator.
- Self-delegation -- the initial amount the validator stakes to itself. Must meet the
min_self_delegationparameter (configurable by admin, default varies by chain config). - Allow-list membership -- the caller's address must be in the admin-managed
authorized_validatorsallow-list. The admin adds an address viaset_authorized_validator(addr, True)before that address can register. This prevents arbitrary self-registration that would dilute rewards.
StakingRegistry register_validator
# This is a @call method on the StakingRegistry system contract.
# Validators call it directly via transaction, not from other contracts.
# Registration via CLI:
# panda tx staking register-validator \
# --commission-bps 500 \
# --moniker "My Validator" \
# --self-delegation 10000
# What happens on-chain:
# StakingRegistry.register_validator(
# commission_bps=500,
# moniker="My Validator",
# self_delegation=10000,
# )
After registration, the validator appears in staking.list_validators() with status "active". The ValidatorInfo object returned by staking.validator(address) contains:
info = staking.validator("0xVal...")
info.address # "0xVal..." -- validator's address
info.moniker # "My Validator" -- human-readable name
info.commission_bps # 500 -- 5% commission
info.total_delegated # 10000 -- total tokens delegated (including self)
info.self_delegation # 10000 -- validator's own stake
info.status # "active" -- active / jailed
info.uptime_bps # 9950 -- 99.50% uptime
info.registered_at_block # 12345 -- block number at registration
Listing Validators
# All validators (including jailed)
all_validators = staking.list_validators(active_only=False)
# Only active validators
active = staking.list_validators(active_only=True)
for v in active:
print(f"{v.moniker}: {v.total_delegated} staked, {v.commission_bps}bps commission")
Delegation Flow
Delegation is the primary way users participate in staking. The flow has three steps: delegate, undelegate (start unbonding), and complete unbonding.
Step 1: Delegate
from panda import staking
# Delegate 1000 tokens to a validator
delegation_id = staking.delegate(
validator="0xVal...",
amount=1000,
ctx=ctx,
)
This transfers amount tokens from the caller to the staking pool and increases the validator's total_delegated. The delegator begins earning rewards immediately from the next block.
Step 2: Query Your Delegations
# Get all delegations for the current caller
my_delegations = staking.my_delegations(ctx=ctx)
for d in my_delegations:
print(f"Validator: {d.validator}")
print(f" Amount: {d.amount}")
print(f" Pending rewards: {d.pending_rewards}")
print(f" Last claim block: {d.last_claim_block}")
Each Delegation object has fields for delegator, validator, amount, pending_rewards, and last_claim_block. See the full type definition in the SDK Reference section below.
Step 3: Undelegate (Start Unbonding)
# Start unbonding 500 tokens
record_id = staking.undelegate(
validator="0xVal...",
amount=500,
ctx=ctx,
)
# record_id is used later to complete the unbonding
Undelegation does not return tokens immediately. It creates an UnbondingRecord with a complete_after_block based on the chain's unbonding_period_blocks parameter. During the unbonding period, the tokens do not earn rewards and cannot be re-delegated.
Step 4: Complete Unbonding
# After the unbonding period has elapsed:
released = staking.complete_unbonding(
record_id=record_id,
ctx=ctx,
)
# released is the amount of tokens returned to the delegator
The complete_unbonding call can be made by anyone -- it is not restricted to the delegator. This design prevents funds from getting stuck if the delegator loses access to their account.
Full Delegation Lifecycle
delegate(validator, amount)
|
v
[Earning rewards] ---> claim_rewards(validator) ---> tokens to delegator
|
v
undelegate(validator, amount) ---> UnbondingRecord created
|
v
[Unbonding period: unbonding_period_blocks]
|
v
complete_unbonding(record_id) ---> tokens returned to delegator
Rewards
Block rewards are distributed every block via the consensus layer. The system uses a MasterChef-style accumulator for gas-efficient reward tracking.
How Rewards Work
- Every block, the consensus layer calls
StakingRegistry.accrue_block_reward(amount). This is gated by thepanda-consensussentinel -- no user or contract can call it. - The reward
amountis divided proportionally among all active validators based on theirtotal_delegated. - Within each validator's share, the commission (in bps) goes to the validator, and the remainder is distributed proportionally to all delegators.
- The accumulator (
acc_rewards_per_share * 1e18) tracks cumulative rewards per unit of stake. When a delegator claims, the contract computes the difference since their last claim.
Claiming Rewards
from panda import staking
# Claim rewards from a specific validator
claimed = staking.claim_rewards(
validator="0xVal...",
ctx=ctx,
)
print(f"Claimed {claimed} tokens in rewards")
Rewards are pull-based: they accumulate in the contract and the delegator must explicitly claim them. This design avoids iteration over all delegators during reward distribution (which would be O(n) gas per block) and instead makes each claim O(1).
Checking Pending Rewards
# Check pending rewards without claiming
delegations = staking.my_delegations(ctx=ctx)
for d in delegations:
print(f"Validator {d.validator}: {d.pending_rewards} pending")
Reward Accumulator Math
The accumulator follows the MasterChef pattern:
When a block reward R is accrued to a validator with total_delegated T:
acc_rewards_per_share += (R * 1e18) / T
When delegator with amount A claims:
pending = (A * acc_rewards_per_share) / 1e18 - reward_debt
reward_debt = (A * acc_rewards_per_share) / 1e18
The 1e18 scaling factor prevents precision loss from integer division. This means reward calculations are accurate down to the smallest token unit regardless of pool size.
Validator Commission
Validators set their commission rate at registration time as basis points:
| Commission BPS | Percentage | Validator keeps | Delegators receive |
|---|---|---|---|
| 0 | 0% | Nothing | 100% of rewards |
| 500 | 5% | 5% of block rewards | 95% of block rewards |
| 1000 | 10% | 10% of block rewards | 90% of block rewards |
| 2000 | 20% | 20% of block rewards | 80% of block rewards |
The maximum commission is controlled by the commission_max_bps staking parameter.
Chain Configuration
On the Go side (panda-geth), two chain config parameters control rewards:
PandaBlockReward-- the amount of tokens minted per block and passed toaccrue_block_rewardPandaSlashingBps-- the default slash rate for validator misbehavior
The consensus layer calls AccrueBlockReward() from the Finalize path every block, and SlashValidator() when double-signing or downtime is detected.
If the admin pauses the StakingRegistry, claim_rewards and complete_unbonding still work. Pausing stops new delegations and undelegations but never traps user funds or rewards.
Unbonding
Unbonding is the process of withdrawing staked tokens. It exists to protect the network: if validators could withdraw instantly, they could misbehave and exit before being slashed.
Unbonding Period
The unbonding period is defined in blocks by the unbonding_period_blocks staking parameter. During this period:
- Tokens do not earn rewards
- Tokens can still be slashed if the validator misbehaves
- Tokens cannot be re-delegated or transferred
# Check the current unbonding period
params = staking.get_staking_params()
print(f"Unbonding period: {params.unbonding_period_blocks} blocks")
Multiple Unbondings
A delegator can have multiple unbonding records active simultaneously. Each has its own complete_after_block and is tracked independently:
# Undelegate in two batches
record_1 = staking.undelegate(validator="0xVal...", amount=300, ctx=ctx)
record_2 = staking.undelegate(validator="0xVal...", amount=200, ctx=ctx)
# Later, complete each independently
staking.complete_unbonding(record_id=record_1, ctx=ctx)
staking.complete_unbonding(record_id=record_2, ctx=ctx)
The complete_unbonding call is not restricted to the original delegator -- any account can call it after the unbonding period. Tokens always go to the original delegator address in the UnbondingRecord, regardless of who triggers completion. This prevents funds from getting stuck if a delegator loses wallet access.
Slashing
Slashing is the penalty mechanism for validator misbehavior. When a validator is slashed, a percentage of their total delegation (including all delegators' stakes) is destroyed.
Slash Triggers
Slashing is initiated by the consensus layer (Go side) in two cases:
- Double-signing -- the validator signs two different blocks at the same height. Detected by the consensus engine.
- Downtime -- the validator misses too many consecutive blocks. Detected by the liveness tracker.
When either is detected, the Geth consensus code calls SlashValidator(validator, bps) on the StakingRegistry.
Slash Rate
The slash rate is specified in basis points:
| Offense | Typical BPS | Percentage |
|---|---|---|
| Extended downtime | 100 | 1% |
| Double-signing | 500-1000 | 5-10% |
Configurable via slashing_bps_dishonesty | varies | varies |
The admin can also manually slash via slash_validator(validator, bps) for governance-decided penalties.
Impact on Delegators
When a validator is slashed by bps basis points:
- Every delegation to that validator loses
(amount * bps) / 10000tokens - Unbonding records for that validator also lose the same proportion
- The validator's self-delegation is slashed the same way
- Slashed tokens are burned (removed from supply)
This means delegators share the risk of the validator they choose. Picking a reliable validator with high uptime is important.
Checking Validator Health
Before delegating, always check the validator's track record:
info = staking.validator("0xVal...")
# uptime_bps of 9950 means 99.50% uptime
if info.uptime_bps < 9500:
print("Warning: validator has less than 95% uptime")
if info.status != "active":
print(f"Warning: validator status is {info.status}")
# Compare commission rates across validators
active = staking.list_validators(active_only=True)
for v in active:
print(f"{v.moniker}: uptime={v.uptime_bps}bps, commission={v.commission_bps}bps")
SDK Reference
Mutating Operations
These require ctx (execution context) and cost gas. They must be called from within a @call method.
| Function | Parameters | Returns | Description |
|---|---|---|---|
staking.delegate | validator: str, amount: int, ctx | str (delegation ID) | Stake tokens to a validator |
staking.undelegate | validator: str, amount: int, ctx | str (record ID) | Start unbonding tokens |
staking.complete_unbonding | record_id: str, ctx | int (released amount) | Finalize unbonding after waiting period |
staking.claim_rewards | validator: str, ctx | int (claimed amount) | Claim accumulated staking rewards |
Read-Only Operations
These do not require ctx and can be called from @query methods. They are free (no gas cost for queries).
| Function | Parameters | Returns | Description |
|---|---|---|---|
staking.validator | address: str | ValidatorInfo | Get a single validator's info |
staking.my_delegations | ctx | list[Delegation] | List caller's delegations |
staking.list_validators | active_only: bool | list[ValidatorInfo] | List all or active validators |
staking.get_staking_params | (none) | StakingParams | Get chain-wide staking parameters |
staking.get_rewards_pool | (none) | int | Get the current reward pool balance |
Data Types
ValidatorInfo
| Field | Type | Description |
|---|---|---|
address | str | Validator's on-chain address |
moniker | str | Human-readable name |
commission_bps | int | Commission rate in basis points |
total_delegated | int | Total tokens delegated to this validator |
self_delegation | int | Validator's own stake |
status | str | "active" or "jailed" |
uptime_bps | int | Uptime percentage in basis points (9950 = 99.50%) |
registered_at_block | int | Block number at which the validator registered |
Delegation
| Field | Type | Description |
|---|---|---|
delegator | str | Delegator's address |
validator | str | Validator's address |
amount | int | Amount currently delegated |
pending_rewards | int | Unclaimed rewards |
last_claim_block | int | Block at which rewards were last claimed |
UnbondingRecord
| Field | Type | Description |
|---|---|---|
id | str | Unique record identifier |
delegator | str | Delegator's address |
validator | str | Validator's address |
amount | int | Amount being unbonded |
complete_after_block | int | Block after which unbonding can be completed |
completed | bool | Whether the unbonding has been finalized |
StakingParams
| Field | Type | Description |
|---|---|---|
unbonding_period_blocks | int | Number of blocks tokens must wait during unbonding |
commission_max_bps | int | Maximum allowed validator commission |
min_self_delegation | int | Minimum self-delegation required to register a validator |
slashing_bps_dishonesty | int | Default slash rate for dishonest behavior |
StakingRegistry System Contract Methods
The StakingRegistry is a system contract deployed at a well-known address. The SDK wraps these methods, but they can also be called directly via cross-contract calls.
@call methods:
| Method | Access | Description |
|---|---|---|
register_validator(commission_bps, moniker, self_delegation) | Allow-listed | Register as a validator (caller must be in the admin-managed authorized_validators allow-list) |
delegate(validator, amount) | Anyone | Delegate tokens to a validator |
undelegate(validator, amount) | Anyone | Start unbonding tokens |
complete_unbonding(record_id) | Anyone | Complete an unbonding |
claim_rewards(validator) | Anyone | Claim pending rewards |
accrue_block_reward(amount) | Consensus or Admin | Distribute block reward (gated by the panda-consensus sentinel; admin may also top up) |
slash_validator(validator, bps) | Admin only | Slash a validator's stake |
set_authorized_validator(addr, authorized) | Admin only | Add or remove an address from the validator allow-list |
set_param(name, value) | Admin only | Update a staking parameter |
pause() | Admin only | Pause mutating operations |
unpause() | Admin only | Resume mutating operations |
@query methods:
| Method | Description |
|---|---|
get_validator(address) | Get validator info |
get_delegation(delegator, validator) | Get a specific delegation |
get_delegations_for(delegator) | List all delegations for an address |
list_validators(active_only) | List validators |
get_params() | Get staking parameters |
get_unbonding_record(record_id) | Get an unbonding record |
stats() | Get aggregate staking statistics |
Security Model
The staking system is designed to prevent common attack vectors in on-chain staking protocols.
MasterChef-Style Accumulator
Rewards use a global accumulator (acc_rewards_per_share * 1e18) rather than per-delegator reward tracking. This means:
- O(1) reward distribution per block -- no iteration over delegators
- O(1) reward claiming -- computed from the difference between current accumulator and the delegator's last-known value
- No rounding drift -- the
1e18scaling factor prevents integer division precision loss
No Re-Entrancy
The staking contract uses pull-based reward distribution with Checks-Effects-Interactions (CEI) ordering: check inputs, update state, then transfer tokens. Because rewards accumulate in the contract and delegators pull them, there is no callback that could trigger re-entrancy.
Admin Cannot Drain Funds
The admin role can slash_validator (burns tokens -- does not transfer to admin), set_param (adjusts parameters, not balances), and pause/unpause (stops new delegations, does not touch funds). There is no method that moves user funds to the admin address.
Funds Cannot Get Stuck
Two design decisions prevent stuck funds: complete_unbonding is anyone-can-call (if the original delegator loses access, any account can finalize and return tokens), and claim_rewards works during pause (the admin cannot trap rewards by pausing).
Consensus Sentinel
The accrue_block_reward method is gated by the panda-consensus sentinel -- only the consensus layer (Go code in panda-geth) can call it. Block rewards cannot be spoofed or inflated.
Summary of Guarantees
| Property | Guarantee |
|---|---|
| Reward accuracy | MasterChef accumulator with 1e18 precision |
| Re-entrancy safety | Pull-based rewards, CEI ordering |
| Admin safety | No method moves user funds to admin |
| Unstuck funds | complete_unbonding is anyone-can-call |
| Pause safety | claim_rewards and complete_unbonding work during pause |
| Reward authenticity | accrue_block_reward gated by consensus sentinel |
| Slash fairness | Proportional across all delegators, including validator self-delegation |
Try It
- Open the Playground and deploy a contract that uses
from panda import staking - Query active validators with
staking.list_validators(active_only=True)in a Notebook - See the Governance guide for DAO-controlled validator management
- See the DeFi Patterns guide for staking-integrated DeFi protocols
- Check the SDK Reference for the full list of built-in modules