PRIV ProtocolPRIV Docs
Contracts

PRIVStakingV2 (ve-PRIV)

ve-PRIV staking contract with 5 lock tiers, weighted reward distribution, auto-compound, and tier upgrades.

Overview

PRIVStakingV2 is a ve-PRIV staking contract that distributes rewards proportionally to each staker's effective stake (raw amount multiplied by a lock-tier multiplier). Longer lock commitments earn disproportionately more rewards. It replaces the flat-lock PRIVStaking (V1) model with five configurable lock tiers, auto-compounding with a 15% bonus, and tier upgrades.

Both V1 and V2 implement the IPRIVStaking interface (fundRewardsPool), so either can receive fee distributions from FeeManagerV2.

PropertyValue
ContractPRIVStakingV2
Address (Base Sepolia)0xFd0b01756178DADCBCfCc1ED337c0e6Dde29B62E
Minimum Stake1 PRIV
Min Total Effective for Rewards1,000 PRIV
Lock Tiers5 (Flex, Bronze, Silver, Gold, Diamond)
Auto-Compound Bonus15%
Precision1e18
NetworkBase (Chain ID: 8453)

Key Features

  • 5 Lock Tiers: Flex (7d, 1.0x), Bronze (30d, 2.0x), Silver (90d, 3.5x), Gold (180d, 5.0x), Diamond (365d, 8.0x)
  • Weighted Reward Distribution: Rewards accrue proportionally to effective stake, not raw amount
  • Auto-Compound: Re-stake rewards with a 15% bonus (deducted from pool, not minted)
  • Tier Upgrades: Upgrade to a higher tier anytime; downgrades are blocked
  • First Depositor Attack Prevention: Rewards only accrue when totalEffectiveStake >= 1,000 PRIV
  • Reward Pool Protection: totalPendingRewards tracking prevents owner from draining staker rewards
  • Anyone Can Fund: fundRewardsPool() is permissionless (used by FeeManagerV2)
  • Emergency Pause: Contract can be paused by owner

Lock Tiers

IndexNameLock DurationMultiplier (BPS)Multiplier
0Flex7 days10,0001.0x
1Bronze30 days20,0002.0x
2Silver90 days35,0003.5x
3Gold180 days50,0005.0x
4Diamond365 days80,0008.0x

Tier configurations can be updated by the owner via setTier(), but must maintain ascending order for both duration and multiplier.


How It Works

User chooses tier  -->  Stakes PRIV  -->  Effective stake calculated  -->  Rewards accrue
      |                     |                       |                          |
      v                     v                       v                          v
  Lock period set    Tokens locked       amount × multiplier / 10000    rewardRate × time
                                                                       / totalEffectiveStake
                                                    |
                                                    v
                                          Claim, auto-compound, or unstake after lock

Reward Calculation

Rewards use a weighted reward-per-token model. The key difference from V1 is that rewards are distributed based on effective stake rather than raw token amount:

rewardPerToken = rewardPerTokenStored +
                 (timeElapsed × rewardRate × PRECISION / totalEffectiveStake)

userReward = (effectiveAmount × (rewardPerToken - userRewardPerTokenPaid) / PRECISION)
             + pendingRewards

Important: Rewards only accrue when totalEffectiveStake >= MIN_TOTAL_EFFECTIVE_FOR_REWARDS (1,000 PRIV). This prevents first depositor attacks where a small stake could capture outsized rewards.

APY Estimation

APY is calculated per tier since each tier has a different multiplier:

estimatedAPY(tierIndex) = (rewardRate × 365 days × multiplierBps × 10000)
                          / (totalEffectiveStake × BPS_DENOMINATOR)

Returns APY in basis points (100 = 1%).


Contract Interface

Constants

uint256 public constant PRECISION = 1e18;
uint256 public constant BPS_DENOMINATOR = 10_000;
uint256 public constant MIN_STAKE_AMOUNT = 1e18;              // 1 PRIV
uint256 public constant MIN_TOTAL_EFFECTIVE_FOR_REWARDS = 1000e18; // 1,000 PRIV effective
uint256 public constant AUTO_COMPOUND_BONUS_BPS = 1500;       // 15%
uint256 public constant NUM_TIERS = 5;

