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
63 changes: 62 additions & 1 deletion src/stellar/stellar.service.ts
Original file line number Diff line number Diff line change
@@ -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()
Expand Down Expand Up @@ -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<WalletBalanceResult> {
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' };
Expand Down
24 changes: 24 additions & 0 deletions src/wallets/wallet-balance.service.ts
Original file line number Diff line number Diff line change
@@ -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<WalletBalanceResult> {
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);
}
}
40 changes: 39 additions & 1 deletion src/wallets/wallets.controller.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Req,
Expand All @@ -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 };
Expand All @@ -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<WalletBalanceResult> {
return this.walletBalanceService.getBalance(id, req.user.userId);
}

@Delete(':id')
@Throttle({ walletDisconnect: { limit: 10, ttl: 60000 } })
Expand Down
4 changes: 3 additions & 1 deletion src/wallets/wallets.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 {}