Files
aitbc/tests/services/test_staking_service.py
aitbc 7c51f3490b Remove outdated GPU marketplace endpoint and fix staking service logic
- Remove duplicate `/marketplace/gpu/{gpu_id}` endpoint from marketplace_gpu.py
- Remove marketplace_gpu router inclusion from main.py (already included elsewhere)
- Fix staking service staker_count logic to check existing stakes before increment/decrement
- Add minimum stake amount validation (100 AITBC)
- Add proper error handling for stake not found cases
- Fix staking pool update to commit and refresh after modifications
- Update CLI send_transaction to use chain
2026-04-13 22:07:51 +02:00

532 lines
20 KiB
Python

"""
Staking Service Tests
High-priority tests for staking service functionality
"""
import pytest
import asyncio
import sys
from datetime import datetime, timedelta
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, Session
import sys
sys.path.insert(0, "/opt/aitbc/apps/coordinator-api/src")
from app.domain.bounty import AgentStake, AgentMetrics, StakingPool, StakeStatus, PerformanceTier
from app.services.staking_service import StakingService
@pytest.fixture
def db_session():
"""Create SQLite in-memory database for testing"""
engine = create_engine("sqlite:///:memory:", echo=False)
from sqlmodel import SQLModel
SQLModel.metadata.create_all(engine)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
session = SessionLocal()
yield session
session.close()
@pytest.fixture
def staking_service(db_session):
"""Create staking service instance"""
return StakingService(db_session)
@pytest.fixture
def agent_metrics(db_session):
"""Create test agent metrics"""
metrics = AgentMetrics(
agent_wallet="0x1234567890123456789012345678901234567890",
total_staked=0.0,
staker_count=0,
total_rewards_distributed=0.0,
average_accuracy=95.0,
total_submissions=10,
successful_submissions=9,
success_rate=90.0,
current_tier=PerformanceTier.GOLD,
tier_score=80.0
)
db_session.add(metrics)
db_session.commit()
db_session.refresh(metrics)
return metrics
@pytest.fixture
def staking_pool(db_session, agent_metrics):
"""Create test staking pool"""
pool = StakingPool(
agent_wallet=agent_metrics.agent_wallet,
total_staked=0.0,
total_rewards=0.0,
pool_apy=5.0,
staker_count=0,
active_stakers=[],
last_distribution_time=datetime.utcnow(),
distribution_frequency=1
)
db_session.add(pool)
db_session.commit()
db_session.refresh(pool)
return pool
class TestStakingService:
"""Test 2.1.1: Create stake via service"""
async def test_create_stake_success(self, staking_service, agent_metrics):
"""Test creating a stake with valid parameters"""
staker_address = "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd"
amount = 1000.0
lock_period = 30
auto_compound = False
# Create stake
stake = await staking_service.create_stake(
staker_address=staker_address,
agent_wallet=agent_metrics.agent_wallet,
amount=amount,
lock_period=lock_period,
auto_compound=auto_compound
)
# Verify stake created
assert stake is not None
assert stake.staker_address == staker_address
assert stake.agent_wallet == agent_metrics.agent_wallet
assert stake.amount == amount
assert stake.lock_period == lock_period
assert stake.status == StakeStatus.ACTIVE
assert stake.auto_compound == auto_compound
assert stake.agent_tier == PerformanceTier.GOLD
# Verify APY calculated
# Base APY = 5%, Gold tier multiplier = 1.5, 30-day lock multiplier = 1.1
# Expected APY = 5% * 1.5 * 1.1 = 8.25%
assert stake.current_apy > 8.0 # Allow small rounding error
# Verify end time calculated
expected_end_time = datetime.utcnow() + timedelta(days=lock_period)
time_diff = abs((stake.end_time - expected_end_time).total_seconds())
assert time_diff < 60 # Within 1 minute
# Verify agent metrics updated
updated_metrics = await staking_service.get_agent_metrics(agent_metrics.agent_wallet)
assert updated_metrics.total_staked == amount
assert updated_metrics.staker_count == 1
async def test_create_stake_unsupported_agent(self, staking_service):
"""Test creating stake on unsupported agent"""
staker_address = "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd"
unsupported_agent = "0x0000000000000000000000000000000000000000"
with pytest.raises(ValueError, match="Agent not supported"):
await staking_service.create_stake(
staker_address=staker_address,
agent_wallet=unsupported_agent,
amount=1000.0,
lock_period=30,
auto_compound=False
)
async def test_create_stake_invalid_amount(self, staking_service, agent_metrics):
"""Test creating stake with invalid amount (below minimum)"""
staker_address = "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd"
with pytest.raises(ValueError, match="Stake amount must be at least 100 AITBC"):
await staking_service.create_stake(
staker_address=staker_address,
agent_wallet=agent_metrics.agent_wallet,
amount=50.0, # Below minimum
lock_period=30,
auto_compound=False
)
async def test_get_stake(self, staking_service, agent_metrics):
"""Test retrieving a stake by ID"""
# First create a stake
staker_address = "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd"
created_stake = await staking_service.create_stake(
staker_address=staker_address,
agent_wallet=agent_metrics.agent_wallet,
amount=1000.0,
lock_period=30,
auto_compound=False
)
# Retrieve the stake
retrieved_stake = await staking_service.get_stake(created_stake.stake_id)
# Verify stake retrieved
assert retrieved_stake is not None
assert retrieved_stake.stake_id == created_stake.stake_id
assert retrieved_stake.staker_address == staker_address
assert retrieved_stake.amount == 1000.0
async def test_get_stake_not_found(self, staking_service):
"""Test retrieving non-existent stake"""
with pytest.raises(ValueError, match="Stake not found"):
await staking_service.get_stake("nonexistent_stake_id")
async def test_calculate_apy(self, staking_service, agent_metrics):
"""Test APY calculation for different tiers and lock periods"""
# Bronze tier, 30 days: 5% * 1.0 * 1.1 = 5.5%
apy_bronze_30 = await staking_service.calculate_apy(agent_metrics.agent_wallet, 30)
assert apy_bronze_30 > 5.0
# Gold tier, 90 days: 5% * 1.5 * 1.25 = 9.375%
apy_gold_90 = await staking_service.calculate_apy(agent_metrics.agent_wallet, 90)
assert apy_gold_90 > 9.0
# Diamond tier, 365 days: 5% * 3.0 * 2.0 = 30% (capped at 20%)
# Update agent to Diamond tier
agent_metrics.current_tier = PerformanceTier.DIAMOND
staking_service.session.commit()
apy_diamond_365 = await staking_service.calculate_apy(agent_metrics.agent_wallet, 365)
assert apy_diamond_365 == 20.0 # Capped at maximum
async def test_get_agent_metrics(self, staking_service, agent_metrics):
"""Test retrieving agent metrics"""
metrics = await staking_service.get_agent_metrics(agent_metrics.agent_wallet)
assert metrics is not None
assert metrics.agent_wallet == agent_metrics.agent_wallet
assert metrics.total_staked == 0.0
assert metrics.current_tier == PerformanceTier.GOLD
assert metrics.average_accuracy == 95.0
async def test_get_staking_pool(self, staking_service, agent_metrics):
"""Test retrieving staking pool after stake creation"""
# Create a stake first which will create the pool
staker_address = "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd"
await staking_service.create_stake(
staker_address=staker_address,
agent_wallet=agent_metrics.agent_wallet,
amount=1000.0,
lock_period=30,
auto_compound=False
)
pool = await staking_service.get_staking_pool(agent_metrics.agent_wallet)
assert pool is not None
assert pool.agent_wallet == agent_metrics.agent_wallet
assert pool.total_staked == 1000.0
assert pool.pool_apy > 5.0
async def test_unbond_stake_before_lock_period(self, staking_service, agent_metrics):
"""Test unbonding stake before lock period ends should fail"""
staker_address = "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd"
# Create a stake
stake = await staking_service.create_stake(
staker_address=staker_address,
agent_wallet=agent_metrics.agent_wallet,
amount=1000.0,
lock_period=30,
auto_compound=False
)
# Try to unbond immediately (lock period not ended)
with pytest.raises(ValueError, match="Lock period has not ended"):
await staking_service.unbond_stake(stake.stake_id)
async def test_unbond_stake_after_lock_period(self, staking_service, agent_metrics):
"""Test unbonding stake after lock period ends"""
staker_address = "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd"
# Create a stake
stake = await staking_service.create_stake(
staker_address=staker_address,
agent_wallet=agent_metrics.agent_wallet,
amount=1000.0,
lock_period=30,
auto_compound=False
)
# Simulate lock period ending by updating end_time
stake.end_time = datetime.utcnow() - timedelta(days=1)
staking_service.session.commit()
# Unbond the stake
unbonded_stake = await staking_service.unbond_stake(stake.stake_id)
assert unbonded_stake.status == StakeStatus.UNBONDING
assert unbonded_stake.unbonding_time is not None
async def test_complete_unbonding_with_penalty(self, staking_service, agent_metrics):
"""Test completing unbonding with early penalty"""
staker_address = "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd"
# Create a stake
stake = await staking_service.create_stake(
staker_address=staker_address,
agent_wallet=agent_metrics.agent_wallet,
amount=1000.0,
lock_period=30,
auto_compound=False
)
# Unbond the stake
stake.end_time = datetime.utcnow() - timedelta(days=1)
staking_service.session.commit()
await staking_service.unbond_stake(stake.stake_id)
# Complete unbonding within 30 days (should have 10% penalty)
result = await staking_service.complete_unbonding(stake.stake_id)
assert result is not None
assert "total_amount" in result
assert "penalty" in result
# Penalty should be 10% of 1000 = 100, so returned amount should be 900 + rewards
assert result["penalty"] == 100.0
async def test_complete_unbonding_no_penalty(self, staking_service, agent_metrics):
"""Test completing unbonding after unbonding period (no penalty)"""
staker_address = "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd"
# Create a stake
stake = await staking_service.create_stake(
staker_address=staker_address,
agent_wallet=agent_metrics.agent_wallet,
amount=1000.0,
lock_period=30,
auto_compound=False
)
# Unbond the stake
stake.end_time = datetime.utcnow() - timedelta(days=1)
staking_service.session.commit()
await staking_service.unbond_stake(stake.stake_id)
# Set unbonding time to 35 days ago (past 30-day penalty period)
stake = await staking_service.get_stake(stake.stake_id)
stake.unbonding_time = datetime.utcnow() - timedelta(days=35)
staking_service.session.commit()
# Complete unbonding (no penalty)
result = await staking_service.complete_unbonding(stake.stake_id)
assert result is not None
assert "total_amount" in result
assert result["penalty"] == 0.0
async def test_calculate_rewards(self, staking_service, agent_metrics):
"""Test reward calculation for active stake"""
staker_address = "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd"
# Create a stake
stake = await staking_service.create_stake(
staker_address=staker_address,
agent_wallet=agent_metrics.agent_wallet,
amount=1000.0,
lock_period=30,
auto_compound=False
)
# Calculate rewards
rewards = await staking_service.calculate_rewards(stake.stake_id)
assert rewards >= 0.0
async def test_calculate_rewards_unbonding_stake(self, staking_service, agent_metrics):
"""Test reward calculation for unbonding stake (should return accumulated)"""
staker_address = "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd"
# Create a stake
stake = await staking_service.create_stake(
staker_address=staker_address,
agent_wallet=agent_metrics.agent_wallet,
amount=1000.0,
lock_period=30,
auto_compound=False
)
# Unbond the stake
stake.end_time = datetime.utcnow() - timedelta(days=1)
staking_service.session.commit()
await staking_service.unbond_stake(stake.stake_id)
# Calculate rewards (should return accumulated rewards only)
rewards = await staking_service.calculate_rewards(stake.stake_id)
assert rewards == stake.accumulated_rewards
async def test_create_stake_minimum_amount(self, staking_service, agent_metrics):
"""Test creating stake with minimum valid amount"""
staker_address = "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd"
# Create stake with exactly minimum amount (100)
stake = await staking_service.create_stake(
staker_address=staker_address,
agent_wallet=agent_metrics.agent_wallet,
amount=100.0, # Minimum amount
lock_period=30,
auto_compound=False
)
assert stake is not None
assert stake.amount == 100.0
async def test_create_stake_maximum_amount(self, staking_service, agent_metrics):
"""Test creating stake with large amount"""
staker_address = "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd"
# Create stake with large amount
stake = await staking_service.create_stake(
staker_address=staker_address,
agent_wallet=agent_metrics.agent_wallet,
amount=100000.0,
lock_period=30,
auto_compound=False
)
assert stake is not None
assert stake.amount == 100000.0
async def test_auto_compound_enabled(self, staking_service, agent_metrics):
"""Test creating stake with auto-compound enabled"""
staker_address = "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd"
stake = await staking_service.create_stake(
staker_address=staker_address,
agent_wallet=agent_metrics.agent_wallet,
amount=1000.0,
lock_period=30,
auto_compound=True
)
assert stake.auto_compound is True
async def test_multiple_stakes_same_agent(self, staking_service, agent_metrics):
"""Test creating multiple stakes on same agent"""
staker_address = "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd"
# Create first stake
stake1 = await staking_service.create_stake(
staker_address=staker_address,
agent_wallet=agent_metrics.agent_wallet,
amount=1000.0,
lock_period=30,
auto_compound=False
)
# Create second stake
stake2 = await staking_service.create_stake(
staker_address=staker_address,
agent_wallet=agent_metrics.agent_wallet,
amount=2000.0,
lock_period=90,
auto_compound=True
)
# Verify both stakes created
assert stake1.stake_id != stake2.stake_id
assert stake1.amount == 1000.0
assert stake2.amount == 2000.0
# Verify agent metrics updated with total
updated_metrics = await staking_service.get_agent_metrics(agent_metrics.agent_wallet)
assert updated_metrics.total_staked == 3000.0
assert updated_metrics.staker_count == 1 # Same staker
async def test_get_user_stakes(self, staking_service, agent_metrics):
"""Test retrieving all stakes for a user"""
staker_address = "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd"
# Create multiple stakes
await staking_service.create_stake(
staker_address=staker_address,
agent_wallet=agent_metrics.agent_wallet,
amount=1000.0,
lock_period=30,
auto_compound=False
)
await staking_service.create_stake(
staker_address=staker_address,
agent_wallet=agent_metrics.agent_wallet,
amount=2000.0,
lock_period=90,
auto_compound=True
)
# Get user stakes
stakes = await staking_service.get_user_stakes(staker_address)
assert len(stakes) == 2
assert all(stake.staker_address == staker_address for stake in stakes)
async def test_claim_rewards(self, staking_service, agent_metrics):
"""Test claiming rewards for a stake"""
staker_address = "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd"
# Create a stake
stake = await staking_service.create_stake(
staker_address=staker_address,
agent_wallet=agent_metrics.agent_wallet,
amount=1000.0,
lock_period=30,
auto_compound=False
)
# Add some accumulated rewards
stake.accumulated_rewards = 50.0
staking_service.session.commit()
# Claim rewards
result = await staking_service.claim_rewards([stake.stake_id])
assert result is not None
assert result["total_rewards"] == 50.0
assert result["claimed_stakes"] == 1
async def test_update_agent_performance(self, staking_service, agent_metrics):
"""Test updating agent performance metrics"""
# Update performance
updated_metrics = await staking_service.update_agent_performance(
agent_wallet=agent_metrics.agent_wallet,
accuracy=98.0,
successful=True
)
assert updated_metrics is not None
# Service recalculates average based on all submissions
assert updated_metrics.successful_submissions == 10 # 9 + 1
assert updated_metrics.total_submissions == 11
# Average is recalculated: (9*95 + 98) / 10 = 95.3
assert updated_metrics.average_accuracy > 95.0
async def test_database_rollback_on_error(self, staking_service, agent_metrics):
"""Test database rollback when stake creation fails"""
staker_address = "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd"
# Get initial total staked
initial_metrics = await staking_service.get_agent_metrics(agent_metrics.agent_wallet)
initial_staked = initial_metrics.total_staked
# Try to create stake with invalid amount (should fail and rollback)
try:
await staking_service.create_stake(
staker_address=staker_address,
agent_wallet=agent_metrics.agent_wallet,
amount=50.0, # Invalid (below minimum)
lock_period=30,
auto_compound=False
)
assert False, "Should have raised ValueError"
except ValueError:
pass
# Verify database state unchanged (rollback worked)
final_metrics = await staking_service.get_agent_metrics(agent_metrics.agent_wallet)
assert final_metrics.total_staked == initial_staked