From 102125deb590028e84289c1bbec230e806ef9158 Mon Sep 17 00:00:00 2001 From: Sean Date: Wed, 29 Oct 2025 19:54:03 -0700 Subject: [PATCH 1/9] Implement USDC only mode --- programs/inference-staking/src/emissions.rs | 82 ++++++++++++++++--- programs/inference-staking/src/error.rs | 8 ++ .../operator_pool/create_operator_pool.rs | 22 +++++ .../operator_pool/update_operator_pool.rs | 21 +++++ .../pool_overview/create_pool_overview.rs | 30 ++++++- .../reward_record/accrue_reward.rs | 5 ++ .../reward_record/create_reward_record.rs | 7 +- .../staking_record/create_staking_record.rs | 28 +++++-- .../src/instructions/staking_record/stake.rs | 5 ++ .../src/state/pool_overview.rs | 10 +++ 10 files changed, 201 insertions(+), 17 deletions(-) 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..69c7313 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,36 @@ 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: Option, +) -> 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.unwrap_or(CreatePoolOverviewArgs { + is_token_mint_usdc: false, + token_rewards_enabled: true, + }); + + // 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 +104,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..2559c3a 100644 --- a/programs/inference-staking/src/instructions/reward_record/accrue_reward.rs +++ b/programs/inference-staking/src/instructions/reward_record/accrue_reward.rs @@ -125,6 +125,11 @@ pub fn handler(ctx: Context, args: AccrueRewardArgs) -> Result<()> )?; let pool_overview = &ctx.accounts.pool_overview; + + // If token rewards are disabled, enforce that reward_amount is zero + if !pool_overview.token_rewards_enabled { + require_eq!(reward_amount, 0, ErrorCode::TokenRewardsDisabled); + } 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/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, From 3b69b7c3c6f3fe4be37b401eb467cce197447fc0 Mon Sep 17 00:00:00 2001 From: Sean Date: Wed, 29 Oct 2025 19:55:37 -0700 Subject: [PATCH 2/9] Fix type errors --- .../instructions/pool_overview/create_pool_overview.rs | 10 ++-------- programs/inference-staking/src/lib.rs | 7 +++++-- 2 files changed, 7 insertions(+), 10 deletions(-) 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 69c7313..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 @@ -71,20 +71,14 @@ pub struct CreatePoolOverviewArgs { } /// Instruction to setup a PoolOverview singleton. To be called after initial program deployment. -pub fn handler( - ctx: Context, - args: Option, -) -> 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.unwrap_or(CreatePoolOverviewArgs { - is_token_mint_usdc: false, - token_rewards_enabled: true, - }); + } = args; // If USDC mint mode is enabled, enforce that the native token mint == USDC mint if is_token_mint_usdc { 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( From 81b0c3dfbc967b7d66e079b2b096df19b50c679d Mon Sep 17 00:00:00 2001 From: Sean Date: Wed, 29 Oct 2025 19:55:55 -0700 Subject: [PATCH 3/9] Build --- sdk/src/idl.ts | 78 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 77 insertions(+), 1 deletion(-) 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: [ From f7f69a32a3ee962c16f2d29adc171d7f526321f0 Mon Sep 17 00:00:00 2001 From: Sean Date: Wed, 29 Oct 2025 19:57:24 -0700 Subject: [PATCH 4/9] Fix tests --- tests/constraints.test.ts | 10 ++++++++-- tests/inference-staking.test.ts | 7 ++++++- tests/multi-epochs.test.ts | 6 +++++- tests/rewards.test.ts | 6 +++++- 4 files changed, 24 insertions(+), 5 deletions(-) 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/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(); From 49236b5c94ff42301d8c29555d7ca763b807a213 Mon Sep 17 00:00:00 2001 From: Sean Date: Wed, 29 Oct 2025 20:15:58 -0700 Subject: [PATCH 5/9] Add USDC test --- Anchor.toml | 1 + scripts/anchor-configs/Anchor.dev.toml | 1 + scripts/anchor-configs/Anchor.local.toml | 1 + scripts/test-all.sh | 16 +- tests/usdc-only-mode.test.ts | 676 +++++++++++++++++++++++ 5 files changed, 694 insertions(+), 1 deletion(-) create mode 100644 tests/usdc-only-mode.test.ts 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/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/tests/usdc-only-mode.test.ts b/tests/usdc-only-mode.test.ts new file mode 100644 index 0000000..ef12e81 --- /dev/null +++ b/tests/usdc-only-mode.test.ts @@ -0,0 +1,676 @@ +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 { PublicKey, 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; + + 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.registrationFeePayoutTokenAccount, + 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.registrationFeePayoutTokenAccount, + 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("Fail to stake as delegator", async () => { + const ownerTokenAccount = getAssociatedTokenAddressSync( + setup.usdcTokenMint, + setup.delegator1 + ); + + // Mint tokens to delegator for staking attempt + await mintTo( + connection, + setup.payerKp, + setup.usdcTokenMint, + ownerTokenAccount, + setup.tokenHolderKp, + BigInt(100_000_000) // 100 USDC + ); + + try { + await program.methods + .stake({ tokenAmount: new anchor.BN(10_000_000) }) + .accountsStrict({ + owner: setup.delegator1, + poolOverview: setup.poolOverview, + operatorPool: setup.pool1.pool, + ownerStakingRecord: setup.pool1.delegatorStakingRecord, + operatorStakingRecord: setup.pool1.stakingRecord, + stakedTokenAccount: setup.pool1.stakedTokenAccount, + tokenProgram: TOKEN_PROGRAM_ID, + ownerTokenAccount, + instructions: SYSVAR_INSTRUCTIONS_PUBKEY, + }) + .signers([setup.delegator1Kp]) + .rpc(); + assert(false); + } catch (error) { + assertStakingProgramError(error, "delegatorStakingDisabled"); + } + }); + + it("Create reward record with zero token rewards", async () => { + const merkleTree = MerkleUtils.constructMerkleTree(setup.rewardEpochs[2]); + 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); + + const rewards = generateRewardsForEpoch( + setup.rewardEpochs[2].map((x) => new PublicKey(x.address)), + 2 + ); + + for (const reward of rewards) { + 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 () => { + const merkleTree = MerkleUtils.constructMerkleTree(setup.rewardEpochs[2]); + const nodeIndex = setup.rewardEpochs[2].findIndex( + (x) => x.address == setup.pool1.pool.toString() + ); + const proofInputs = { + ...setup.rewardEpochs[2][nodeIndex], + index: nodeIndex, + merkleTree, + } as GenerateMerkleProofInput; + const { proof, proofPath } = MerkleUtils.generateMerkleProof(proofInputs); + + try { + await program.methods + .accrueReward({ + merkleIndex: 0, + rewardAmount: new anchor.BN(1_000), // Non-zero reward should fail + usdcAmount: new anchor.BN(10_000_000), + 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 () => { + const merkleTree = MerkleUtils.constructMerkleTree(setup.rewardEpochs[2]); + const nodeIndex = setup.rewardEpochs[2].findIndex( + (x) => x.address == setup.pool1.pool.toString() + ); + const proofInputs = { + ...setup.rewardEpochs[2][nodeIndex], + index: nodeIndex, + merkleTree, + } as GenerateMerkleProofInput; + const { proof, proofPath } = MerkleUtils.generateMerkleProof(proofInputs); + + const operatorPoolPre = await program.account.operatorPool.fetch( + setup.pool1.pool + ); + + await program.methods + .accrueReward({ + merkleIndex: 0, + rewardAmount: new anchor.BN(0), // Zero token rewards + usdcAmount: new anchor.BN(10_000_000), // 10 USDC + 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" + ); + + // Verify USDC was distributed (100% to operator due to 100% commission) + assert( + operatorPool.cumulativeUsdcPerShare.gt( + operatorPoolPre.cumulativeUsdcPerShare + ), + "USDC per share should increase" + ); + }); + + 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() * 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()); + }); +}); From df578ffca7d8ee2fcc283e2f23e03fb43432d705 Mon Sep 17 00:00:00 2001 From: Sean Date: Wed, 29 Oct 2025 20:18:33 -0700 Subject: [PATCH 6/9] Update README --- Anchor.toml | 4 ++-- README.md | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Anchor.toml b/Anchor.toml index 3c7cf24..4e3697a 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -16,8 +16,8 @@ cluster = "localnet" wallet = "~/.config/solana/id.json" [scripts] -test = "bun run ts-mocha -p ./tsconfig.json -r tsconfig-paths/register -t 100000000 tests/**/multi-epochs.test.ts" +# test = "bun run ts-mocha -p ./tsconfig.json -r tsconfig-paths/register -t 100000000 tests/**/multi-epochs.test.ts" # 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" +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..ea8f84a 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). From 4829b48052cacd18b8e29f09b8933821a2395ade Mon Sep 17 00:00:00 2001 From: Sean Date: Wed, 29 Oct 2025 20:48:03 -0700 Subject: [PATCH 7/9] Get test working --- .../reward_record/accrue_reward.rs | 13 +-- tests/lib/setup.ts | 9 ++ tests/usdc-only-mode.test.ts | 96 +++++++------------ 3 files changed, 52 insertions(+), 66 deletions(-) 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 2559c3a..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,12 +131,6 @@ pub fn handler(ctx: Context, args: AccrueRewardArgs) -> Result<()> usdc_amount, )?; - let pool_overview = &ctx.accounts.pool_overview; - - // If token rewards are disabled, enforce that reward_amount is zero - if !pool_overview.token_rewards_enabled { - require_eq!(reward_amount, 0, ErrorCode::TokenRewardsDisabled); - } let operator_staking_record: &mut Box> = &mut ctx.accounts.operator_staking_record; 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/usdc-only-mode.test.ts b/tests/usdc-only-mode.test.ts index ef12e81..8aff8dc 100644 --- a/tests/usdc-only-mode.test.ts +++ b/tests/usdc-only-mode.test.ts @@ -6,7 +6,7 @@ import { TOKEN_PROGRAM_ID, } from "@solana/spl-token"; import type { Connection } from "@solana/web3.js"; -import { PublicKey, SYSVAR_INSTRUCTIONS_PUBKEY } from "@solana/web3.js"; +import { SYSVAR_INSTRUCTIONS_PUBKEY } from "@solana/web3.js"; import { SystemProgram } from "@solana/web3.js"; import { assert } from "chai"; @@ -27,6 +27,7 @@ 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); @@ -240,7 +241,7 @@ describe("USDC-only mode tests", () => { systemProgram: SystemProgram.programId, adminTokenAccount, registrationFeePayoutTokenAccount: - setup.registrationFeePayoutTokenAccount, + setup.registrationFeePayoutUsdcAccount, // Use USDC version operatorUsdcVault: setup.pool1.poolUsdcVault, usdcMint: setup.usdcTokenMint, }) @@ -300,7 +301,7 @@ describe("USDC-only mode tests", () => { systemProgram: SystemProgram.programId, adminTokenAccount, registrationFeePayoutTokenAccount: - setup.registrationFeePayoutTokenAccount, + setup.registrationFeePayoutUsdcAccount, // Use USDC version operatorUsdcVault: setup.pool2.poolUsdcVault, usdcMint: setup.usdcTokenMint, }) @@ -372,56 +373,23 @@ describe("USDC-only mode tests", () => { assert(stakingRecord.shares.eq(stakeAmount)); }); - it("Fail to stake as delegator", async () => { - const ownerTokenAccount = getAssociatedTokenAddressSync( - setup.usdcTokenMint, - setup.delegator1 - ); - - // Mint tokens to delegator for staking attempt - await mintTo( - connection, - setup.payerKp, - setup.usdcTokenMint, - ownerTokenAccount, - setup.tokenHolderKp, - BigInt(100_000_000) // 100 USDC - ); - - try { - await program.methods - .stake({ tokenAmount: new anchor.BN(10_000_000) }) - .accountsStrict({ - owner: setup.delegator1, - poolOverview: setup.poolOverview, - operatorPool: setup.pool1.pool, - ownerStakingRecord: setup.pool1.delegatorStakingRecord, - operatorStakingRecord: setup.pool1.stakingRecord, - stakedTokenAccount: setup.pool1.stakedTokenAccount, - tokenProgram: TOKEN_PROGRAM_ID, - ownerTokenAccount, - instructions: SYSVAR_INSTRUCTIONS_PUBKEY, - }) - .signers([setup.delegator1Kp]) - .rpc(); - assert(false); - } catch (error) { - assertStakingProgramError(error, "delegatorStakingDisabled"); - } - }); - it("Create reward record with zero token rewards", async () => { - const merkleTree = MerkleUtils.constructMerkleTree(setup.rewardEpochs[2]); + // 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); - const rewards = generateRewardsForEpoch( - setup.rewardEpochs[2].map((x) => new PublicKey(x.address)), - 2 - ); - - for (const reward of rewards) { + for (const reward of epoch2Rewards) { totalUsdcAmount = totalUsdcAmount.add( new anchor.BN(reward.usdcAmount.toString()) ); @@ -469,23 +437,27 @@ describe("USDC-only mode tests", () => { }); it("Fail to accrue non-zero token rewards", async () => { - const merkleTree = MerkleUtils.constructMerkleTree(setup.rewardEpochs[2]); - const nodeIndex = setup.rewardEpochs[2].findIndex( + // 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 = { - ...setup.rewardEpochs[2][nodeIndex], + ...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: new anchor.BN(1_000), // Non-zero reward should fail - usdcAmount: new anchor.BN(10_000_000), + rewardAmount, + usdcAmount, proof: proof.map((p) => Array.from(p)), proofPath, }) @@ -511,17 +483,21 @@ describe("USDC-only mode tests", () => { }); it("Accrue USDC earnings successfully", async () => { - const merkleTree = MerkleUtils.constructMerkleTree(setup.rewardEpochs[2]); - const nodeIndex = setup.rewardEpochs[2].findIndex( + // 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 = { - ...setup.rewardEpochs[2][nodeIndex], + ...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 ); @@ -529,8 +505,8 @@ describe("USDC-only mode tests", () => { await program.methods .accrueReward({ merkleIndex: 0, - rewardAmount: new anchor.BN(0), // Zero token rewards - usdcAmount: new anchor.BN(10_000_000), // 10 USDC + rewardAmount, + usdcAmount, proof: proof.map((p) => Array.from(p)), proofPath, }) @@ -564,9 +540,9 @@ describe("USDC-only mode tests", () => { "Token rewards should be zero" ); - // Verify USDC was distributed (100% to operator due to 100% commission) + // USDC per share should not increase since there's no delegator staking assert( - operatorPool.cumulativeUsdcPerShare.gt( + operatorPool.cumulativeUsdcPerShare.eq( operatorPoolPre.cumulativeUsdcPerShare ), "USDC per share should increase" From dffb998fad9ce27cb025c88e8658e52a8367ff6f Mon Sep 17 00:00:00 2001 From: Sean Date: Wed, 29 Oct 2025 20:49:12 -0700 Subject: [PATCH 8/9] Cleanup --- README.md | 1 + tests/usdc-only-mode.test.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ea8f84a..7544603 100644 --- a/README.md +++ b/README.md @@ -29,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/tests/usdc-only-mode.test.ts b/tests/usdc-only-mode.test.ts index 8aff8dc..f8ffe5f 100644 --- a/tests/usdc-only-mode.test.ts +++ b/tests/usdc-only-mode.test.ts @@ -595,7 +595,7 @@ describe("USDC-only mode tests", () => { it("Operator can claim unstake successfully", async () => { // Sleep till delay duration has elapsed - await sleep(operatorUnstakeDelaySeconds.toNumber() * 1_000); + await sleep(operatorUnstakeDelaySeconds.toNumber() * 2 * 1_000); const ownerTokenAccount = getAssociatedTokenAddressSync( setup.usdcTokenMint, From e87cbfffd8350225d8434e8273a5527bdf45ea2b Mon Sep 17 00:00:00 2001 From: Sean Date: Wed, 29 Oct 2025 20:55:24 -0700 Subject: [PATCH 9/9] Nit --- Anchor.toml | 4 ++-- tests/usdc-only-mode.test.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Anchor.toml b/Anchor.toml index 4e3697a..3c7cf24 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -16,8 +16,8 @@ cluster = "localnet" wallet = "~/.config/solana/id.json" [scripts] -# test = "bun run ts-mocha -p ./tsconfig.json -r tsconfig-paths/register -t 100000000 tests/**/multi-epochs.test.ts" +test = "bun run ts-mocha -p ./tsconfig.json -r tsconfig-paths/register -t 100000000 tests/**/multi-epochs.test.ts" # 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" +# test = "bun run ts-mocha -p ./tsconfig.json -r tsconfig-paths/register -t 1000000 tests/**/usdc-only-mode.test.ts" diff --git a/tests/usdc-only-mode.test.ts b/tests/usdc-only-mode.test.ts index f8ffe5f..a163036 100644 --- a/tests/usdc-only-mode.test.ts +++ b/tests/usdc-only-mode.test.ts @@ -545,7 +545,7 @@ describe("USDC-only mode tests", () => { operatorPool.cumulativeUsdcPerShare.eq( operatorPoolPre.cumulativeUsdcPerShare ), - "USDC per share should increase" + "USDC per share should not change" ); });