State Variables

IERC20 public immutable privToken;       // The PRIV token contract
LockTier[5] public tiers;               // Lock tier configurations (index 0-4)
uint256 public rewardRate;               // Tokens per second (scaled by PRECISION)
uint256 public totalStaked;              // Total actual PRIV staked
uint256 public totalEffectiveStake;      // Total weighted stake (sum of all effective amounts)
uint256 public rewardsPool;              // Total rewards available in the pool
uint256 public rewardPerTokenStored;     // Accumulated reward per effective token
uint256 public lastUpdateTime;           // Last time rewards were updated
uint256 public totalPendingRewards;      // Total pending rewards owed to all stakers

Structs

LockTier

struct LockTier {
    uint256 duration;       // Lock duration in seconds
    uint256 multiplierBps;  // Reward multiplier in BPS (10000 = 1x)
}

StakePosition

struct StakePosition {
    uint256 amount;           // Actual PRIV deposited
    uint256 effectiveAmount;  // amount × multiplierBps / BPS_DENOMINATOR
    uint8 tierIndex;          // Lock tier (0=Flex, 1=Bronze, 2=Silver, 3=Gold, 4=Diamond)
    uint256 stakedAt;         // Timestamp of last stake/upgrade
    uint256 unlockTime;       // When tokens can be withdrawn
}

Functions

User Functions

stake

Stake PRIV tokens with a chosen lock tier.

function stake(uint256 amount, uint8 tierIndex) external nonReentrant whenNotPaused
ParameterTypeDescription
amountuint256Amount of PRIV to stake (18 decimals)
tierIndexuint8Lock tier (0=Flex, 1=Bronze, 2=Silver, 3=Gold, 4=Diamond)

Requirements:

  • Amount must be greater than 0
  • First stake must be at least MIN_STAKE_AMOUNT (1 PRIV)
  • Tier index must be 0-4
  • Cannot downgrade tier on existing position
  • User must have approved the contract
  • Contract must not be paused

Effects:

  • Transfers tokens from user to contract
  • Calculates effective amount based on tier multiplier
  • If upgrading tier on existing position, recalculates entire effective amount
  • Resets lock timer to block.timestamp + tier.duration

unstake

Unstake all tokens and claim pending rewards.

function unstake() external nonReentrant whenNotPaused

Requirements:

  • User must have staked tokens
  • Lock period must have elapsed (block.timestamp >= unlockTime)
  • Contract must not be paused

Effects:

  • Claims all pending rewards
  • Returns all staked tokens to user
  • Clears position entirely

claimRewards

Claim pending rewards without unstaking.

function claimRewards() external nonReentrant whenNotPaused

Requirements:

  • User must have pending rewards greater than 0
  • Rewards pool must have sufficient balance
  • Contract must not be paused

autoCompound

Re-stake pending rewards with a 15% bonus. The bonus is deducted from the rewards pool, not minted.

function autoCompound() external nonReentrant whenNotPaused

Requirements:

  • User must have an active position
  • User must have pending rewards greater than 0
  • Rewards pool must have enough to cover reward + 15% bonus
  • Contract must not be paused

Effects:

  • Adds reward + bonus to the user's staked amount
  • Recalculates effective amount with current tier multiplier
  • Resets lock timer
  • Deducts total compound amount from rewards pool

upgradeTier

Upgrade to a higher lock tier for a better reward multiplier without depositing additional tokens.

function upgradeTier(uint8 newTierIndex) external nonReentrant whenNotPaused
ParameterTypeDescription
newTierIndexuint8The tier to upgrade to (must be strictly higher than current)

Requirements:

  • User must have an active position
  • New tier must be strictly higher than current tier (same tier reverts with SameTier)
  • Contract must not be paused

Effects:

  • Recalculates effective amount with new tier's multiplier
  • Resets lock timer to new tier's duration
  • Does not require additional token deposit

fundRewardsPool

Add tokens to the rewards pool. Anyone can call this function. FeeManagerV2 calls this via processFees() to route the staker share of protocol fees.

