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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions src/defi/defi.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";
Expand All @@ -51,6 +57,7 @@ export class DeFiController {
private riskAssessmentService: RiskAssessmentService,
private transactionOptimizationService: TransactionOptimizationService,
private protocolRegistry: ProtocolRegistry,
private stakingService: StakingService,
) {}

// ==================== Protocols ====================
Expand Down Expand Up @@ -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);
}
}


Expand Down
3 changes: 3 additions & 0 deletions src/defi/defi.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -103,6 +104,7 @@ import { TradeLockService } from "./trade-lock.service";
RiskAssessmentService,
TransactionOptimizationService,
TradeLockService,
StakingService,
],
controllers: [DeFiController, TradeController],
exports: [
Expand All @@ -112,6 +114,7 @@ import { TradeLockService } from "./trade-lock.service";
RiskAssessmentService,
TransactionOptimizationService,
TradeLockService,
StakingService,
],
})
export class DeFiModule implements OnModuleInit {
Expand Down
83 changes: 83 additions & 0 deletions src/defi/dto/staking.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
177 changes: 177 additions & 0 deletions src/defi/services/staking.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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<DeFiPosition> => ({
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<string, jest.Mock>;
let yieldRepo: Record<string, jest.Mock>;
let protocolRegistry: Record<string, jest.Mock>;

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>(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);
});
});
});
Loading
Loading