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
26 changes: 26 additions & 0 deletions packages/backend/src/analytics/analytics.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
UserStakesQueryDto,
UserStakesResponseDto,
} from './dto/user-stakes.dto';
import { TotalValueLockedResponseDto } from './dto/tvl.dto';

@ApiTags('Analytics')
@Controller('users')
Expand Down Expand Up @@ -108,4 +109,29 @@ export class AnalyticsController {
const limit = query.limit ?? 20;
return this.analyticsService.getUserStakes(address, page, limit);
}

/**
* GET /analytics/:userAddress/tvl
*
* Returns the total XLM value locked in all unresolved (Pending) stakes
* for the given wallet address.
*/
@Get(':userAddress/tvl')
@ApiOperation({
summary: 'Get Total Value Locked',
description:
"Sums the amounts of every stake whose underlying call is still PENDING. " +
"This represents the user's active capital that has not yet been resolved.",
})
@ApiParam({ name: 'userAddress', description: 'Stellar wallet address of the user' })
@ApiResponse({
status: 200,
description: 'Total value locked successfully aggregated',
type: TotalValueLockedResponseDto,
})
getTotalValueLocked(
@Param('userAddress') userAddress: string,
): Promise<TotalValueLockedResponseDto> {
return this.analyticsService.getTotalValueLocked(userAddress);
}
}
80 changes: 75 additions & 5 deletions packages/backend/src/analytics/analytics.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,88 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { AnalyticsService } from './analytics.service';
import { Stake } from './entities/stake.entity';

describe('AnalyticsService', () => {
const mockQb = {
innerJoin: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
select: jest.fn().mockReturnThis(),
addSelect: jest.fn().mockReturnThis(),
getRawOne: jest.fn(),
};

const mockStakeLedgerRepository = {
createQueryBuilder: jest.fn(() => mockQb),
};

describe('AnalyticsService – getTotalValueLocked', () => {
let service: AnalyticsService;

beforeEach(async () => {
// Reset call counts between tests without recreating the chain references
jest.clearAllMocks();
mockStakeLedgerRepository.createQueryBuilder.mockReturnValue(mockQb);

const module: TestingModule = await Test.createTestingModule({
providers: [AnalyticsService],
providers: [
AnalyticsService,
{
provide: getRepositoryToken(Stake),
useValue: mockStakeLedgerRepository,
},
],
}).compile();

service = module.get<AnalyticsService>(AnalyticsService);
});

it('should be defined', () => {
expect(service).toBeDefined();
it('returns correct TVL and count when pending stakes exist', async () => {
mockQb.getRawOne.mockResolvedValue({
totalValueLocked: '1250.75',
pendingStakesCount: '8',
});

const result = await service.getTotalValueLocked('GBXXX');

expect(result).toEqual({
userAddress: 'GBXXX',
totalValueLocked: 1250.75,
pendingStakesCount: 8,
});

// Assert the query was scoped correctly
expect(mockQb.where).toHaveBeenCalledWith(
'stake.userAddress = :userAddress',
{ userAddress: 'GBXXX' },
);
expect(mockQb.andWhere).toHaveBeenCalledWith(
'call.outcome = :outcome',
{ outcome: 'PENDING' },
);
});

it('returns zeros when the user has no pending stakes', async () => {
// DB returns COALESCE default — still a string from getRawOne
mockQb.getRawOne.mockResolvedValue({
totalValueLocked: '0',
pendingStakesCount: '0',
});

const result = await service.getTotalValueLocked('GBYYY');

expect(result.totalValueLocked).toBe(0);
expect(result.pendingStakesCount).toBe(0);
expect(result.userAddress).toBe('GBYYY');
});

it('handles null getRawOne result gracefully', async () => {
// Edge case: getRawOne can return undefined if the driver returns nothing
mockQb.getRawOne.mockResolvedValue(undefined);

const result = await service.getTotalValueLocked('GBZZZ');

expect(result.totalValueLocked).toBe(0);
expect(result.pendingStakesCount).toBe(0);
});
});
});
35 changes: 33 additions & 2 deletions packages/backend/src/analytics/analytics.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { TotalValueLockedResponseDto } from './dto/tvl.dto';
import { Injectable } from '@nestjs/common';
import { DataSource, Repository } from 'typeorm';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
Expand Down Expand Up @@ -27,10 +28,12 @@ export class AnalyticsService {
private readonly callRepository: Repository<Call>,
@InjectRepository(Stake)
private readonly stakeRepository: Repository<Stake>,
@InjectRepository(Stake)
private readonly stakeLedgerRepository: Repository<Stake>,

private readonly dataSource: DataSource,
@Inject(CACHE_MANAGER) private cacheManager: Cache,
) { }
) {}

/**
* Get a paginated ledger of a user's stakes joined with call info.
Expand Down Expand Up @@ -430,4 +433,32 @@ export class AnalyticsService {

return Number(reputation.toFixed(4));
}

/**
* Aggregates a user's active Portfolio "Total Value Locked".
*
* Loops over every StakeLedger row where:
* - userAddress matches the caller
* - the parent Call still has outcome === 'PENDING' (i.e. unresolved)
*
* Returns the XLM sum of those amounts and a count of matching rows.
*/
async getTotalValueLocked(
userAddress: string,
): Promise<TotalValueLockedResponseDto> {
const result = await this.stakeLedgerRepository
.createQueryBuilder('stake')
.innerJoin('stake.call', 'call')
.where('stake.userAddress = :userAddress', { userAddress })
.andWhere('call.outcome = :outcome', { outcome: 'PENDING' })
.select('COALESCE(SUM(stake.amount), 0)', 'totalValueLocked')
.addSelect('COUNT(stake.id)', 'pendingStakesCount')
.getRawOne<{ totalValueLocked: string; pendingStakesCount: string }>();

return {
userAddress,
totalValueLocked: parseFloat(result?.totalValueLocked ?? '0'),
pendingStakesCount: parseInt(result?.pendingStakesCount ?? '0', 10),
};
}
}
21 changes: 21 additions & 0 deletions packages/backend/src/analytics/dto/tvl.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { ApiProperty } from '@nestjs/swagger';

export class TotalValueLockedResponseDto {
@ApiProperty({
description: 'Wallet address the TVL was calculated for',
example: 'GBXXX...',
})
userAddress: string;

@ApiProperty({
description: 'Sum of all Pending stake amounts for this user, in XLM',
example: 1250.75,
})
totalValueLocked: number;

@ApiProperty({
description: 'Number of individual Pending stakes included in the sum',
example: 8,
})
pendingStakesCount: number;
}
Loading