Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Anchor.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ test = "bun run ts-mocha -p ./tsconfig.json -r tsconfig-paths/register -t 100000
# test = "bun run ts-mocha -p ./tsconfig.json -r tsconfig-paths/register -t 1000000 tests/**/inference-staking.test.ts"
# test = "bun run ts-mocha -p ./tsconfig.json -r tsconfig-paths/register -t 1000000 tests/**/rewards.test.ts"
# test = "bun run ts-mocha -p ./tsconfig.json -r tsconfig-paths/register -t 1000000 tests/**/constraints.test.ts"
# test = "bun run ts-mocha -p ./tsconfig.json -r tsconfig-paths/register -t 1000000 tests/**/usdc-only-mode.test.ts"
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ At [Inference.net](https://inference.net/?utm_source=github&utm_medium=readme&ut

# Inference.net Staking Program

An on-chain Solana program that manages staking and unstaking of tokens to operator managed pools, custodies delegated tokens, distributes rewards and USDC earnings, and ensures Inference.net network security via halting and slashing mechanisms.
A general-purpose proof-of-stake system implemented on Solana for coordinating off-chain services with a fully on-chain accounting and reward distribution system.

The protocol supports staking and unstaking of tokens to operator managed pools, token delegation, rewards/revenue distribution, and security via halting and slashing mechanisms.

View staking program documentation [here](https://docs.devnet.inference.net/devnet-epoch-3/staking-protocol).

Expand All @@ -27,6 +29,7 @@ The Inference.net Staking System allows users to stake tokens to operator-manage
- Efficient reward distribution with off-chain storage and on-chain merkle tree proof verification
- On-chain encoded reward emission schedule for transparency and auditability
- Program events for fine-grained monitoring and auditing
- USDC-only mode for token-less revenue distribution to a group of operators

## Architecture

Expand Down
82 changes: 72 additions & 10 deletions programs/inference-staking/src/emissions.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use anchor_lang::prelude::*;

use crate::error::ErrorCode;
use crate::state::PoolOverview;

/// Number of epochs per super epoch
/// 5 super epochs at 300 days = 1500 days = 4.1 years
Expand All @@ -18,9 +19,17 @@ pub const TOKEN_REWARDS_EMISSIONS_SCHEDULE_BY_SUPER_EPOCH: &[u64] = &[
];

/// Calculate the expected reward emissions for a given epoch based on the emission schedule.
pub fn get_expected_reward_emissions_for_epoch(epoch: u64) -> Result<u64> {
pub fn get_expected_reward_emissions_for_epoch(
epoch: u64,
pool_overview: &PoolOverview,
) -> Result<u64> {
require!(epoch >= 1, ErrorCode::InvalidEpoch);

// If token rewards are disabled, return zero emissions
if !pool_overview.token_rewards_enabled {
return Ok(0);
}

let emissions_schedule = TOKEN_REWARDS_EMISSIONS_SCHEDULE_BY_SUPER_EPOCH;

// Calculate which super epoch this epoch belongs to (0-indexed)
Expand Down Expand Up @@ -55,8 +64,40 @@ pub fn get_expected_reward_emissions_for_epoch(epoch: u64) -> Result<u64> {
mod tests {
use super::*;

fn create_mock_pool_overview(token_rewards_enabled: bool) -> PoolOverview {
PoolOverview {
mint: Pubkey::default(),
bump: 0,
program_admin: Pubkey::default(),
reward_distribution_authorities: vec![],
halt_authorities: vec![],
slashing_authorities: vec![],
slashing_destination_usdc_account: Pubkey::default(),
slashing_destination_token_account: Pubkey::default(),
slashing_delay_seconds: 0,
is_epoch_finalizing: false,
is_token_mint_usdc: false,
token_rewards_enabled,
is_staking_halted: false,
is_withdrawal_halted: false,
is_accrue_reward_halted: false,
allow_pool_creation: false,
operator_pool_registration_fee: 0,
registration_fee_payout_wallet: Pubkey::default(),
min_operator_token_stake: 0,
delegator_unstake_delay_seconds: 0,
operator_unstake_delay_seconds: 0,
total_pools: 0,
completed_reward_epoch: 0,
unclaimed_rewards: 0,
unclaimed_usdc: 0,
}
}

#[test]
fn test_get_expected_reward_emissions_for_epoch() {
let pool_overview = create_mock_pool_overview(true);

// Test epoch 1 (first epoch of first super epoch) - may get dust
let first_super_epoch_total = TOKEN_REWARDS_EMISSIONS_SCHEDULE_BY_SUPER_EPOCH[0];
let base_reward = first_super_epoch_total / EPOCHS_PER_SUPER_EPOCH;
Expand All @@ -67,12 +108,12 @@ mod tests {
base_reward
};

let result = get_expected_reward_emissions_for_epoch(1).unwrap();
let result = get_expected_reward_emissions_for_epoch(1, &pool_overview).unwrap();
assert_eq!(result, first_epoch_reward);

// Test last epoch of first super epoch - gets base reward (no dust)
let last_epoch_first_super = EPOCHS_PER_SUPER_EPOCH;
let result = get_expected_reward_emissions_for_epoch(last_epoch_first_super).unwrap();
let result = get_expected_reward_emissions_for_epoch(last_epoch_first_super, &pool_overview).unwrap();
assert_eq!(result, base_reward);

// Test first epoch of second super epoch (if it exists)
Expand All @@ -87,35 +128,51 @@ mod tests {
};

let first_epoch_second_super = EPOCHS_PER_SUPER_EPOCH + 1;
let result = get_expected_reward_emissions_for_epoch(first_epoch_second_super).unwrap();
let result = get_expected_reward_emissions_for_epoch(first_epoch_second_super, &pool_overview).unwrap();
assert_eq!(result, second_first_epoch_reward);

// Test last epoch of second super epoch
let last_epoch_second_super = EPOCHS_PER_SUPER_EPOCH * 2;
let result = get_expected_reward_emissions_for_epoch(last_epoch_second_super).unwrap();
let result = get_expected_reward_emissions_for_epoch(last_epoch_second_super, &pool_overview).unwrap();
assert_eq!(result, second_base_reward);
}

// Test epoch beyond defined schedule (should return 0)
let beyond_schedule_epoch = (TOKEN_REWARDS_EMISSIONS_SCHEDULE_BY_SUPER_EPOCH.len() as u64
* EPOCHS_PER_SUPER_EPOCH)
+ 1;
let result = get_expected_reward_emissions_for_epoch(beyond_schedule_epoch).unwrap();
let result = get_expected_reward_emissions_for_epoch(beyond_schedule_epoch, &pool_overview).unwrap();
assert_eq!(result, 0);
}

#[test]
fn test_get_expected_reward_emissions_when_disabled() {
let pool_overview = create_mock_pool_overview(false);

// When token rewards are disabled, all epochs should return 0
let result = get_expected_reward_emissions_for_epoch(1, &pool_overview).unwrap();
assert_eq!(result, 0);

let result = get_expected_reward_emissions_for_epoch(100, &pool_overview).unwrap();
assert_eq!(result, 0);

let result = get_expected_reward_emissions_for_epoch(1000, &pool_overview).unwrap();
assert_eq!(result, 0);
}

#[test]
fn test_epoch_dust_distribution() {
// For super epoch rewards that don't divide evenly by EPOCHS_PER_SUPER_EPOCH,
// the dust should be distributed to earlier epochs
let pool_overview = create_mock_pool_overview(true);

let first_super_epoch_total = TOKEN_REWARDS_EMISSIONS_SCHEDULE_BY_SUPER_EPOCH[0];
let base_reward = first_super_epoch_total / EPOCHS_PER_SUPER_EPOCH;
let dust = first_super_epoch_total % EPOCHS_PER_SUPER_EPOCH;

// Test reward distribution for all epochs in first super epoch
for epoch in 1..=EPOCHS_PER_SUPER_EPOCH {
let result = get_expected_reward_emissions_for_epoch(epoch).unwrap();
let result = get_expected_reward_emissions_for_epoch(epoch, &pool_overview).unwrap();

// Earlier epochs get 1 extra token unit if there's dust
let expected_reward = if (epoch - 1) < dust {
Expand All @@ -131,14 +188,17 @@ mod tests {
#[test]
fn test_invalid_epoch() {
// Test epoch 0 (invalid)
let result = get_expected_reward_emissions_for_epoch(0);
let pool_overview = create_mock_pool_overview(true);
let result = get_expected_reward_emissions_for_epoch(0, &pool_overview);
assert!(result.is_err());
}

#[test]
fn test_all_super_epochs() {
// Test first epoch of each super epoch matches expected schedule
// First epoch gets base reward + 1 if there's dust, otherwise just base reward
let pool_overview = create_mock_pool_overview(true);

for (super_epoch_index, &total) in TOKEN_REWARDS_EMISSIONS_SCHEDULE_BY_SUPER_EPOCH
.iter()
.enumerate()
Expand All @@ -152,7 +212,7 @@ mod tests {
};

let epoch = (super_epoch_index as u64 * EPOCHS_PER_SUPER_EPOCH) + 1;
let result = get_expected_reward_emissions_for_epoch(epoch).unwrap();
let result = get_expected_reward_emissions_for_epoch(epoch, &pool_overview).unwrap();
assert_eq!(
result,
expected_first_epoch_reward,
Expand All @@ -166,6 +226,8 @@ mod tests {
fn test_total_emissions_per_super_epoch() {
// Verify that the sum of all epochs in a super epoch equals the expected total
// Test all defined super epochs from the emissions schedule
let pool_overview = create_mock_pool_overview(true);

for (super_epoch_index, &expected_total) in TOKEN_REWARDS_EMISSIONS_SCHEDULE_BY_SUPER_EPOCH
.iter()
.enumerate()
Expand All @@ -175,7 +237,7 @@ mod tests {
let end_epoch = start_epoch + EPOCHS_PER_SUPER_EPOCH - 1;

for epoch in start_epoch..=end_epoch {
let reward = get_expected_reward_emissions_for_epoch(epoch).unwrap();
let reward = get_expected_reward_emissions_for_epoch(epoch, &pool_overview).unwrap();
total += reward;
}

Expand Down
8 changes: 8 additions & 0 deletions programs/inference-staking/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,4 +102,12 @@ pub enum ErrorCode {
InvalidAmount,
#[msg("Invalid shares amount provided - cannot be greater than total operator shares")]
InvalidSlashSharesAmount,
#[msg("Token rewards are disabled for this protocol deployment")]
TokenRewardsDisabled,
#[msg("Delegator staking is not allowed when token rewards are disabled")]
DelegatorStakingDisabled,
#[msg("Invalid mint for USDC-only mode - mint must match USDC")]
InvalidMintForUsdcMode,
#[msg("Invalid commission rate - must be 100% when token rewards are disabled")]
InvalidCommissionRateForDisabledRewards,
}
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,28 @@ pub fn handler(ctx: Context<CreateOperatorPool>, args: CreateOperatorPoolArgs) -

let pool_overview = &mut ctx.accounts.pool_overview;

// If USDC mint mode is enabled, enforce that the mint matches USDC
if pool_overview.is_token_mint_usdc {
require!(
ctx.accounts.mint.key() == ctx.accounts.usdc_mint.key(),
ErrorCode::InvalidMintForUsdcMode
);
}

// If token rewards are disabled, enforce both commission rates are 100%
if !pool_overview.token_rewards_enabled {
require_eq!(
reward_commission_rate_bps,
10000,
ErrorCode::InvalidCommissionRateForDisabledRewards
);
require_eq!(
usdc_commission_rate_bps,
10000,
ErrorCode::InvalidCommissionRateForDisabledRewards
);
}

// Transfer registration fee if it's set above zero.
let registration_fee = pool_overview.operator_pool_registration_fee;
if registration_fee > 0 {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use anchor_lang::prelude::*;
use anchor_lang::solana_program::sysvar::instructions::load_current_index_checked;

use crate::error::ErrorCode;
use crate::events::UpdateOperatorPoolEvent;
use crate::state::{OperatorPool, PoolOverview};

Expand Down Expand Up @@ -88,16 +89,36 @@ pub fn handler(ctx: Context<UpdateOperatorPool>, args: UpdateOperatorPoolArgs) -
operator_pool.auto_stake_fees = auto_stake_fees;
}

let pool_overview = &ctx.accounts.pool_overview;

if let Some(new_reward_rate_setting) = new_reward_commission_rate_bps {
if let Some(new_commission_rate_bps) = new_reward_rate_setting.rate_bps {
OperatorPool::validate_commission_rate(new_commission_rate_bps)?;

// If token rewards are disabled, enforce commission rate is 100%
if !pool_overview.token_rewards_enabled {
require_eq!(
new_commission_rate_bps,
10000,
ErrorCode::InvalidCommissionRateForDisabledRewards
);
}
}
operator_pool.new_reward_commission_rate_bps = new_reward_rate_setting.rate_bps;
}

if let Some(new_usdc_rate_setting) = new_usdc_commission_rate_bps {
if let Some(new_usdc_rate_bps) = new_usdc_rate_setting.rate_bps {
OperatorPool::validate_commission_rate(new_usdc_rate_bps)?;

// If token rewards are disabled, enforce commission rate is 100%
if !pool_overview.token_rewards_enabled {
require_eq!(
new_usdc_rate_bps,
10000,
ErrorCode::InvalidCommissionRateForDisabledRewards
);
}
}
operator_pool.new_usdc_commission_rate_bps = new_usdc_rate_setting.rate_bps;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,30 @@ pub struct CreatePoolOverview<'info> {
pub system_program: Program<'info, System>,
}

#[derive(AnchorSerialize, AnchorDeserialize)]
pub struct CreatePoolOverviewArgs {
pub is_token_mint_usdc: bool,
pub token_rewards_enabled: bool,
}

/// Instruction to setup a PoolOverview singleton. To be called after initial program deployment.
pub fn handler(ctx: Context<CreatePoolOverview>) -> Result<()> {
pub fn handler(ctx: Context<CreatePoolOverview>, args: CreatePoolOverviewArgs) -> Result<()> {
let pool_overview = &mut ctx.accounts.pool_overview;

// Set defaults: is_token_mint_usdc = false, token_rewards_enabled = true
let CreatePoolOverviewArgs {
is_token_mint_usdc,
token_rewards_enabled,
} = args;

// If USDC mint mode is enabled, enforce that the native token mint == USDC mint
if is_token_mint_usdc {
require!(
ctx.accounts.mint.key() == ctx.accounts.usdc_mint.key(),
ErrorCode::InvalidMintForUsdcMode
);
}

pool_overview.bump = ctx.bumps.pool_overview;
pool_overview.mint = ctx.accounts.mint.key();
pool_overview.program_admin = ctx.accounts.program_admin.key();
Expand All @@ -78,6 +98,8 @@ pub fn handler(ctx: Context<CreatePoolOverview>) -> Result<()> {
pool_overview.slashing_destination_usdc_account =
ctx.accounts.slashing_destination_usdc_account.key();
pool_overview.slashing_delay_seconds = MIN_SLASHING_DELAY_SECONDS;
pool_overview.is_token_mint_usdc = is_token_mint_usdc;
pool_overview.token_rewards_enabled = token_rewards_enabled;

Ok(())
}
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,15 @@ pub fn handler(ctx: Context<AccrueReward>, args: AccrueRewardArgs) -> Result<()>
usdc_amount,
} = args;

let pool_overview = &ctx.accounts.pool_overview;
let reward_record = &ctx.accounts.reward_record;
let operator_pool = &mut ctx.accounts.operator_pool;

// If token rewards are disabled, enforce that reward_amount is zero
if !pool_overview.token_rewards_enabled {
require_eq!(reward_amount, 0, ErrorCode::TokenRewardsDisabled);
}

reward_record.verify_proof(
merkle_index,
operator_pool.key(),
Expand All @@ -124,7 +131,6 @@ pub fn handler(ctx: Context<AccrueReward>, args: AccrueRewardArgs) -> Result<()>
usdc_amount,
)?;

let pool_overview = &ctx.accounts.pool_overview;
let operator_staking_record: &mut Box<Account<'_, StakingRecord>> =
&mut ctx.accounts.operator_staking_record;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,18 @@ pub fn handler(ctx: Context<CreateRewardRecord>, args: CreateRewardRecordArgs) -

let epoch = pool_overview.completed_reward_epoch.checked_add(1).unwrap();

// If token rewards are disabled, enforce that total_rewards is zero
if !pool_overview.token_rewards_enabled {
require_eq!(total_rewards, 0, ErrorCode::TokenRewardsDisabled);
}

// If no merkle roots are provided then reward amounts must be zero.
if merkle_roots.is_empty() {
require_eq!(total_rewards, 0);
require_eq!(total_usdc_payout, 0);
} else {
// If merkle roots are provided, verify that total_rewards matches expected emissions
let expected_rewards = get_expected_reward_emissions_for_epoch(epoch)?;
let expected_rewards = get_expected_reward_emissions_for_epoch(epoch, pool_overview)?;
require_eq!(
total_rewards,
expected_rewards,
Expand Down
Loading