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_reward every 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 ID
  • staking.undelegate() -- begin unbonding, returns a record ID
  • staking.complete_unbonding() -- release tokens after the unbonding period
  • staking.claim_rewards() -- pull accumulated rewards
  • staking.validator() -- read-only query for validator info
  • staking.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=500 means 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_delegation parameter (configurable by admin, default varies by chain config).
  • Allow-list membership -- the caller's address must be in the admin-managed authorized_validators allow-list. The admin adds an address via set_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

  1. Every block, the consensus layer calls StakingRegistry.accrue_block_reward(amount). This is gated by the panda-consensus sentinel -- no user or contract can call it.
  2. The reward amount is divided proportionally among all active validators based on their total_delegated.
  3. Within each validator's share, the commission (in bps) goes to the validator, and the remainder is distributed proportionally to all delegators.
  4. 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 BPSPercentageValidator keepsDelegators receive
00%Nothing100% of rewards
5005%5% of block rewards95% of block rewards
100010%10% of block rewards90% of block rewards
200020%20% of block rewards80% 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 to accrue_block_reward
  • PandaSlashingBps -- 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:

  1. Double-signing -- the validator signs two different blocks at the same height. Detected by the consensus engine.
  2. 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:

OffenseTypical BPSPercentage
Extended downtime1001%
Double-signing500-10005-10%
Configurable via slashing_bps_dishonestyvariesvaries

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) / 10000 tokens
  • 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.

FunctionParametersReturnsDescription
staking.delegatevalidator: str, amount: int, ctxstr (delegation ID)Stake tokens to a validator
staking.undelegatevalidator: str, amount: int, ctxstr (record ID)Start unbonding tokens
staking.complete_unbondingrecord_id: str, ctxint (released amount)Finalize unbonding after waiting period
staking.claim_rewardsvalidator: str, ctxint (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).

FunctionParametersReturnsDescription
staking.validatoraddress: strValidatorInfoGet a single validator's info
staking.my_delegationsctxlist[Delegation]List caller's delegations
staking.list_validatorsactive_only: boollist[ValidatorInfo]List all or active validators
staking.get_staking_params(none)StakingParamsGet chain-wide staking parameters
staking.get_rewards_pool(none)intGet the current reward pool balance

Data Types

ValidatorInfo

FieldTypeDescription
addressstrValidator's on-chain address
monikerstrHuman-readable name
commission_bpsintCommission rate in basis points
total_delegatedintTotal tokens delegated to this validator
self_delegationintValidator's own stake
statusstr"active" or "jailed"
uptime_bpsintUptime percentage in basis points (9950 = 99.50%)
registered_at_blockintBlock number at which the validator registered

Delegation

FieldTypeDescription
delegatorstrDelegator's address
validatorstrValidator's address
amountintAmount currently delegated
pending_rewardsintUnclaimed rewards
last_claim_blockintBlock at which rewards were last claimed

UnbondingRecord

FieldTypeDescription
idstrUnique record identifier
delegatorstrDelegator's address
validatorstrValidator's address
amountintAmount being unbonded
complete_after_blockintBlock after which unbonding can be completed
completedboolWhether the unbonding has been finalized

StakingParams

FieldTypeDescription
unbonding_period_blocksintNumber of blocks tokens must wait during unbonding
commission_max_bpsintMaximum allowed validator commission
min_self_delegationintMinimum self-delegation required to register a validator
slashing_bps_dishonestyintDefault 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:

MethodAccessDescription
register_validator(commission_bps, moniker, self_delegation)Allow-listedRegister as a validator (caller must be in the admin-managed authorized_validators allow-list)
delegate(validator, amount)AnyoneDelegate tokens to a validator
undelegate(validator, amount)AnyoneStart unbonding tokens
complete_unbonding(record_id)AnyoneComplete an unbonding
claim_rewards(validator)AnyoneClaim pending rewards
accrue_block_reward(amount)Consensus or AdminDistribute block reward (gated by the panda-consensus sentinel; admin may also top up)
slash_validator(validator, bps)Admin onlySlash a validator's stake
set_authorized_validator(addr, authorized)Admin onlyAdd or remove an address from the validator allow-list
set_param(name, value)Admin onlyUpdate a staking parameter
pause()Admin onlyPause mutating operations
unpause()Admin onlyResume mutating operations

@query methods:

MethodDescription
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 1e18 scaling 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

PropertyGuarantee
Reward accuracyMasterChef accumulator with 1e18 precision
Re-entrancy safetyPull-based rewards, CEI ordering
Admin safetyNo method moves user funds to admin
Unstuck fundscomplete_unbonding is anyone-can-call
Pause safetyclaim_rewards and complete_unbonding work during pause
Reward authenticityaccrue_block_reward gated by consensus sentinel
Slash fairnessProportional 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