function fundRewardsPool(uint256 amount) external nonReentrant
ParameterTypeDescription
amountuint256Amount of PRIV to add to pool

View Functions

earned

Calculate pending rewards for a user.

function earned(address account) public view returns (uint256)

rewardPerToken

Get the current reward per effective token value.

function rewardPerToken() public view returns (uint256)

getPosition

Get the full stake position for a user.

function getPosition(address account) external view returns (StakePosition memory)

getTier

Get the lock tier configuration for a given index.

function getTier(uint8 tierIndex) external view returns (LockTier memory)

isLocked

Check if a user's tokens are still locked.

function isLocked(address account) external view returns (bool)

timeUntilUnlock

Get the remaining time until unlock (0 if already unlocked).

function timeUntilUnlock(address account) external view returns (uint256)

estimatedAPY

Get the estimated APY for a specific tier based on current parameters.

function estimatedAPY(uint8 tierIndex) external view returns (uint256)
ParameterTypeDescription
tierIndexuint8The tier to estimate APY for (0-4)

Returns: APY in basis points (100 = 1%)


availableRewardsForWithdrawal

Get rewards available for owner withdrawal (pool minus pending rewards).

function availableRewardsForWithdrawal() external view returns (uint256)

Admin Functions

setRewardRate

Update the reward rate (tokens per second, scaled by 1e18).

function setRewardRate(uint256 newRate) external onlyOwner

setTier

Update a lock tier's duration and multiplier. Does not affect existing positions. Tiers must maintain ascending order.

function setTier(uint8 tierIndex, uint256 duration, uint256 multiplierBps) external onlyOwner
ParameterTypeDescription
tierIndexuint8Which tier to update (0-4)
durationuint256New lock duration in seconds
multiplierBpsuint256New multiplier in basis points (10000 = 1x)

withdrawRewards

Withdraw excess rewards from the pool. Only withdraws from available rewards (pool minus totalPendingRewards), protecting staker rewards.

function withdrawRewards(uint256 amount) external onlyOwner

pause / unpause

Emergency pause controls.

function pause() external onlyOwner
function unpause() external onlyOwner

Events

/// @notice Emitted when a user stakes tokens
event Staked(address indexed user, uint256 amount, uint8 tierIndex, uint256 effectiveAmount, uint256 unlockTime);

/// @notice Emitted when a user unstakes tokens
event Unstaked(address indexed user, uint256 amount);

/// @notice Emitted when a user claims rewards
event RewardsClaimed(address indexed user, uint256 amount);

/// @notice Emitted when rewards are auto-compounded
event AutoCompounded(address indexed user, uint256 rewardAmount, uint256 bonusAmount, uint256 newEffectiveAmount);

/// @notice Emitted when a user upgrades their lock tier
event TierUpgraded(address indexed user, uint8 oldTier, uint8 newTier, uint256 newEffectiveAmount, uint256 newUnlockTime);

/// @notice Emitted when the rewards pool is funded
event RewardsPoolFunded(address indexed funder, uint256 amount);

/// @notice Emitted when the reward rate is updated
event RewardRateUpdated(uint256 oldRate, uint256 newRate);

/// @notice Emitted when a tier configuration is updated
event TierUpdated(uint8 indexed tierIndex, uint256 duration, uint256 multiplierBps);

/// @notice Emitted when rewards are withdrawn by owner
event RewardsWithdrawn(uint256 amount);

Errors

error InvalidAddress();
error InvalidAmount();
error InvalidTier();
error BelowMinimumStake();
error NoStake();
error TokensLocked();
error CannotDowngradeTier();
error InsufficientRewardsPool();
error InvalidRewardRate();
error InsufficientAvailableRewards();
error InvalidTierConfig();
error SameTier();

Usage Examples

Stake Tokens (Diamond Tier)

import { useWriteContract, useReadContract } from 'wagmi'
import { parseEther } from 'viem'

