diff --git a/Anchor.toml b/Anchor.toml index 2939a53..3c7cf24 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -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" diff --git a/README.md b/README.md index 749d0de..7544603 100644 --- a/README.md +++ b/README.md @@ -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). @@ -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 diff --git a/programs/inference-staking/src/emissions.rs b/programs/inference-staking/src/emissions.rs index e8c3b3e..ee146d2 100644 --- a/programs/inference-staking/src/emissions.rs +++ b/programs/inference-staking/src/emissions.rs @@ -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 @@ -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 { +pub fn get_expected_reward_emissions_for_epoch( + epoch: u64, + pool_overview: &PoolOverview, +) -> Result { 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) @@ -55,8 +64,40 @@ pub fn get_expected_reward_emissions_for_epoch(epoch: u64) -> Result { 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; @@ -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) @@ -87,12 +128,12 @@ 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); } @@ -100,7 +141,22 @@ mod tests { 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); } @@ -108,6 +164,7 @@ mod tests { 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; @@ -115,7 +172,7 @@ mod tests { // 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 { @@ -131,7 +188,8 @@ 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()); } @@ -139,6 +197,8 @@ mod tests { 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() @@ -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, @@ -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() @@ -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; } diff --git a/programs/inference-staking/src/error.rs b/programs/inference-staking/src/error.rs index bff5ad6..8fceab8 100644 --- a/programs/inference-staking/src/error.rs +++ b/programs/inference-staking/src/error.rs @@ -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, } diff --git a/programs/inference-staking/src/instructions/operator_pool/create_operator_pool.rs b/programs/inference-staking/src/instructions/operator_pool/create_operator_pool.rs index 83361c1..7ea4be0 100644 --- a/programs/inference-staking/src/instructions/operator_pool/create_operator_pool.rs +++ b/programs/inference-staking/src/instructions/operator_pool/create_operator_pool.rs @@ -149,6 +149,28 @@ pub fn handler(ctx: Context, 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 { diff --git a/programs/inference-staking/src/instructions/operator_pool/update_operator_pool.rs b/programs/inference-staking/src/instructions/operator_pool/update_operator_pool.rs index 95f487b..c016dbe 100644 --- a/programs/inference-staking/src/instructions/operator_pool/update_operator_pool.rs +++ b/programs/inference-staking/src/instructions/operator_pool/update_operator_pool.rs @@ -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}; @@ -88,9 +89,20 @@ pub fn handler(ctx: Context, 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; } @@ -98,6 +110,15 @@ pub fn handler(ctx: Context, args: UpdateOperatorPoolArgs) - 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; } diff --git a/programs/inference-staking/src/instructions/pool_overview/create_pool_overview.rs b/programs/inference-staking/src/instructions/pool_overview/create_pool_overview.rs index a861cd4..84d8a9d 100644 --- a/programs/inference-staking/src/instructions/pool_overview/create_pool_overview.rs +++ b/programs/inference-staking/src/instructions/pool_overview/create_pool_overview.rs @@ -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) -> Result<()> { +pub fn handler(ctx: Context, 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(); @@ -78,6 +98,8 @@ pub fn handler(ctx: Context) -> 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(()) } diff --git a/programs/inference-staking/src/instructions/reward_record/accrue_reward.rs b/programs/inference-staking/src/instructions/reward_record/accrue_reward.rs index 578dc87..49de6d0 100644 --- a/programs/inference-staking/src/instructions/reward_record/accrue_reward.rs +++ b/programs/inference-staking/src/instructions/reward_record/accrue_reward.rs @@ -113,8 +113,15 @@ pub fn handler(ctx: Context, 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(), @@ -124,7 +131,6 @@ pub fn handler(ctx: Context, args: AccrueRewardArgs) -> Result<()> usdc_amount, )?; - let pool_overview = &ctx.accounts.pool_overview; let operator_staking_record: &mut Box> = &mut ctx.accounts.operator_staking_record; diff --git a/programs/inference-staking/src/instructions/reward_record/create_reward_record.rs b/programs/inference-staking/src/instructions/reward_record/create_reward_record.rs index 8d40899..49d486c 100644 --- a/programs/inference-staking/src/instructions/reward_record/create_reward_record.rs +++ b/programs/inference-staking/src/instructions/reward_record/create_reward_record.rs @@ -69,13 +69,18 @@ pub fn handler(ctx: Context, 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, diff --git a/programs/inference-staking/src/instructions/staking_record/create_staking_record.rs b/programs/inference-staking/src/instructions/staking_record/create_staking_record.rs index 50a2ecf..06bea8e 100644 --- a/programs/inference-staking/src/instructions/staking_record/create_staking_record.rs +++ b/programs/inference-staking/src/instructions/staking_record/create_staking_record.rs @@ -1,6 +1,7 @@ use anchor_lang::prelude::*; -use crate::state::{OperatorPool, StakingRecord}; +use crate::error::ErrorCode; +use crate::state::{OperatorPool, PoolOverview, StakingRecord}; #[derive(Accounts)] pub struct CreateStakingRecord<'info> { @@ -9,6 +10,12 @@ pub struct CreateStakingRecord<'info> { pub owner: Signer<'info>, + #[account( + seeds = [PoolOverview::SEED], + bump = pool_overview.bump, + )] + pub pool_overview: Box>, + #[account( seeds = [OperatorPool::SEED, operator_pool.initial_pool_admin.as_ref()], bump, @@ -33,12 +40,23 @@ pub struct CreateStakingRecord<'info> { /// Instruction to setup a StakingRecord. pub fn handler(ctx: Context) -> Result<()> { + let pool_overview = &ctx.accounts.pool_overview; + let operator_pool = &ctx.accounts.operator_pool; + let owner = ctx.accounts.owner.key(); + + // Check if this is the operator's staking record + let is_operator = owner == operator_pool.admin; + + // If token rewards are disabled, only operators can create staking records + if !pool_overview.token_rewards_enabled && !is_operator { + return Err(ErrorCode::DelegatorStakingDisabled.into()); + } + let staking_record = &mut ctx.accounts.owner_staking_record; staking_record.version = StakingRecord::VERSION; - staking_record.owner = ctx.accounts.owner.key(); - staking_record.operator_pool = ctx.accounts.operator_pool.key(); - staking_record.last_settled_usdc_per_share = - ctx.accounts.operator_pool.cumulative_usdc_per_share; + staking_record.owner = owner; + staking_record.operator_pool = operator_pool.key(); + staking_record.last_settled_usdc_per_share = operator_pool.cumulative_usdc_per_share; staking_record.accrued_usdc_earnings = 0; Ok(()) diff --git a/programs/inference-staking/src/instructions/staking_record/stake.rs b/programs/inference-staking/src/instructions/staking_record/stake.rs index f71459a..aa48695 100644 --- a/programs/inference-staking/src/instructions/staking_record/stake.rs +++ b/programs/inference-staking/src/instructions/staking_record/stake.rs @@ -87,6 +87,11 @@ pub fn handler(ctx: Context, args: StakeArgs) -> Result<()> { ErrorCode::StakingNotAllowed ); + // If token rewards are disabled, only operators can stake + if !pool_overview.token_rewards_enabled { + require!(is_operator_staking, ErrorCode::DelegatorStakingDisabled); + } + // Check that pool is not closed or halted. require!( operator_pool.closed_at_epoch.is_none(), diff --git a/programs/inference-staking/src/lib.rs b/programs/inference-staking/src/lib.rs index 58f9174..64a3add 100644 --- a/programs/inference-staking/src/lib.rs +++ b/programs/inference-staking/src/lib.rs @@ -36,8 +36,11 @@ pub mod inference_staking { /** ----------------------------------------------------------------------- * PoolOverview Admin Instructions * ------------------------------------------------------------------------ */ - pub fn create_pool_overview(ctx: Context) -> Result<()> { - create_pool_overview::handler(ctx) + pub fn create_pool_overview( + ctx: Context, + args: CreatePoolOverviewArgs, + ) -> Result<()> { + create_pool_overview::handler(ctx, args) } pub fn update_pool_overview( diff --git a/programs/inference-staking/src/state/pool_overview.rs b/programs/inference-staking/src/state/pool_overview.rs index 48c378c..a92dacb 100644 --- a/programs/inference-staking/src/state/pool_overview.rs +++ b/programs/inference-staking/src/state/pool_overview.rs @@ -36,6 +36,16 @@ pub struct PoolOverview { /// Whether the current epoch is in the finalizing state. pub is_epoch_finalizing: bool, + /// Set on account creation and then immutable. Defines if the token mint is USDC. If it is, + /// then the protocol is effectively running in "USDC-only" mode where it functions as a + /// USDC revenue distribution proof-of-stake system. + pub is_token_mint_usdc: bool, + + /// Set on account creation and then immutable. Defines if token rewards are enabled for the + /// protocol deployment. If not, token rewards must be zero, operator pool commission rates + /// must 100%, and delegator staking is not allowed. + pub token_rewards_enabled: bool, + /// Halts all staking instructions when true. Used as a security backstop. pub is_staking_halted: bool, diff --git a/scripts/anchor-configs/Anchor.dev.toml b/scripts/anchor-configs/Anchor.dev.toml index 1887fd3..fab3504 100644 --- a/scripts/anchor-configs/Anchor.dev.toml +++ b/scripts/anchor-configs/Anchor.dev.toml @@ -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" diff --git a/scripts/anchor-configs/Anchor.local.toml b/scripts/anchor-configs/Anchor.local.toml index 2939a53..3c7cf24 100644 --- a/scripts/anchor-configs/Anchor.local.toml +++ b/scripts/anchor-configs/Anchor.local.toml @@ -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" diff --git a/scripts/test-all.sh b/scripts/test-all.sh index 526844d..ce68554 100755 --- a/scripts/test-all.sh +++ b/scripts/test-all.sh @@ -11,12 +11,14 @@ PATTERN1="^test = \"bun run ts-mocha -p ./tsconfig.json -r tsconfig-paths/regist PATTERN2="^# test = \"bun run ts-mocha -p ./tsconfig.json -r tsconfig-paths/register -t 1000000 tests/\*\*/inference-staking.test.ts\"$" PATTERN3="^# test = \"bun run ts-mocha -p ./tsconfig.json -r tsconfig-paths/register -t 1000000 tests/\*\*/rewards.test.ts\"$" PATTERN4="^# test = \"bun run ts-mocha -p ./tsconfig.json -r tsconfig-paths/register -t 1000000 tests/\*\*/constraints.test.ts\"$" +PATTERN5="^# test = \"bun run ts-mocha -p ./tsconfig.json -r tsconfig-paths/register -t 1000000 tests/\*\*/usdc-only-mode.test.ts\"$" # Check if the file is in the expected state if ! grep -q "$PATTERN1" "$FILE_PATH" || \ ! grep -q "$PATTERN2" "$FILE_PATH" || \ ! grep -q "$PATTERN3" "$FILE_PATH" || \ - ! grep -q "$PATTERN4" "$FILE_PATH"; then + ! grep -q "$PATTERN4" "$FILE_PATH" || \ + ! grep -q "$PATTERN5" "$FILE_PATH"; then echo "❌ Error: Anchor.toml is not in the expected initial state." echo "Please ensure the file has the following test configuration:" echo "" @@ -24,6 +26,7 @@ if ! grep -q "$PATTERN1" "$FILE_PATH" || \ echo "# test = \"bun run ts-mocha -p ./tsconfig.json -r tsconfig-paths/register -t 1000000 tests/**/inference-staking.test.ts\"" echo "# test = \"bun run ts-mocha -p ./tsconfig.json -r tsconfig-paths/register -t 1000000 tests/**/rewards.test.ts\"" echo "# test = \"bun run ts-mocha -p ./tsconfig.json -r tsconfig-paths/register -t 1000000 tests/**/constraints.test.ts\"" + echo "# test = \"bun run ts-mocha -p ./tsconfig.json -r tsconfig-paths/register -t 1000000 tests/**/usdc-only-mode.test.ts\"" echo "" exit 1 fi @@ -73,6 +76,17 @@ sed -i'.bak' \ bun run test +echo "" +echo "Running USDC-only mode tests..." +echo "" + +sed -i'.bak' \ + -e 's/^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\/\*\*\/constraints.test.ts"/' \ + -e 's/^# test = "bun run ts-mocha -p .\/tsconfig.json -r tsconfig-paths\/register -t 1000000 tests\/\*\*\/usdc-only-mode.test.ts"$/test = "bun run ts-mocha -p .\/tsconfig.json -r tsconfig-paths\/register -t 1000000 tests\/\*\*\/usdc-only-mode.test.ts"/' \ + $FILE_PATH + +bun run test + # Restore the original content echo "$ORIGINAL_CONTENT" > $FILE_PATH diff --git a/sdk/src/idl.ts b/sdk/src/idl.ts index 69a6245..640e306 100644 --- a/sdk/src/idl.ts +++ b/sdk/src/idl.ts @@ -1002,7 +1002,16 @@ const _IDL = { address: "11111111111111111111111111111111", }, ], - args: [], + args: [ + { + name: "args", + type: { + defined: { + name: "createPoolOverviewArgs", + }, + }, + }, + ], }, { name: "createRewardRecord", @@ -1098,6 +1107,19 @@ const _IDL = { name: "owner", signer: true, }, + { + name: "poolOverview", + pda: { + seeds: [ + { + kind: "const", + value: [ + 80, 111, 111, 108, 79, 118, 101, 114, 118, 105, 101, 119, + ], + }, + ], + }, + }, { name: "operatorPool", pda: { @@ -2281,6 +2303,26 @@ const _IDL = { name: "invalidSlashSharesAmount", msg: "Invalid shares amount provided - cannot be greater than total operator shares", }, + { + code: 6050, + name: "tokenRewardsDisabled", + msg: "Token rewards are disabled for this protocol deployment", + }, + { + code: 6051, + name: "delegatorStakingDisabled", + msg: "Delegator staking is not allowed when token rewards are disabled", + }, + { + code: 6052, + name: "invalidMintForUsdcMode", + msg: "Invalid mint for USDC-only mode - mint must match USDC", + }, + { + code: 6053, + name: "invalidCommissionRateForDisabledRewards", + msg: "Invalid commission rate - must be 100% when token rewards are disabled", + }, ], types: [ { @@ -2591,6 +2633,22 @@ const _IDL = { ], }, }, + { + name: "createPoolOverviewArgs", + type: { + kind: "struct", + fields: [ + { + name: "isTokenMintUsdc", + type: "bool", + }, + { + name: "tokenRewardsEnabled", + type: "bool", + }, + ], + }, + }, { name: "createRewardRecordArgs", type: { @@ -2950,6 +3008,24 @@ const _IDL = { docs: ["Whether the current epoch is in the finalizing state."], type: "bool", }, + { + name: "isTokenMintUsdc", + docs: [ + "Set on account creation and then immutable. Defines if the token mint is USDC. If it is,", + 'then the protocol is effectively running in "USDC-only" mode where it functions as a', + "USDC revenue distribution proof-of-stake system.", + ], + type: "bool", + }, + { + name: "tokenRewardsEnabled", + docs: [ + "Set on account creation and then immutable. Defines if token rewards are enabled for the", + "protocol deployment. If not, token rewards must be zero, operator pool commission rates", + "must 100%, and delegator staking is not allowed.", + ], + type: "bool", + }, { name: "isStakingHalted", docs: [ diff --git a/tests/constraints.test.ts b/tests/constraints.test.ts index 4776ca7..47f901b 100644 --- a/tests/constraints.test.ts +++ b/tests/constraints.test.ts @@ -33,7 +33,10 @@ describe("Additional tests for instruction constraints", () => { it("Fail to create PoolOverview with an invalid USDC mint", async () => { try { await program.methods - .createPoolOverview() + .createPoolOverview({ + isTokenMintUsdc: false, + tokenRewardsEnabled: true, + }) .accountsStrict({ payer: setup.payer, programAdmin: setup.signer, @@ -58,7 +61,10 @@ describe("Additional tests for instruction constraints", () => { it("Create PoolOverview and update with a valid admin", async () => { await program.methods - .createPoolOverview() + .createPoolOverview({ + isTokenMintUsdc: false, + tokenRewardsEnabled: true, + }) .accountsStrict({ payer: setup.payer, programAdmin: setup.poolOverviewAdmin, diff --git a/tests/inference-staking.test.ts b/tests/inference-staking.test.ts index d0055b8..3468c87 100644 --- a/tests/inference-staking.test.ts +++ b/tests/inference-staking.test.ts @@ -54,7 +54,10 @@ describe("inference-staking program tests", () => { it("Create PoolOverview successfully", async () => { await program.methods - .createPoolOverview() + .createPoolOverview({ + isTokenMintUsdc: false, + tokenRewardsEnabled: true, + }) .accountsStrict({ payer: setup.payer, programAdmin: setup.poolOverviewAdmin, @@ -886,6 +889,7 @@ describe("inference-staking program tests", () => { operatorPool: setup.pool1.pool, ownerStakingRecord: setup.pool1.delegatorStakingRecord, systemProgram: SystemProgram.programId, + poolOverview: setup.poolOverview, }) .signers([setup.payerKp, setup.delegator1Kp]) .rpc(); @@ -3208,6 +3212,7 @@ describe("inference-staking program tests", () => { operatorPool: setup.pool1.pool, ownerStakingRecord: delegator2StakingRecord, systemProgram: SystemProgram.programId, + poolOverview: setup.poolOverview, }) .signers([setup.payerKp, setup.delegator2Kp]) .rpc(); diff --git a/tests/lib/setup.ts b/tests/lib/setup.ts index 2b90b84..73db655 100644 --- a/tests/lib/setup.ts +++ b/tests/lib/setup.ts @@ -373,6 +373,14 @@ export async function setupTests() { registrationFeePayoutWalletKp.publicKey ); + const registrationFeePayoutUsdcAccount = + await getOrCreateAssociatedTokenAccount( + provider.connection, + payerKp, + usdcTokenMint, + registrationFeePayoutWalletKp.publicKey + ); + debug(`- Test setup complete\n`); return { @@ -385,6 +393,7 @@ export async function setupTests() { registrationFeePayoutWallet: registrationFeePayoutWalletKp.publicKey, registrationFeePayoutTokenAccount: registrationFeePayoutTokenAccount.address, + registrationFeePayoutUsdcAccount: registrationFeePayoutUsdcAccount.address, rewardDistributionAuthorityKp, rewardDistributionAuthority: rewardDistributionAuthorityKp.publicKey, haltingAuthorityKp, diff --git a/tests/multi-epochs.test.ts b/tests/multi-epochs.test.ts index 99e5bd0..0255646 100644 --- a/tests/multi-epochs.test.ts +++ b/tests/multi-epochs.test.ts @@ -732,6 +732,7 @@ describe("multi-epoch lifecycle tests", () => { operatorPool: pool.pool, ownerStakingRecord: stakingRecord, systemProgram: SystemProgram.programId, + poolOverview: setup.poolOverview, }) .signers([setup.payerKp, delegatorKp]) .rpc(); @@ -822,7 +823,10 @@ describe("multi-epoch lifecycle tests", () => { it("Create PoolOverview successfully", async () => { await program.methods - .createPoolOverview() + .createPoolOverview({ + isTokenMintUsdc: false, + tokenRewardsEnabled: true, + }) .accountsStrict({ payer: setup.payer, programAdmin: setup.poolOverviewAdmin, diff --git a/tests/rewards.test.ts b/tests/rewards.test.ts index f0ddcb6..8510d41 100644 --- a/tests/rewards.test.ts +++ b/tests/rewards.test.ts @@ -60,7 +60,10 @@ describe("Reward creation and accrual tests", () => { connection = program.provider.connection; await program.methods - .createPoolOverview() + .createPoolOverview({ + isTokenMintUsdc: false, + tokenRewardsEnabled: true, + }) .accountsStrict({ payer: setup.payer, programAdmin: setup.poolOverviewAdmin, @@ -292,6 +295,7 @@ describe("Reward creation and accrual tests", () => { operatorPool: setup.pool1.pool, ownerStakingRecord: setup.pool1.delegatorStakingRecord, systemProgram: SystemProgram.programId, + poolOverview: setup.poolOverview, }) .signers([setup.payerKp, setup.delegator1Kp]) .rpc(); diff --git a/tests/usdc-only-mode.test.ts b/tests/usdc-only-mode.test.ts new file mode 100644 index 0000000..a163036 --- /dev/null +++ b/tests/usdc-only-mode.test.ts @@ -0,0 +1,652 @@ +import * as anchor from "@coral-xyz/anchor"; +import type { Program } from "@coral-xyz/anchor"; +import { + getAssociatedTokenAddressSync, + mintTo, + TOKEN_PROGRAM_ID, +} from "@solana/spl-token"; +import type { Connection } from "@solana/web3.js"; +import { SYSVAR_INSTRUCTIONS_PUBKEY } from "@solana/web3.js"; +import { SystemProgram } from "@solana/web3.js"; +import { assert } from "chai"; + +import type { InferenceStaking } from "@sdk/src/idl"; + +import type { GenerateMerkleProofInput } from "@tests/lib/merkle"; +import { MerkleUtils } from "@tests/lib/merkle"; +import type { SetupTestResult } from "@tests/lib/setup"; +import { setupTests } from "@tests/lib/setup"; +import { + assertStakingProgramError, + sleep, + handleMarkEpochAsFinalizing, + generateRewardsForEpoch, +} from "@tests/lib/utils"; + +describe("USDC-only mode tests", () => { + let setup: SetupTestResult; + let connection: Connection; + let program: Program; + let epoch2Rewards: ReturnType; + + const delegatorUnstakeDelaySeconds = new anchor.BN(8); + const operatorUnstakeDelaySeconds = new anchor.BN(5); + const autoStakeFees = false; + const rewardCommissionRateBps = 10_000; // 100% for USDC-only mode + const usdcCommissionRateBps = 10_000; // 100% for USDC-only mode + const allowDelegation = true; + const allowPoolCreation = true; + const operatorPoolRegistrationFee = new anchor.BN(1_000); + const minOperatorTokenStake = new anchor.BN(1_000); + const isStakingHalted = false; + const isWithdrawalHalted = false; + const isAccrueRewardHalted = false; + const slashingDelaySeconds = new anchor.BN(3); + + before(async () => { + setup = await setupTests(); + program = setup.sdk.program; + connection = program.provider.connection; + }); + + it("Create PoolOverview in USDC-only mode successfully", async () => { + await program.methods + .createPoolOverview({ + isTokenMintUsdc: true, + tokenRewardsEnabled: false, + }) + .accountsStrict({ + payer: setup.payer, + programAdmin: setup.poolOverviewAdmin, + poolOverview: setup.poolOverview, + rewardTokenAccount: setup.rewardTokenAccount, + mint: setup.usdcTokenMint, // Using USDC mint as the main mint + tokenProgram: TOKEN_PROGRAM_ID, + usdcMint: setup.usdcTokenMint, + usdcTokenAccount: setup.usdcTokenAccount, + systemProgram: SystemProgram.programId, + registrationFeePayoutWallet: setup.registrationFeePayoutWallet, + slashingDestinationTokenAccount: setup.slashingDestinationTokenAccount, + slashingDestinationUsdcAccount: setup.slashingDestinationUsdcAccount, + }) + .signers([setup.payerKp, setup.poolOverviewAdminKp]) + .rpc(); + + const poolOverview = await program.account.poolOverview.fetch( + setup.poolOverview + ); + assert(poolOverview.programAdmin.equals(setup.poolOverviewAdmin)); + assert(poolOverview.mint.equals(setup.usdcTokenMint)); + assert.equal(poolOverview.isTokenMintUsdc, true); + assert.equal(poolOverview.tokenRewardsEnabled, false); + + // Check that all other values are set to default. + assert.isEmpty(poolOverview.haltAuthorities); + assert(!poolOverview.isWithdrawalHalted); + assert(!poolOverview.allowPoolCreation); + assert(poolOverview.minOperatorTokenStake.isZero()); + assert(poolOverview.delegatorUnstakeDelaySeconds.isZero()); + assert(poolOverview.operatorUnstakeDelaySeconds.isZero()); + assert(poolOverview.totalPools.isZero()); + assert(poolOverview.completedRewardEpoch.isZero()); + assert(poolOverview.unclaimedRewards.isZero()); + }); + + it("Update PoolOverview successfully", async () => { + await program.methods + .updatePoolOverview({ + isStakingHalted, + isWithdrawalHalted, + isAccrueRewardHalted, + allowPoolCreation, + minOperatorTokenStake, + delegatorUnstakeDelaySeconds, + operatorUnstakeDelaySeconds, + operatorPoolRegistrationFee, + slashingDelaySeconds, + }) + .accountsStrict({ + programAdmin: setup.poolOverviewAdmin, + poolOverview: setup.poolOverview, + registrationFeePayoutWallet: null, + slashingDestinationTokenAccount: null, + slashingDestinationUsdcAccount: null, + }) + .signers([setup.poolOverviewAdminKp]) + .rpc(); + + const poolOverview = await program.account.poolOverview.fetch( + setup.poolOverview + ); + + assert.equal(poolOverview.isWithdrawalHalted, isWithdrawalHalted); + assert.equal(poolOverview.allowPoolCreation, allowPoolCreation); + assert(poolOverview.minOperatorTokenStake.eq(minOperatorTokenStake)); + assert( + poolOverview.delegatorUnstakeDelaySeconds.eq(delegatorUnstakeDelaySeconds) + ); + assert( + poolOverview.operatorUnstakeDelaySeconds.eq(operatorUnstakeDelaySeconds) + ); + }); + + it("Update PoolOverview authorities successfully", async () => { + await program.methods + .updatePoolOverviewAuthorities({ + newRewardDistributionAuthorities: [ + setup.rewardDistributionAuthorityKp.publicKey, + ], + newHaltAuthorities: [setup.haltingAuthorityKp.publicKey], + newSlashingAuthorities: [setup.slashingAuthorityKp.publicKey], + }) + .accountsStrict({ + newProgramAdmin: null, + programAdmin: setup.poolOverviewAdmin, + poolOverview: setup.poolOverview, + }) + .signers([setup.poolOverviewAdminKp]) + .rpc(); + + const poolOverview = await program.account.poolOverview.fetch( + setup.poolOverview + ); + assert( + poolOverview.programAdmin.equals(setup.poolOverviewAdminKp.publicKey) + ); + assert(poolOverview.slashingAuthorities.length === 1); + assert( + poolOverview.slashingAuthorities[0]?.equals(setup.slashingAuthority) + ); + assert(poolOverview.haltAuthorities.length === 1); + assert(poolOverview.haltAuthorities[0]?.equals(setup.haltingAuthority)); + assert(poolOverview.rewardDistributionAuthorities.length === 1); + assert( + poolOverview.rewardDistributionAuthorities[0]?.equals( + setup.rewardDistributionAuthority + ) + ); + }); + + it("Create empty RewardRecord 1 successfully", async () => { + await handleMarkEpochAsFinalizing({ + setup, + program, + }); + + // Create an empty record with no rewards. + await program.methods + .createRewardRecord({ + merkleRoots: [], + totalRewards: new anchor.BN(0), + totalUsdcPayout: new anchor.BN(0), + }) + .accountsStrict({ + payer: setup.payer, + authority: setup.rewardDistributionAuthority, + poolOverview: setup.poolOverview, + rewardRecord: setup.rewardRecords[1], + rewardTokenAccount: setup.rewardTokenAccount, + usdcTokenAccount: setup.usdcTokenAccount, + systemProgram: SystemProgram.programId, + }) + .signers([setup.payerKp, setup.rewardDistributionAuthorityKp]) + .rpc(); + + const poolOverviewPost = await program.account.poolOverview.fetch( + setup.poolOverview + ); + assert(poolOverviewPost.isEpochFinalizing === false); + }); + + it("Create OperatorPool with 100% commission rates successfully", async () => { + // Need to create token account for admin since we're using USDC mint + const adminTokenAccount = getAssociatedTokenAddressSync( + setup.usdcTokenMint, + setup.pool1.admin + ); + + // Mint tokens to admin for registration fee + await mintTo( + connection, + setup.payerKp, + setup.usdcTokenMint, + adminTokenAccount, + setup.tokenHolderKp, + BigInt(100_000_000) // 100 USDC with 6 decimals + ); + + await program.methods + .createOperatorPool({ + autoStakeFees, + rewardCommissionRateBps, + usdcCommissionRateBps, + allowDelegation, + name: setup.pool1.name, + description: setup.pool1.description, + websiteUrl: setup.pool1.websiteUrl, + avatarImageUrl: setup.pool1.avatarImageUrl, + operatorAuthKeys: null, + }) + .accountsStrict({ + payer: setup.payer, + admin: setup.pool1.admin, + operatorPool: setup.pool1.pool, + stakingRecord: setup.pool1.stakingRecord, + stakedTokenAccount: setup.pool1.stakedTokenAccount, + rewardFeeTokenAccount: setup.pool1.rewardCommissionFeeTokenVault, + poolOverview: setup.poolOverview, + mint: setup.usdcTokenMint, // Using USDC mint + tokenProgram: TOKEN_PROGRAM_ID, + usdcFeeTokenAccount: setup.pool1.usdcCommissionFeeTokenVault, + systemProgram: SystemProgram.programId, + adminTokenAccount, + registrationFeePayoutTokenAccount: + setup.registrationFeePayoutUsdcAccount, // Use USDC version + operatorUsdcVault: setup.pool1.poolUsdcVault, + usdcMint: setup.usdcTokenMint, + }) + .signers([setup.payerKp, setup.pool1.adminKp]) + .rpc(); + + const operatorPool = await program.account.operatorPool.fetch( + setup.pool1.pool + ); + assert(operatorPool.admin.equals(setup.pool1.admin)); + assert(operatorPool.initialPoolAdmin.equals(setup.pool1.admin)); + assert.equal(operatorPool.rewardCommissionRateBps, rewardCommissionRateBps); + assert.equal(operatorPool.usdcCommissionRateBps, usdcCommissionRateBps); + assert.equal(operatorPool.allowDelegation, allowDelegation); + }); + + it("Fail to create OperatorPool with commission rates < 100%", async () => { + const adminTokenAccount = getAssociatedTokenAddressSync( + setup.usdcTokenMint, + setup.pool2.admin + ); + + // Mint tokens to admin for registration fee + await mintTo( + connection, + setup.payerKp, + setup.usdcTokenMint, + adminTokenAccount, + setup.tokenHolderKp, + BigInt(100_000_000) // 100 USDC with 6 decimals + ); + + try { + await program.methods + .createOperatorPool({ + autoStakeFees, + rewardCommissionRateBps: 5_000, // 50% - should fail + usdcCommissionRateBps: 5_000, // 50% - should fail + allowDelegation, + name: setup.pool2.name, + description: setup.pool2.description, + websiteUrl: setup.pool2.websiteUrl, + avatarImageUrl: setup.pool2.avatarImageUrl, + operatorAuthKeys: null, + }) + .accountsStrict({ + payer: setup.payer, + admin: setup.pool2.admin, + operatorPool: setup.pool2.pool, + stakingRecord: setup.pool2.stakingRecord, + stakedTokenAccount: setup.pool2.stakedTokenAccount, + rewardFeeTokenAccount: setup.pool2.rewardCommissionFeeTokenVault, + poolOverview: setup.poolOverview, + mint: setup.usdcTokenMint, + tokenProgram: TOKEN_PROGRAM_ID, + usdcFeeTokenAccount: setup.pool2.usdcCommissionFeeTokenVault, + systemProgram: SystemProgram.programId, + adminTokenAccount, + registrationFeePayoutTokenAccount: + setup.registrationFeePayoutUsdcAccount, // Use USDC version + operatorUsdcVault: setup.pool2.poolUsdcVault, + usdcMint: setup.usdcTokenMint, + }) + .signers([setup.payerKp, setup.pool2.adminKp]) + .rpc(); + assert(false); + } catch (error) { + assertStakingProgramError( + error, + "invalidCommissionRateForDisabledRewards" + ); + } + }); + + it("Fail to create delegator staking record", async () => { + try { + await program.methods + .createStakingRecord() + .accountsStrict({ + payer: setup.payer, + owner: setup.delegator1, + operatorPool: setup.pool1.pool, + ownerStakingRecord: setup.pool1.delegatorStakingRecord, + systemProgram: SystemProgram.programId, + poolOverview: setup.poolOverview, + }) + .signers([setup.payerKp, setup.delegator1Kp]) + .rpc(); + assert(false); + } catch (error) { + assertStakingProgramError(error, "delegatorStakingDisabled"); + } + }); + + it("Operator can stake successfully", async () => { + const ownerTokenAccount = getAssociatedTokenAddressSync( + setup.usdcTokenMint, + setup.pool1.admin + ); + const stakeAmount = new anchor.BN(50_000_000); // 50 USDC + + await program.methods + .stake({ tokenAmount: stakeAmount }) + .accountsStrict({ + owner: setup.pool1.admin, + poolOverview: setup.poolOverview, + operatorPool: setup.pool1.pool, + ownerStakingRecord: setup.pool1.stakingRecord, + operatorStakingRecord: setup.pool1.stakingRecord, + stakedTokenAccount: setup.pool1.stakedTokenAccount, + tokenProgram: TOKEN_PROGRAM_ID, + ownerTokenAccount, + instructions: SYSVAR_INSTRUCTIONS_PUBKEY, + }) + .signers([setup.pool1.adminKp]) + .rpc(); + + const operatorPool = await program.account.operatorPool.fetch( + setup.pool1.pool + ); + + assert(operatorPool.totalStakedAmount.eq(stakeAmount)); + assert(operatorPool.totalShares.eq(stakeAmount)); + assert(operatorPool.totalUnstaking.isZero()); + + const stakingRecord = await program.account.stakingRecord.fetch( + setup.pool1.stakingRecord + ); + assert(stakingRecord.shares.eq(stakeAmount)); + }); + + it("Create reward record with zero token rewards", async () => { + // Create rewards for only pool1 since it's the only pool we created in USDC-only mode + // Store this in the module-level variable so other tests can reuse the same data + epoch2Rewards = generateRewardsForEpoch([setup.pool1.pool], 2).map( + (reward) => ({ + ...reward, + // Token rewards are disabled in USDC-only mode, so keep leaves consistent + // with zero token amounts to satisfy on-chain proof verification. + tokenAmount: BigInt(0), + }) + ); + const merkleTree = MerkleUtils.constructMerkleTree(epoch2Rewards); + const merkleRoots = [Array.from(MerkleUtils.getTreeRoot(merkleTree))]; + const totalRewards = new anchor.BN(0); // No token rewards in USDC-only mode + let totalUsdcAmount = new anchor.BN(0); + + for (const reward of epoch2Rewards) { + totalUsdcAmount = totalUsdcAmount.add( + new anchor.BN(reward.usdcAmount.toString()) + ); + } + + // Mint USDC for rewards + await mintTo( + connection, + setup.payerKp, + setup.usdcTokenMint, + setup.usdcTokenAccount, + setup.tokenHolderKp, + BigInt(totalUsdcAmount.toString()) + ); + + await handleMarkEpochAsFinalizing({ + setup, + program, + }); + + await program.methods + .createRewardRecord({ + merkleRoots, + totalRewards, + totalUsdcPayout: totalUsdcAmount, + }) + .accountsStrict({ + payer: setup.payer, + authority: setup.rewardDistributionAuthority, + poolOverview: setup.poolOverview, + rewardRecord: setup.rewardRecords[2], + rewardTokenAccount: setup.rewardTokenAccount, + usdcTokenAccount: setup.usdcTokenAccount, + systemProgram: SystemProgram.programId, + }) + .signers([setup.payerKp, setup.rewardDistributionAuthorityKp]) + .rpc(); + + const rewardRecord = await program.account.rewardRecord.fetch( + setup.rewardRecords[2] + ); + assert(rewardRecord.epoch.eqn(2)); + assert(rewardRecord.totalRewards.eq(totalRewards)); + assert(rewardRecord.totalRewards.isZero()); // Verify zero token rewards + }); + + it("Fail to accrue non-zero token rewards", async () => { + // Use the same rewards we created in the previous test + const merkleTree = MerkleUtils.constructMerkleTree(epoch2Rewards); + const nodeIndex = epoch2Rewards.findIndex( + (x) => x.address == setup.pool1.pool.toString() + ); + const proofInputs = { + ...epoch2Rewards[nodeIndex], + index: nodeIndex, + merkleTree, + } as GenerateMerkleProofInput; + const { proof, proofPath } = MerkleUtils.generateMerkleProof(proofInputs); + + const rewardAmount = new anchor.BN(1_000); // Non-zero reward should fail + const usdcAmount = new anchor.BN(proofInputs.usdcAmount.toString()); + + try { + await program.methods + .accrueReward({ + merkleIndex: 0, + rewardAmount, + usdcAmount, + proof: proof.map((p) => Array.from(p)), + proofPath, + }) + .accountsStrict({ + poolUsdcVault: setup.pool1.poolUsdcVault, + poolOverview: setup.poolOverview, + operatorPool: setup.pool1.pool, + operatorStakingRecord: setup.pool1.stakingRecord, + rewardRecord: setup.rewardRecords[2], + rewardTokenAccount: setup.rewardTokenAccount, + usdcTokenAccount: setup.usdcTokenAccount, + stakedTokenAccount: setup.pool1.stakedTokenAccount, + rewardFeeTokenAccount: setup.pool1.rewardCommissionFeeTokenVault, + usdcFeeTokenAccount: setup.pool1.usdcCommissionFeeTokenVault, + tokenProgram: TOKEN_PROGRAM_ID, + instructions: SYSVAR_INSTRUCTIONS_PUBKEY, + }) + .rpc(); + assert(false); + } catch (error) { + assertStakingProgramError(error, "tokenRewardsDisabled"); + } + }); + + it("Accrue USDC earnings successfully", async () => { + // Use the same rewards we created in the previous tests + const merkleTree = MerkleUtils.constructMerkleTree(epoch2Rewards); + const nodeIndex = epoch2Rewards.findIndex( + (x) => x.address == setup.pool1.pool.toString() + ); + const proofInputs = { + ...epoch2Rewards[nodeIndex], + index: nodeIndex, + merkleTree, + } as GenerateMerkleProofInput; + const { proof, proofPath } = MerkleUtils.generateMerkleProof(proofInputs); + + const rewardAmount = new anchor.BN(0); // Zero token rewards + const usdcAmount = new anchor.BN(proofInputs.usdcAmount.toString()); + + const operatorPoolPre = await program.account.operatorPool.fetch( + setup.pool1.pool + ); + + await program.methods + .accrueReward({ + merkleIndex: 0, + rewardAmount, + usdcAmount, + proof: proof.map((p) => Array.from(p)), + proofPath, + }) + .accountsStrict({ + poolUsdcVault: setup.pool1.poolUsdcVault, + poolOverview: setup.poolOverview, + operatorPool: setup.pool1.pool, + operatorStakingRecord: setup.pool1.stakingRecord, + rewardRecord: setup.rewardRecords[2], + rewardTokenAccount: setup.rewardTokenAccount, + usdcTokenAccount: setup.usdcTokenAccount, + stakedTokenAccount: setup.pool1.stakedTokenAccount, + rewardFeeTokenAccount: setup.pool1.rewardCommissionFeeTokenVault, + usdcFeeTokenAccount: setup.pool1.usdcCommissionFeeTokenVault, + tokenProgram: TOKEN_PROGRAM_ID, + instructions: SYSVAR_INSTRUCTIONS_PUBKEY, + }) + .rpc(); + + const operatorPool = await program.account.operatorPool.fetch( + setup.pool1.pool + ); + + // Verify no token rewards were accrued + assert( + operatorPool.accruedRewards.eq(operatorPoolPre.accruedRewards), + "Token rewards should not increase" + ); + assert( + operatorPool.accruedRewards.isZero(), + "Token rewards should be zero" + ); + + // USDC per share should not increase since there's no delegator staking + assert( + operatorPool.cumulativeUsdcPerShare.eq( + operatorPoolPre.cumulativeUsdcPerShare + ), + "USDC per share should not change" + ); + }); + + it("Operator can unstake successfully", async () => { + const unstakeAmount = new anchor.BN(10_000_000); // 10 USDC + const operatorPoolPre = await program.account.operatorPool.fetch( + setup.pool1.pool + ); + const stakingRecordPre = await program.account.stakingRecord.fetch( + setup.pool1.stakingRecord + ); + + await program.methods + .unstake({ sharesAmount: unstakeAmount }) + .accountsStrict({ + owner: setup.pool1.admin, + poolOverview: setup.poolOverview, + operatorPool: setup.pool1.pool, + ownerStakingRecord: setup.pool1.stakingRecord, + operatorStakingRecord: setup.pool1.stakingRecord, + instructions: SYSVAR_INSTRUCTIONS_PUBKEY, + }) + .signers([setup.pool1.adminKp]) + .rpc(); + + const operatorPool = await program.account.operatorPool.fetch( + setup.pool1.pool + ); + assert( + operatorPoolPre.totalStakedAmount + .sub(operatorPool.totalStakedAmount) + .eq(unstakeAmount) + ); + assert( + operatorPoolPre.totalShares + .sub(operatorPool.totalShares) + .eq(unstakeAmount) + ); + assert(operatorPool.totalUnstaking.eq(unstakeAmount)); + + const stakingRecord = await program.account.stakingRecord.fetch( + setup.pool1.stakingRecord + ); + assert(stakingRecordPre.shares.sub(stakingRecord.shares).eq(unstakeAmount)); + assert(stakingRecord.tokensUnstakeAmount.eq(unstakeAmount)); + }); + + it("Operator can claim unstake successfully", async () => { + // Sleep till delay duration has elapsed + await sleep(operatorUnstakeDelaySeconds.toNumber() * 2 * 1_000); + + const ownerTokenAccount = getAssociatedTokenAddressSync( + setup.usdcTokenMint, + setup.pool1.admin + ); + + const [ownerTokenAccountBalancePre, programTokenAccountBalancePre] = + await Promise.all([ + connection.getTokenAccountBalance(ownerTokenAccount), + connection.getTokenAccountBalance(setup.pool1.stakedTokenAccount), + ]); + + await program.methods + .claimUnstake() + .accountsStrict({ + owner: setup.pool1.admin, + poolOverview: setup.poolOverview, + operatorPool: setup.pool1.pool, + ownerStakingRecord: setup.pool1.stakingRecord, + operatorStakingRecord: setup.pool1.stakingRecord, + ownerTokenAccount, + stakedTokenAccount: setup.pool1.stakedTokenAccount, + tokenProgram: TOKEN_PROGRAM_ID, + instructions: SYSVAR_INSTRUCTIONS_PUBKEY, + }) + .rpc(); + + const [ownerTokenAccountBalancePost, programTokenAccountBalancePost] = + await Promise.all([ + connection.getTokenAccountBalance(ownerTokenAccount), + connection.getTokenAccountBalance(setup.pool1.stakedTokenAccount), + ]); + + const unstakeAmount = new anchor.BN(10_000_000); // From previous test + + assert( + new anchor.BN(ownerTokenAccountBalancePost.value.amount) + .sub(new anchor.BN(ownerTokenAccountBalancePre.value.amount)) + .eq(unstakeAmount) + ); + + assert( + new anchor.BN(programTokenAccountBalancePre.value.amount) + .sub(new anchor.BN(programTokenAccountBalancePost.value.amount)) + .eq(unstakeAmount) + ); + + const stakingRecord = await program.account.stakingRecord.fetch( + setup.pool1.stakingRecord + ); + assert(stakingRecord.tokensUnstakeAmount.isZero()); + assert(stakingRecord.unstakeAtTimestamp.isZero()); + }); +});