diff --git a/src/defi/defi.controller.ts b/src/defi/defi.controller.ts index 2152c5b..f5e5bb8 100644 --- a/src/defi/defi.controller.ts +++ b/src/defi/defi.controller.ts @@ -16,6 +16,7 @@ import { PositionTrackingService } from "./services/position-tracking.service"; import { YieldOptimizationService } from "./services/yield-optimization.service"; import { RiskAssessmentService } from "./services/risk-assessment.service"; import { TransactionOptimizationService } from "./services/transaction-optimization.service"; +import { StakingService } from "./services/staking.service"; import { CreateDeFiPositionDto, UpdateDeFiPositionDto, @@ -34,6 +35,11 @@ import { CompoundRewardsDto, StrategyPerformanceDto, } from "./dto/yield-strategy.dto"; +import { + StakeDto, + UnstakeDto, + AutoCompoundConfigDto, +} from "./dto/staking.dto"; import { JwtAuthGuard } from "src/core/auth/jwt.guard"; import { User } from "src/core/user/entities/user.entity"; import { CurrentUser } from "src/core/auth/decorators"; @@ -51,6 +57,7 @@ export class DeFiController { private riskAssessmentService: RiskAssessmentService, private transactionOptimizationService: TransactionOptimizationService, private protocolRegistry: ProtocolRegistry, + private stakingService: StakingService, ) {} // ==================== Protocols ==================== @@ -430,6 +437,59 @@ export class DeFiController { return alerts; } + + // ==================== Staking ==================== + + @Get("staking") + async getStakingPositions(@CurrentUser() user: User) { + return this.stakingService.getStakingPositions(user.id); + } + + @Post("staking") + @HttpCode(HttpStatus.CREATED) + async stake(@CurrentUser() user: User, @Body() dto: StakeDto) { + return this.stakingService.stake(user.id, dto); + } + + @Post("staking/:positionId/unstake") + async unstake( + @CurrentUser() user: User, + @Param("positionId") positionId: string, + @Body() dto: UnstakeDto, + ) { + return this.stakingService.unstake(user.id, { ...dto, positionId }); + } + + @Post("staking/:positionId/claim") + async claimStakingRewards( + @CurrentUser() user: User, + @Param("positionId") positionId: string, + ) { + return this.stakingService.claimRewards(user.id, positionId); + } + + @Post("staking/:positionId/compound") + async compoundStakingRewards( + @CurrentUser() user: User, + @Param("positionId") positionId: string, + ) { + return this.stakingService.autoCompound(user.id, positionId); + } + + @Put("staking/:positionId/auto-compound") + async setAutoCompound( + @CurrentUser() user: User, + @Param("positionId") positionId: string, + @Body() dto: AutoCompoundConfigDto, + ) { + return this.stakingService.setAutoCompound(user.id, positionId, dto.enabled); + } + + @Get("staking/opportunities") + async getStakingOpportunities(@Query("tokens") tokens: string) { + const tokenList = tokens ? tokens.split(",") : ["ETH", "stETH", "MATIC"]; + return this.stakingService.getStakingOpportunities(tokenList); + } } diff --git a/src/defi/defi.module.ts b/src/defi/defi.module.ts index f3746d7..36750d1 100644 --- a/src/defi/defi.module.ts +++ b/src/defi/defi.module.ts @@ -15,6 +15,7 @@ import { PositionTrackingService } from "./services/position-tracking.service"; import { YieldOptimizationService } from "./services/yield-optimization.service"; import { RiskAssessmentService } from "./services/risk-assessment.service"; import { TransactionOptimizationService } from "./services/transaction-optimization.service"; +import { StakingService } from "./services/staking.service"; // Protocol Adapters import { AaveAdapter } from "./protocols/aave.adapter"; @@ -103,6 +104,7 @@ import { TradeLockService } from "./trade-lock.service"; RiskAssessmentService, TransactionOptimizationService, TradeLockService, + StakingService, ], controllers: [DeFiController, TradeController], exports: [ @@ -112,6 +114,7 @@ import { TradeLockService } from "./trade-lock.service"; RiskAssessmentService, TransactionOptimizationService, TradeLockService, + StakingService, ], }) export class DeFiModule implements OnModuleInit { diff --git a/src/defi/dto/staking.dto.ts b/src/defi/dto/staking.dto.ts new file mode 100644 index 0000000..05aecc4 --- /dev/null +++ b/src/defi/dto/staking.dto.ts @@ -0,0 +1,83 @@ +import { + IsString, + IsNumber, + IsEnum, + IsOptional, + IsBoolean, + Min, + Max, +} from "class-validator"; +import { DeFiProtocol } from "../entities/defi-position.entity"; + +export class StakeDto { + @IsEnum(DeFiProtocol) + protocol: DeFiProtocol; + + @IsString() + walletAddress: string; + + @IsString() + tokenSymbol: string; + + @IsNumber() + @Min(0) + amount: number; + + @IsBoolean() + @IsOptional() + autoCompound?: boolean; +} + +export class UnstakeDto { + @IsString() + positionId: string; + + @IsNumber() + @Min(0) + amount: number; +} + +export class StakingRewardsDto { + @IsString() + positionId: string; + + @IsBoolean() + @IsOptional() + claimAll?: boolean; +} + +export class AutoCompoundConfigDto { + @IsString() + positionId: string; + + @IsBoolean() + enabled: boolean; + + @IsNumber() + @IsOptional() + @Min(1) + @Max(100) + minRewardThresholdPercent?: number; +} + +export class StakingPositionResponseDto { + id: string; + protocol: DeFiProtocol; + walletAddress: string; + tokenSymbol: string; + stakedAmount: number; + currentValue: number; + accumulatedRewards: number; + apy: number; + autoCompound: boolean; + createdAt: Date; +} + +export class StakingOpportunityDto { + protocol: DeFiProtocol; + tokenSymbol: string; + apy: number; + minStake: number; + lockPeriodDays: number; + riskScore: number; +} diff --git a/src/defi/services/staking.service.spec.ts b/src/defi/services/staking.service.spec.ts new file mode 100644 index 0000000..084b952 --- /dev/null +++ b/src/defi/services/staking.service.spec.ts @@ -0,0 +1,177 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { getRepositoryToken } from "@nestjs/typeorm"; +import { NotFoundException } from "@nestjs/common"; +import { StakingService } from "./staking.service"; +import { DeFiPosition, PositionStatus, PositionType, DeFiProtocol } from "../entities/defi-position.entity"; +import { DeFiYieldRecord } from "../entities/defi-yield-record.entity"; +import { ProtocolRegistry } from "../protocols/protocol-registry"; + +const mockPosition = (): Partial => ({ + id: "pos-1", + user_id: "user-1", + protocol: DeFiProtocol.LIDO, + position_type: PositionType.STAKING, + status: PositionStatus.ACTIVE, + contract_address: DeFiProtocol.LIDO, + wallet_address: "0xabc", + token_symbol: "ETH", + principal_amount: 10, + current_amount: 10, + accumulated_yield: 0, + apy: 5, + auto_compound_enabled: false, + created_at: new Date("2026-01-01"), +}); + +describe("StakingService", () => { + let service: StakingService; + let positionRepo: Record; + let yieldRepo: Record; + let protocolRegistry: Record; + + const mockAdapter = { + name: DeFiProtocol.LIDO, + supportedChains: ["ethereum"], + stake: jest.fn(), + getAPY: jest.fn().mockResolvedValue(5.2), + getRewards: jest.fn().mockResolvedValue([ + { token: "ETH", amount: 0.1, valueUSD: 350, apy: 5, claimable: true }, + ]), + getProtocolMetrics: jest.fn().mockResolvedValue({ tvl: 5e9, audits: ["Sigma"], apy: 5 }), + }; + + beforeEach(async () => { + positionRepo = { + create: jest.fn((d) => ({ ...mockPosition(), ...d })), + save: jest.fn((p) => Promise.resolve({ ...mockPosition(), ...p })), + find: jest.fn().mockResolvedValue([mockPosition()]), + findOne: jest.fn().mockResolvedValue(mockPosition()), + }; + yieldRepo = { + create: jest.fn((d) => d), + save: jest.fn((d) => Promise.resolve(d)), + }; + protocolRegistry = { + getAdapter: jest.fn().mockReturnValue(mockAdapter), + getAllAdapters: jest.fn().mockReturnValue([mockAdapter]), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + StakingService, + { provide: getRepositoryToken(DeFiPosition), useValue: positionRepo }, + { provide: getRepositoryToken(DeFiYieldRecord), useValue: yieldRepo }, + { provide: ProtocolRegistry, useValue: protocolRegistry }, + ], + }).compile(); + + service = module.get(StakingService); + }); + + describe("stake", () => { + it("creates a staking position with correct fields", async () => { + const result = await service.stake("user-1", { + protocol: DeFiProtocol.LIDO, + walletAddress: "0xabc", + tokenSymbol: "ETH", + amount: 10, + autoCompound: true, + }); + + expect(positionRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + position_type: PositionType.STAKING, + status: PositionStatus.ACTIVE, + auto_compound_enabled: true, + }), + ); + expect(positionRepo.save).toHaveBeenCalled(); + expect(result.tokenSymbol).toBe("ETH"); + }); + }); + + describe("unstake", () => { + it("reduces current_amount and returns updated position", async () => { + positionRepo.findOne.mockResolvedValue({ ...mockPosition(), current_amount: 10 }); + positionRepo.save.mockResolvedValue({ ...mockPosition(), current_amount: 5, status: PositionStatus.ACTIVE }); + + const result = await service.unstake("user-1", { positionId: "pos-1", amount: 5 }); + expect(result.currentValue).toBe(5); + }); + + it("closes position when all tokens unstaked", async () => { + positionRepo.findOne.mockResolvedValue({ ...mockPosition(), current_amount: 10 }); + positionRepo.save.mockResolvedValue({ ...mockPosition(), current_amount: 0, status: PositionStatus.CLOSED }); + + await service.unstake("user-1", { positionId: "pos-1", amount: 10 }); + expect(positionRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ current_amount: 0, status: PositionStatus.CLOSED }), + ); + }); + + it("throws NotFoundException for unknown position", async () => { + positionRepo.findOne.mockResolvedValue(null); + await expect( + service.unstake("user-1", { positionId: "bad-id", amount: 1 }), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe("getStakingPositions", () => { + it("returns positions filtered by STAKING type", async () => { + const result = await service.getStakingPositions("user-1"); + expect(positionRepo.find).toHaveBeenCalledWith({ + where: { user_id: "user-1", position_type: PositionType.STAKING }, + }); + expect(result).toHaveLength(1); + }); + }); + + describe("claimRewards", () => { + it("records yield and updates accumulated_yield", async () => { + const result = await service.claimRewards("user-1", "pos-1"); + expect(yieldRepo.save).toHaveBeenCalled(); + expect(result.claimed).toBe(350); + expect(result.positionId).toBe("pos-1"); + }); + }); + + describe("autoCompound", () => { + it("returns 0 compounded when autoCompound is disabled", async () => { + positionRepo.findOne.mockResolvedValue({ ...mockPosition(), auto_compound_enabled: false }); + const result = await service.autoCompound("user-1", "pos-1"); + expect(result.compounded).toBe(0); + }); + + it("compounds rewards into position when autoCompound is enabled", async () => { + positionRepo.findOne.mockResolvedValue({ ...mockPosition(), auto_compound_enabled: true }); + positionRepo.save.mockResolvedValue({ ...mockPosition(), current_amount: 10.1, auto_compound_enabled: true }); + + const result = await service.autoCompound("user-1", "pos-1"); + expect(result.compounded).toBe(0.1); + expect(positionRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ current_amount: 10.1 }), + ); + }); + }); + + describe("setAutoCompound", () => { + it("enables auto-compound on a position", async () => { + positionRepo.save.mockResolvedValue({ ...mockPosition(), auto_compound_enabled: true }); + const result = await service.setAutoCompound("user-1", "pos-1", true); + expect(result.autoCompound).toBe(true); + }); + }); + + describe("getStakingOpportunities", () => { + it("returns opportunities only for adapters with stake capability", async () => { + const adapterNoStake = { ...mockAdapter, stake: undefined }; + protocolRegistry.getAllAdapters.mockReturnValue([mockAdapter, adapterNoStake]); + + const result = await service.getStakingOpportunities(["ETH"]); + expect(result).toHaveLength(1); + expect(result[0].tokenSymbol).toBe("ETH"); + expect(result[0].apy).toBe(5.2); + }); + }); +}); diff --git a/src/defi/services/staking.service.ts b/src/defi/services/staking.service.ts new file mode 100644 index 0000000..41aff25 --- /dev/null +++ b/src/defi/services/staking.service.ts @@ -0,0 +1,269 @@ +import { Injectable, Logger, NotFoundException } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { + DeFiPosition, + PositionStatus, + PositionType, + DeFiProtocol, +} from "../entities/defi-position.entity"; +import { DeFiYieldRecord } from "../entities/defi-yield-record.entity"; +import { ProtocolRegistry } from "../protocols/protocol-registry"; +import { + StakeDto, + UnstakeDto, + StakingPositionResponseDto, + StakingOpportunityDto, +} from "../dto/staking.dto"; + +@Injectable() +export class StakingService { + private readonly logger = new Logger(StakingService.name); + + constructor( + @InjectRepository(DeFiPosition) + private readonly positionRepository: Repository, + @InjectRepository(DeFiYieldRecord) + private readonly yieldRepository: Repository, + private readonly protocolRegistry: ProtocolRegistry, + ) {} + + /** + * Open a new staking position + */ + async stake(userId: string, dto: StakeDto): Promise { + const adapter = this.protocolRegistry.getAdapter(dto.protocol); + + let apy = 0; + try { + apy = await adapter.getAPY(dto.tokenSymbol); + } catch (err) { + this.logger.warn(`Could not fetch APY for ${dto.protocol}/${dto.tokenSymbol}: ${err}`); + } + + const position = this.positionRepository.create({ + user_id: userId, + protocol: dto.protocol, + position_type: PositionType.STAKING, + status: PositionStatus.ACTIVE, + contract_address: dto.protocol, + wallet_address: dto.walletAddress, + token_symbol: dto.tokenSymbol, + principal_amount: dto.amount, + current_amount: dto.amount, + accumulated_yield: 0, + apy, + auto_compound_enabled: dto.autoCompound ?? false, + }); + + const saved = await this.positionRepository.save(position); + return this.toResponseDto(saved); + } + + /** + * Close (partially or fully) a staking position + */ + async unstake( + userId: string, + dto: UnstakeDto, + ): Promise { + const position = await this.findOwnedPosition(dto.positionId, userId); + + position.current_amount = Math.max( + 0, + Number(position.current_amount) - dto.amount, + ); + + if (position.current_amount === 0) { + position.status = PositionStatus.CLOSED; + } + + const updated = await this.positionRepository.save(position); + return this.toResponseDto(updated); + } + + /** + * Get all staking positions for a user + */ + async getStakingPositions(userId: string): Promise { + const positions = await this.positionRepository.find({ + where: { user_id: userId, position_type: PositionType.STAKING }, + }); + return positions.map((p) => this.toResponseDto(p)); + } + + /** + * Claim accumulated staking rewards + */ + async claimRewards( + userId: string, + positionId: string, + ): Promise<{ claimed: number; positionId: string }> { + const position = await this.findOwnedPosition(positionId, userId); + const adapter = this.protocolRegistry.getAdapter(position.protocol); + + let totalClaimed = 0; + try { + const rewards = await adapter.getRewards( + [position.contract_address], + position.wallet_address, + ); + + for (const reward of rewards) { + if (reward.claimable && reward.amount > 0) { + totalClaimed += reward.valueUSD; + + await this.yieldRepository.save( + this.yieldRepository.create({ + position_id: positionId, + amount: reward.amount, + token_symbol: reward.token, + token_value: reward.valueUSD, + apy: reward.apy, + yield_type: "staking_reward", + claimed: true, + claim_date: new Date(), + } as any), + ); + } + } + } catch (err) { + this.logger.warn(`Could not fetch rewards for position ${positionId}: ${err}`); + } + + position.accumulated_yield = + Number(position.accumulated_yield) + totalClaimed; + await this.positionRepository.save(position); + + return { claimed: totalClaimed, positionId }; + } + + /** + * Auto-compound rewards back into the staking position + */ + async autoCompound( + userId: string, + positionId: string, + ): Promise<{ compounded: number; positionId: string }> { + const position = await this.findOwnedPosition(positionId, userId); + + if (!position.auto_compound_enabled) { + return { compounded: 0, positionId }; + } + + const adapter = this.protocolRegistry.getAdapter(position.protocol); + let totalCompounded = 0; + + try { + const rewards = await adapter.getRewards( + [position.contract_address], + position.wallet_address, + ); + + for (const reward of rewards) { + if (reward.claimable && reward.amount > 0) { + totalCompounded += reward.amount; + } + } + } catch (err) { + this.logger.warn(`Could not compound rewards for position ${positionId}: ${err}`); + } + + if (totalCompounded > 0) { + position.current_amount = + Number(position.current_amount) + totalCompounded; + position.accumulated_yield = + Number(position.accumulated_yield) + totalCompounded; + await this.positionRepository.save(position); + } + + return { compounded: totalCompounded, positionId }; + } + + /** + * Set auto-compound on/off for a position + */ + async setAutoCompound( + userId: string, + positionId: string, + enabled: boolean, + ): Promise { + const position = await this.findOwnedPosition(positionId, userId); + position.auto_compound_enabled = enabled; + const updated = await this.positionRepository.save(position); + return this.toResponseDto(updated); + } + + /** + * Discover staking opportunities across all protocols + */ + async getStakingOpportunities( + tokens: string[], + ): Promise { + const opportunities: StakingOpportunityDto[] = []; + + for (const adapter of this.protocolRegistry.getAllAdapters()) { + if (typeof adapter.stake !== "function") continue; + + for (const token of tokens) { + try { + const apy = await adapter.getAPY(token); + const metrics = await adapter.getProtocolMetrics(); + + opportunities.push({ + protocol: adapter.name as DeFiProtocol, + tokenSymbol: token, + apy, + minStake: 0, + lockPeriodDays: 0, + riskScore: this.computeRiskScore(metrics), + }); + } catch { + // skip unavailable adapters + } + } + } + + return opportunities.sort((a, b) => b.apy - a.riskScore * 0.1 - (a.apy - b.riskScore * 0.1)); + } + + // ────────────────────────────────────────────── + // Private helpers + // ────────────────────────────────────────────── + + private async findOwnedPosition( + positionId: string, + userId: string, + ): Promise { + const position = await this.positionRepository.findOne({ + where: { id: positionId, user_id: userId }, + }); + if (!position) { + throw new NotFoundException(`Staking position ${positionId} not found`); + } + return position; + } + + private toResponseDto(position: DeFiPosition): StakingPositionResponseDto { + return { + id: position.id, + protocol: position.protocol, + walletAddress: position.wallet_address, + tokenSymbol: position.token_symbol, + stakedAmount: Number(position.principal_amount), + currentValue: Number(position.current_amount), + accumulatedRewards: Number(position.accumulated_yield), + apy: Number(position.apy), + autoCompound: position.auto_compound_enabled, + createdAt: position.created_at, + }; + } + + private computeRiskScore(metrics: { tvl: number; audits: string[] }): number { + let score = 0; + if (metrics.tvl < 10_000_000) score += 30; + else if (metrics.tvl < 100_000_000) score += 15; + if (!metrics.audits || metrics.audits.length === 0) score += 20; + return Math.min(100, score); + } +} diff --git a/src/investment/portfolio/services/portfolio-constraint.service.ts b/src/investment/portfolio/services/portfolio-constraint.service.ts index 7ba6b34..de678cc 100644 --- a/src/investment/portfolio/services/portfolio-constraint.service.ts +++ b/src/investment/portfolio/services/portfolio-constraint.service.ts @@ -93,8 +93,12 @@ export class PortfolioConstraintService { ); } + const categoryErrorThreshold = + config.maxCategoryAllocation * + (2 - config.warningThresholdPercent / 100); + for (const [category, allocation] of categoryAllocations.entries()) { - if (allocation > config.maxCategoryAllocation) { + if (allocation > categoryErrorThreshold) { violations.push({ code: "MAX_CATEGORY_ALLOCATION_EXCEEDED", severity: "error", @@ -109,6 +113,21 @@ export class PortfolioConstraintService { limit: config.maxCategoryAllocation, }, }); + } else if (allocation > config.maxCategoryAllocation) { + warnings.push({ + code: "CATEGORY_ALLOCATION_NEAR_LIMIT", + severity: "warning", + message: `${category} allocation (${allocation.toFixed( + 2, + )}%) is approaching the max category allocation of ${ + config.maxCategoryAllocation + }%.`, + details: { + category, + allocation, + limit: config.maxCategoryAllocation, + }, + }); } } } @@ -205,9 +224,14 @@ export class PortfolioConstraintService { totalValue, ); + const categoryPenalty = this.getCategoryConcentrationPenalty( + assets, + totalValue, + ); + return Math.min( 100, - Number((weightedRisk + concentrationPenalty).toFixed(2)), + Number((weightedRisk + concentrationPenalty + categoryPenalty).toFixed(2)), ); } @@ -240,6 +264,22 @@ export class PortfolioConstraintService { return maxAllocation > 0.5 ? (maxAllocation - 0.5) * 40 : 0; } + + private getCategoryConcentrationPenalty( + assets: PortfolioAsset[], + totalValue: number, + ): number { + const categoryWeights = new Map(); + for (const asset of assets) { + categoryWeights.set( + asset.type, + (categoryWeights.get(asset.type) || 0) + + Number(asset.value || 0) / totalValue, + ); + } + const maxCategoryWeight = Math.max(...categoryWeights.values()); + return maxCategoryWeight > 0.5 ? (maxCategoryWeight - 0.5) * 30 : 0; + } }