function useStakePriv() {
  const { writeContract } = useWriteContract()

  return async (amount: string, tierIndex: number) => {
    const amountWei = parseEther(amount)

    // 1. Approve staking contract
    await writeContract({
      address: PRIV_TOKEN_ADDRESS,
      abi: privTokenAbi,
      functionName: 'approve',
      args: [STAKING_V2_ADDRESS, amountWei],
    })

    // 2. Stake tokens with tier selection
    // Tier indexes: 0=Flex, 1=Bronze, 2=Silver, 3=Gold, 4=Diamond
    await writeContract({
      address: STAKING_V2_ADDRESS,
      abi: stakingV2Abi,
      functionName: 'stake',
      args: [amountWei, tierIndex],
    })
  }
}

Check Position and Pending Rewards

function useStakingPosition(address: string) {
  const { data: position } = useReadContract({
    address: STAKING_V2_ADDRESS,
    abi: stakingV2Abi,
    functionName: 'getPosition',
    args: [address],
  })

  const { data: pendingRewards } = useReadContract({
    address: STAKING_V2_ADDRESS,
    abi: stakingV2Abi,
    functionName: 'earned',
    args: [address],
  })

  return { position, pendingRewards }
}

Auto-Compound Rewards

function useAutoCompound() {
  const { writeContract } = useWriteContract()

  return () => {
    writeContract({
      address: STAKING_V2_ADDRESS,
      abi: stakingV2Abi,
      functionName: 'autoCompound',
    })
  }
}

Upgrade Tier

function useUpgradeTier() {
  const { writeContract } = useWriteContract()

  return (newTierIndex: number) => {
    writeContract({
      address: STAKING_V2_ADDRESS,
      abi: stakingV2Abi,
      functionName: 'upgradeTier',
      args: [newTierIndex],
    })
  }
}

Check APY per Tier

function useEstimatedAPY(tierIndex: number) {
  const { data: apyBps } = useReadContract({
    address: STAKING_V2_ADDRESS,
    abi: stakingV2Abi,
    functionName: 'estimatedAPY',
    args: [tierIndex],
  })

  // Convert basis points to percentage
  const apyPercent = apyBps ? Number(apyBps) / 100 : 0
  return apyPercent
}

Fund Rewards Pool

// Anyone can fund the rewards pool
async function fundPool(amount: string) {
  const amountWei = parseEther(amount)

  await writeContract({
    address: PRIV_TOKEN_ADDRESS,
    abi: privTokenAbi,
    functionName: 'approve',
    args: [STAKING_V2_ADDRESS, amountWei],
  })

  await writeContract({
    address: STAKING_V2_ADDRESS,
    abi: stakingV2Abi,
    functionName: 'fundRewardsPool',
    args: [amountWei],
  })
}

Security Notes

  1. First Depositor Attack Prevention: The contract requires a minimum of 1,000 PRIV total effective stake before rewards start accruing. This prevents attackers from staking small amounts to capture outsized rewards.

  2. Reward Pool Protection: The owner cannot withdraw rewards that are owed to stakers. totalPendingRewards is tracked separately and subtracted from the pool when calculating availableRewardsForWithdrawal().

  3. Lock Duration: Users should understand their chosen tier's lock duration before staking. Once staked, tokens cannot be withdrawn until the lock period expires. Tier upgrades and auto-compounds reset the lock timer.

  4. Tier Ordering Invariant: The setTier() admin function enforces that tiers maintain ascending order of both duration and multiplier, preventing misconfigurations.

  5. Reentrancy Protection: All state-changing functions use the nonReentrant modifier.

  6. Emergency Pause: The owner can pause the contract in emergencies. Users cannot stake, unstake, claim, compound, or upgrade while paused.


V1 Backward Compatibility

PRIVStaking (V1) remains deployed for backward compatibility. Both V1 and V2 implement the IPRIVStaking interface, which defines fundRewardsPool(uint256). FeeManagerV2 can be configured to route the staker share of fees to either contract.

FeatureV1 (PRIVStaking)V2 (PRIVStakingV2)
Lock modelSingle configurable duration5 lock tiers
MultipliersNone1.0x to 8.0x
Reward distributionProportional to raw amountProportional to effective stake
Auto-compoundNot available15% bonus
Tier upgradesN/ASupported
Partial unstakeSupportedFull unstake only

Source Code

View on GitHub