From 12bf2852d049a2fe64bb8589dea24627524b5a09 Mon Sep 17 00:00:00 2001 From: petahade Date: Thu, 25 Jun 2026 18:48:46 +0000 Subject: [PATCH] feat(wallets): add GET /wallets/:id/balance endpoint for XLM balance checks Introduces a balance endpoint that queries Horizon for a wallet's native XLM balance and returns a low-funds warning flag to help callers avoid NFT mint failures caused by insufficient balance. - StellarService.getWalletBalance(): strict-error variant of getAccountBalance that throws BadRequestException for invalid addresses, NotFoundException for unfunded/non-existent Stellar accounts, and BadGatewayException for Horizon network failures; re-exports LOW_BALANCE_THRESHOLD_XLM (2.0) and WalletBalanceResult interface - WalletBalanceService: dedicated service (PrismaService + StellarService) that looks up the wallet by id+userId, guards against soft-deleted wallets, then delegates to getWalletBalance - WalletsController: new GET :id/balance route behind @Auth() + WalletOwnershipGuard with full Swagger docs and appropriate rate limit - WalletsModule: registers WalletBalanceService and exports it --- src/stellar/stellar.service.ts | 63 ++++++++++++++++++++++++++- src/wallets/wallet-balance.service.ts | 24 ++++++++++ src/wallets/wallets.controller.ts | 40 ++++++++++++++++- src/wallets/wallets.module.ts | 4 +- 4 files changed, 128 insertions(+), 3 deletions(-) create mode 100644 src/wallets/wallet-balance.service.ts diff --git a/src/stellar/stellar.service.ts b/src/stellar/stellar.service.ts index 0dae78e..4e789df 100644 --- a/src/stellar/stellar.service.ts +++ b/src/stellar/stellar.service.ts @@ -1,7 +1,20 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { + Injectable, + Logger, + BadRequestException, + NotFoundException, + BadGatewayException, +} from '@nestjs/common'; import { StrKey, Horizon } from '@stellar/stellar-sdk'; import { CircuitBreakerService, CircuitBreakerConfig } from '../common/circuit-breaker/circuit-breaker.service'; +export const LOW_BALANCE_THRESHOLD_XLM = 2.0; + +export interface WalletBalanceResult { + balance: number; + warning: boolean; +} + export type StellarNetwork = 'testnet' | 'public'; @Injectable() @@ -108,6 +121,54 @@ export class StellarService { }); } + /** + * Fetches the native XLM balance for a Stellar address with strict error propagation. + * Unlike getAccountBalance, this throws typed exceptions for callers that need to + * surface them to the HTTP layer (e.g. the wallet balance endpoint). + */ + async getWalletBalance(address: string): Promise { + const validation = this.validateAddress(address); + if (!validation.valid) { + throw new BadRequestException( + validation.message ?? 'Invalid Stellar address', + ); + } + + let balance: number; + try { + balance = await this.circuitBreakerService.execute( + this.horizonCircuitBreakerConfig, + async () => { + const server = new Horizon.Server(this.horizonUrl); + const account = await server.loadAccount(address); + const native = account.balances.find( + (b) => b.asset_type === 'native', + ); + return native ? parseFloat(native.balance) : 0; + }, + ); + } catch (err: unknown) { + const e = err as { name?: string; status?: number; response?: { status?: number }; message?: string }; + + if (e.name === 'ServiceUnavailableException') { + throw err; + } + + const httpStatus = e?.response?.status ?? e?.status; + if (httpStatus === 404) { + throw new NotFoundException( + `Stellar account not found for address: ${address}`, + ); + } + + const msg = e.message ?? 'Unknown Horizon error'; + this.logger.error(`Horizon balance fetch failed for ${address}: ${msg}`); + throw new BadGatewayException(`Horizon request failed: ${msg}`); + } + + return { balance, warning: balance < LOW_BALANCE_THRESHOLD_XLM }; + } + validateAddress(address: string): { valid: boolean; message?: string } { if (!address) { return { valid: false, message: 'Address is required' }; diff --git a/src/wallets/wallet-balance.service.ts b/src/wallets/wallet-balance.service.ts new file mode 100644 index 0000000..68876fe --- /dev/null +++ b/src/wallets/wallet-balance.service.ts @@ -0,0 +1,24 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { StellarService, WalletBalanceResult } from '../stellar/stellar.service'; + +@Injectable() +export class WalletBalanceService { + constructor( + private readonly prisma: PrismaService, + private readonly stellarService: StellarService, + ) {} + + async getBalance(walletId: number, userId: number): Promise { + const wallet = await this.prisma.wallet.findUnique({ + where: { id: walletId }, + select: { id: true, userId: true, address: true, deletedAt: true }, + }); + + if (!wallet || wallet.userId !== userId || wallet.deletedAt !== null) { + throw new NotFoundException(`Wallet ${walletId} not found`); + } + + return this.stellarService.getWalletBalance(wallet.address); + } +} diff --git a/src/wallets/wallets.controller.ts b/src/wallets/wallets.controller.ts index b8b6386..e454a6d 100644 --- a/src/wallets/wallets.controller.ts +++ b/src/wallets/wallets.controller.ts @@ -1,6 +1,7 @@ import { Controller, Delete, + Get, Param, ParseIntPipe, Req, @@ -14,8 +15,10 @@ import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiParam } from '@ne import { Throttle } from '@nestjs/throttler'; import { Auth } from '../auth/decorators/auth.decorator'; import { WalletsService, DisconnectResult } from './wallets.service'; +import { WalletBalanceService } from './wallet-balance.service'; import { CreateWalletConnectionDto } from './dto/connect-wallet.dto'; import { WalletOwnershipGuard } from './guards/wallet-ownership.guard'; +import { WalletBalanceResult } from '../stellar/stellar.service'; interface AuthRequest extends Request { user: { userId: number; email: string | null }; @@ -26,7 +29,42 @@ interface AuthRequest extends Request { @Controller('wallets') @Auth() export class WalletsController { - constructor(private readonly walletsService: WalletsService) {} + constructor( + private readonly walletsService: WalletsService, + private readonly walletBalanceService: WalletBalanceService, + ) {} + + @Get(':id/balance') + @Throttle({ default: { limit: 30, ttl: 60000 } }) + @UseGuards(WalletOwnershipGuard) + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Get wallet XLM balance', + description: + 'Returns the current native XLM balance for the specified wallet and a warning flag when funds are below the 2 XLM threshold that may cause NFT mint failures.', + }) + @ApiParam({ name: 'id', description: 'Wallet ID', type: 'number' }) + @ApiResponse({ + status: 200, + description: 'Balance retrieved successfully', + schema: { + type: 'object', + properties: { + balance: { type: 'number', example: 5.2, description: 'Available XLM balance' }, + warning: { type: 'boolean', example: false, description: 'True when balance is below 2 XLM' }, + }, + }, + }) + @ApiResponse({ status: 400, description: 'Invalid wallet address stored on record' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 404, description: 'Wallet not found or Stellar account does not exist' }) + @ApiResponse({ status: 502, description: 'Horizon network request failed' }) + async getBalance( + @Param('id', ParseIntPipe) id: number, + @Req() req: AuthRequest, + ): Promise { + return this.walletBalanceService.getBalance(id, req.user.userId); + } @Delete(':id') @Throttle({ walletDisconnect: { limit: 10, ttl: 60000 } }) diff --git a/src/wallets/wallets.module.ts b/src/wallets/wallets.module.ts index 0355a81..f57cfd0 100644 --- a/src/wallets/wallets.module.ts +++ b/src/wallets/wallets.module.ts @@ -3,6 +3,7 @@ import { AuthModule } from '../auth/auth.module'; import { WalletsService } from './wallets.service'; import { WalletValidationService } from './wallet-validation.service'; import { WalletManagementService } from './wallet-management.service'; +import { WalletBalanceService } from './wallet-balance.service'; import { WalletsController } from './wallets.controller'; import { PrismaModule } from '../prisma/prisma.module'; import { StellarModule } from '../stellar/stellar.module'; @@ -12,9 +13,10 @@ import { StellarModule } from '../stellar/stellar.module'; providers: [ WalletValidationService, WalletManagementService, + WalletBalanceService, WalletsService, ], controllers: [WalletsController], - exports: [WalletValidationService, WalletManagementService, WalletsService], + exports: [WalletValidationService, WalletManagementService, WalletBalanceService, WalletsService], }) export class WalletsModule {}