diff --git a/nftopia-backend/src/app.module.ts b/nftopia-backend/src/app.module.ts index 3d6342a..dceae31 100644 --- a/nftopia-backend/src/app.module.ts +++ b/nftopia-backend/src/app.module.ts @@ -11,6 +11,7 @@ import { NftModule } from './nft/nft.module'; import { LoggerModule } from 'nestjs-pino'; import { APP_FILTER } from '@nestjs/core'; import { HttpExceptionFilter } from './common/filters/http-exception.filter'; +import stellarConfig from './config/stellar.config'; import { StorageModule } from './storage/storage.module'; @Module({ @@ -39,7 +40,7 @@ import { StorageModule } from './storage/storage.module'; }, }), }), - ConfigModule.forRoot({ isGlobal: true }), + ConfigModule.forRoot({ isGlobal: true, load: [stellarConfig] }), CacheModule.registerAsync({ isGlobal: true, inject: [ConfigService], diff --git a/nftopia-backend/src/common/errors/stellar.errors.ts b/nftopia-backend/src/common/errors/stellar.errors.ts new file mode 100644 index 0000000..3022c00 --- /dev/null +++ b/nftopia-backend/src/common/errors/stellar.errors.ts @@ -0,0 +1,41 @@ +export class StellarError extends Error { + constructor( + message: string, + public readonly code: string, + public readonly metadata?: Record, + ) { + super(message); + this.name = this.constructor.name; + Error.captureStackTrace(this, this.constructor); + } +} + +export class SorobanRpcError extends StellarError { + constructor(message: string, metadata?: Record) { + super(message, 'SOROBAN_RPC_ERROR', metadata); + } +} + +export class TransactionFailedError extends StellarError { + constructor(message: string, metadata?: Record) { + super(message, 'TRANSACTION_FAILED_ERROR', metadata); + } +} + +export class InsufficientBalanceError extends StellarError { + constructor(message: string, metadata?: Record) { + super(message, 'INSUFFICIENT_BALANCE_ERROR', metadata); + } +} + +export class InvalidSignatureError extends StellarError { + constructor(message: string, metadata?: Record) { + super(message, 'INVALID_SIGNATURE_ERROR', metadata); + } +} + +export class ContractError extends StellarError { + constructor(message: string, metadata?: Record) { + super(message, 'CONTRACT_ERROR', metadata); + } +} diff --git a/nftopia-backend/src/config/stellar.config.ts b/nftopia-backend/src/config/stellar.config.ts new file mode 100644 index 0000000..fcc134c --- /dev/null +++ b/nftopia-backend/src/config/stellar.config.ts @@ -0,0 +1,20 @@ +import { registerAs } from '@nestjs/config'; + +export default registerAs('stellar', () => ({ + network: process.env.STELLAR_NETWORK || 'testnet', + rpcUrl: process.env.SOROBAN_RPC_URL || 'https://soroban-testnet.stellar.org', + passphrase: + process.env.STELLAR_NETWORK_PASSPHRASE || + 'Test SDF Network ; September 2015', + timeouts: { + rpcCall: parseInt(process.env.STELLAR_RPC_TIMEOUT_MS || '30000', 10), + simulation: parseInt( + process.env.STELLAR_SIMULATION_TIMEOUT_MS || '15000', + 10, + ), + submission: parseInt( + process.env.STELLAR_SUBMISSION_TIMEOUT_MS || '45000', + 10, + ), + }, +})); diff --git a/nftopia-backend/src/interceptors/stellar-error.interceptor.ts b/nftopia-backend/src/interceptors/stellar-error.interceptor.ts new file mode 100644 index 0000000..5119502 --- /dev/null +++ b/nftopia-backend/src/interceptors/stellar-error.interceptor.ts @@ -0,0 +1,72 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, + BadGatewayException, + BadRequestException, +} from '@nestjs/common'; +import { Observable, throwError } from 'rxjs'; +import { catchError } from 'rxjs/operators'; +import { + SorobanRpcError, + TransactionFailedError, + InsufficientBalanceError, + InvalidSignatureError, + ContractError, +} from '../common/errors/stellar.errors'; + +@Injectable() +export class StellarErrorInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + return next.handle().pipe( + catchError((error: any) => { + let safeMessage = error.message; + if (safeMessage && typeof safeMessage === 'string') { + // Redact Stellar secret seeds (starts with S and is 56 chars long) + safeMessage = safeMessage.replace( + /(S[A-Z0-9]{55})/g, + '[REDACTED_SECRET]', + ); + } + + if (error instanceof SorobanRpcError) { + return throwError( + () => + new BadGatewayException({ + message: safeMessage, + code: error.code, + ...error.metadata, + }), + ); + } + if (error instanceof TransactionFailedError) { + return throwError( + () => + new BadRequestException({ + message: safeMessage, + code: error.code, + ...error.metadata, + }), + ); + } + if ( + error instanceof InsufficientBalanceError || + error instanceof InvalidSignatureError || + error instanceof ContractError + ) { + return throwError( + () => + new BadRequestException({ + message: safeMessage, + code: error.code, + ...error.metadata, + }), + ); + } + + return throwError(() => error); + }), + ); + } +} diff --git a/nftopia-backend/src/interceptors/stellar-logging.interceptor.ts b/nftopia-backend/src/interceptors/stellar-logging.interceptor.ts new file mode 100644 index 0000000..11d77db --- /dev/null +++ b/nftopia-backend/src/interceptors/stellar-logging.interceptor.ts @@ -0,0 +1,58 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, + Logger, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { Request } from 'express'; + +@Injectable() +export class StellarLoggingInterceptor implements NestInterceptor { + private readonly logger = new Logger('StellarTx'); + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const ctx = context.switchToHttp(); + const req = ctx.getRequest(); + const now = Date.now(); + const method = req.method; + const url = req.url; + + // We can infer Soroban specific activity based on endpoint routes + const isStellarRoute = url.includes('/nft') || url.includes('/marketplace'); + + if (isStellarRoute) { + if (process.env.NODE_ENV !== 'production') { + this.logger.debug(`[SorobanRPC] Incoming request: ${method} ${url}`); + } + } + + return next.handle().pipe( + tap({ + next: (data: any) => { + const delay = Date.now() - now; + if (data && typeof data === 'object') { + const txHash = data.transactionHash || data.meta?.transactionHash; + const contractId = data.contractId || data.meta?.contractId; + + if (txHash || contractId) { + this.logger.log( + `[StellarTx] ${method} ${url} +${delay}ms - TX: ${txHash || 'N/A'} Contract: ${contractId || 'N/A'}`, + ); + } else if (isStellarRoute) { + this.logger.log(`[StellarCall] ${method} ${url} +${delay}ms`); + } + } + }, + error: (error: any) => { + const delay = Date.now() - now; + this.logger.error( + `[StellarError] ${method} ${url} +${delay}ms - ${error.message}`, + ); + }, + }), + ); + } +} diff --git a/nftopia-backend/src/interceptors/stellar-response.interceptor.ts b/nftopia-backend/src/interceptors/stellar-response.interceptor.ts new file mode 100644 index 0000000..a5bc4d0 --- /dev/null +++ b/nftopia-backend/src/interceptors/stellar-response.interceptor.ts @@ -0,0 +1,67 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { ConfigService } from '@nestjs/config'; + +export interface StellarResponse { + data: T; + meta: { + timestamp: string; + network: string; + transactionHash?: string; + contractId?: string; + }; +} + +@Injectable() +export class StellarResponseInterceptor implements NestInterceptor< + T, + StellarResponse +> { + constructor(private configService: ConfigService) {} + + intercept( + context: ExecutionContext, + next: CallHandler, + ): Observable> { + const network = + this.configService.get('stellar.network') || 'testnet'; + + return next.handle().pipe( + map((data) => { + let txHash, + contractId, + actualData = data; + + if (data && typeof data === 'object') { + if ('transactionHash' in data) { + txHash = data.transactionHash; + } + if ('contractId' in data) { + contractId = data.contractId; + } + // If the payload explicitly returned 'data' separate from metadata, use it. Otherwise, use the whole object. + if ('data' in data && Object.keys(data).length <= 3) { + // rudimentary check + actualData = data.data; + } + } + + return { + data: actualData, + meta: { + timestamp: new Date().toISOString(), + network, + ...(txHash && { transactionHash: txHash }), + ...(contractId && { contractId }), + }, + }; + }), + ); + } +} diff --git a/nftopia-backend/src/interceptors/stellar-timeout.interceptor.ts b/nftopia-backend/src/interceptors/stellar-timeout.interceptor.ts new file mode 100644 index 0000000..b4f59bd --- /dev/null +++ b/nftopia-backend/src/interceptors/stellar-timeout.interceptor.ts @@ -0,0 +1,47 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, + RequestTimeoutException, +} from '@nestjs/common'; +import { Observable, throwError, TimeoutError } from 'rxjs'; +import { catchError, timeout } from 'rxjs/operators'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class StellarTimeoutInterceptor implements NestInterceptor { + constructor(private configService: ConfigService) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const defaultTimeout = + this.configService.get('stellar.timeouts.rpcCall') || 30000; + + const req = context.switchToHttp().getRequest(); + const url = req.url; + let timeoutMs = defaultTimeout; + + if (url.includes('/simulate')) { + timeoutMs = + this.configService.get('stellar.timeouts.simulation') || 15000; + } else if (url.includes('/submit')) { + timeoutMs = + this.configService.get('stellar.timeouts.submission') || 45000; + } + + return next.handle().pipe( + timeout(timeoutMs), + catchError((err: any) => { + if (err instanceof TimeoutError) { + return throwError( + () => + new RequestTimeoutException( + `Stellar action timed out after ${timeoutMs}ms`, + ), + ); + } + return throwError(() => err); + }), + ); + } +} diff --git a/nftopia-backend/src/interceptors/stellar-transform.interceptor.ts b/nftopia-backend/src/interceptors/stellar-transform.interceptor.ts new file mode 100644 index 0000000..8c11868 --- /dev/null +++ b/nftopia-backend/src/interceptors/stellar-transform.interceptor.ts @@ -0,0 +1,98 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +@Injectable() +export class StellarTransformInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + return next.handle().pipe( + map((data) => { + return this.transformData(data); + }), + ); + } + + private transformData(data: any): any { + if (!data) return data; + + // Handle Horizon server collection pages + if (data.records && Array.isArray(data.records)) { + return { + items: data.records.map((item: any) => this.transformItem(item)), + nextPageToken: this.extractNextPageToken(data), + }; + } + + // Handle arrays + if (Array.isArray(data)) { + return data.map((item: any) => this.transformItem(item)); + } + + // Handle single item explicitly if it's an object with `data` payload + if (data.data && typeof data.data === 'object') { + data.data = this.transformData(data.data); + return data; + } + + return this.transformItem(data); + } + + private extractNextPageToken(data: any): string | null { + if (data._links && data._links.next && data._links.next.href) { + try { + const nextUrl = new URL(data._links.next.href); + return nextUrl.searchParams.get('cursor') || null; + } catch (e) { + return null; + } + } + return null; + } + + private stroopsToXlm(stroops: string): string { + try { + const isInteger = /^\d+$/.test(stroops); + if (!isInteger) return stroops; // Already formatted or invalid + + const s = BigInt(stroops); + const xlm = s / 10000000n; + const fraction = s % 10000000n; + if (fraction === 0n) return xlm.toString(); + const fractionStr = fraction + .toString() + .padStart(7, '0') + .replace(/0+$/, ''); + return `${xlm}.${fractionStr}`; + } catch { + return stroops; + } + } + + private transformItem(item: any): any { + if (!item || typeof item !== 'object') return item; + + const transformed = { ...item }; + + // Format Stellar account responses (e.g., balances, trustlines). + if (transformed.balances && Array.isArray(transformed.balances)) { + transformed.balances = transformed.balances.map((b: any) => { + if ( + b.balance && + typeof b.balance === 'string' && + /^\d+$/.test(b.balance) + ) { + // If we suspect it's pure stroops (no decimal), convert it to XLM format string + b.balanceXlm = this.stroopsToXlm(b.balance); + } + return b; + }); + } + + return transformed; + } +} diff --git a/nftopia-backend/src/main.ts b/nftopia-backend/src/main.ts index 6974fd9..a3ce1ce 100644 --- a/nftopia-backend/src/main.ts +++ b/nftopia-backend/src/main.ts @@ -4,6 +4,12 @@ import { AppModule } from './app.module'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { ValidationPipe } from '@nestjs/common'; import { Logger } from 'nestjs-pino'; +import { ConfigService } from '@nestjs/config'; +import { StellarResponseInterceptor } from './interceptors/stellar-response.interceptor'; +import { StellarLoggingInterceptor } from './interceptors/stellar-logging.interceptor'; +import { StellarErrorInterceptor } from './interceptors/stellar-error.interceptor'; +import { StellarTimeoutInterceptor } from './interceptors/stellar-timeout.interceptor'; +import { StellarTransformInterceptor } from './interceptors/stellar-transform.interceptor'; async function bootstrap() { const app = await NestFactory.create(AppModule); @@ -26,6 +32,15 @@ async function bootstrap() { }), ); + const configService = app.get(ConfigService); + app.useGlobalInterceptors( + new StellarLoggingInterceptor(), + new StellarTimeoutInterceptor(configService), + new StellarErrorInterceptor(), + new StellarTransformInterceptor(), + new StellarResponseInterceptor(configService), + ); + // Set global API prefix app.setGlobalPrefix('api/v1'); diff --git a/nftopia-backend/src/nft/soroban.service.ts b/nftopia-backend/src/nft/soroban.service.ts index 5992ac8..aff6caa 100644 --- a/nftopia-backend/src/nft/soroban.service.ts +++ b/nftopia-backend/src/nft/soroban.service.ts @@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Server, Durability } from 'stellar-sdk/rpc'; import { xdr } from 'stellar-sdk'; +import { SorobanRpcError } from '../common/errors/stellar.errors'; @Injectable() export class SorobanService { @@ -36,7 +37,10 @@ export class SorobanService { `Failed to fetch contract data for contract ${contractId}: ${error.message}`, error.stack, ); - return null; + throw new SorobanRpcError( + `Contract data fetch failed: ${error.message}`, + { contractId }, + ); } } @@ -60,7 +64,7 @@ export class SorobanService { } catch (e) { const error = e as Error; this.logger.error(`Error fetching events: ${error.message}`); - return []; + throw new SorobanRpcError(`Events fetch failed: ${error.message}`); } } @@ -71,7 +75,7 @@ export class SorobanService { } catch (e) { const error = e as Error; this.logger.error(`Error fetching latest ledger: ${error.message}`); - return 0; + throw new SorobanRpcError(`Latest ledger fetch failed: ${error.message}`); } } }