Skip to content
3 changes: 2 additions & 1 deletion nftopia-backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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],
Expand Down
41 changes: 41 additions & 0 deletions nftopia-backend/src/common/errors/stellar.errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
export class StellarError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly metadata?: Record<string, any>,
) {
super(message);
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
}
}

export class SorobanRpcError extends StellarError {
constructor(message: string, metadata?: Record<string, any>) {
super(message, 'SOROBAN_RPC_ERROR', metadata);
}
}

export class TransactionFailedError extends StellarError {
constructor(message: string, metadata?: Record<string, any>) {
super(message, 'TRANSACTION_FAILED_ERROR', metadata);
}
}

export class InsufficientBalanceError extends StellarError {
constructor(message: string, metadata?: Record<string, any>) {
super(message, 'INSUFFICIENT_BALANCE_ERROR', metadata);
}
}

export class InvalidSignatureError extends StellarError {
constructor(message: string, metadata?: Record<string, any>) {
super(message, 'INVALID_SIGNATURE_ERROR', metadata);
}
}

export class ContractError extends StellarError {
constructor(message: string, metadata?: Record<string, any>) {
super(message, 'CONTRACT_ERROR', metadata);
}
}
20 changes: 20 additions & 0 deletions nftopia-backend/src/config/stellar.config.ts
Original file line number Diff line number Diff line change
@@ -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,
),
},
}));
72 changes: 72 additions & 0 deletions nftopia-backend/src/interceptors/stellar-error.interceptor.ts
Original file line number Diff line number Diff line change
@@ -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<any> {
return next.handle().pipe(
catchError((error: any) => {
let safeMessage = error.message;

Check failure on line 24 in nftopia-backend/src/interceptors/stellar-error.interceptor.ts

View workflow job for this annotation

GitHub Actions / build-and-verify

Unsafe member access .message on an `any` value

Check failure on line 24 in nftopia-backend/src/interceptors/stellar-error.interceptor.ts

View workflow job for this annotation

GitHub Actions / build-and-verify

Unsafe assignment of an `any` value
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,

Check failure on line 37 in nftopia-backend/src/interceptors/stellar-error.interceptor.ts

View workflow job for this annotation

GitHub Actions / build-and-verify

Unsafe assignment of an `any` value
code: error.code,
...error.metadata,
}),
);
}
if (error instanceof TransactionFailedError) {
return throwError(
() =>
new BadRequestException({
message: safeMessage,

Check failure on line 47 in nftopia-backend/src/interceptors/stellar-error.interceptor.ts

View workflow job for this annotation

GitHub Actions / build-and-verify

Unsafe assignment of an `any` value
code: error.code,
...error.metadata,
}),
);
}
if (
error instanceof InsufficientBalanceError ||
error instanceof InvalidSignatureError ||
error instanceof ContractError
) {
return throwError(
() =>
new BadRequestException({
message: safeMessage,

Check failure on line 61 in nftopia-backend/src/interceptors/stellar-error.interceptor.ts

View workflow job for this annotation

GitHub Actions / build-and-verify

Unsafe assignment of an `any` value
code: error.code,
...error.metadata,
}),
);
}

return throwError(() => error);

Check failure on line 68 in nftopia-backend/src/interceptors/stellar-error.interceptor.ts

View workflow job for this annotation

GitHub Actions / build-and-verify

Unsafe return of a value of type `any`
}),
);
}
}
58 changes: 58 additions & 0 deletions nftopia-backend/src/interceptors/stellar-logging.interceptor.ts
Original file line number Diff line number Diff line change
@@ -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<any> {
const ctx = context.switchToHttp();
const req = ctx.getRequest<Request>();
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;

Check failure on line 37 in nftopia-backend/src/interceptors/stellar-logging.interceptor.ts

View workflow job for this annotation

GitHub Actions / build-and-verify

Unsafe member access .meta on an `any` value

Check failure on line 37 in nftopia-backend/src/interceptors/stellar-logging.interceptor.ts

View workflow job for this annotation

GitHub Actions / build-and-verify

Unsafe member access .transactionHash on an `any` value

Check failure on line 37 in nftopia-backend/src/interceptors/stellar-logging.interceptor.ts

View workflow job for this annotation

GitHub Actions / build-and-verify

Unsafe assignment of an `any` value
const contractId = data.contractId || data.meta?.contractId;

Check failure on line 38 in nftopia-backend/src/interceptors/stellar-logging.interceptor.ts

View workflow job for this annotation

GitHub Actions / build-and-verify

Unsafe assignment of an `any` value

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}`,
);
},
}),
);
}
}
67 changes: 67 additions & 0 deletions nftopia-backend/src/interceptors/stellar-response.interceptor.ts
Original file line number Diff line number Diff line change
@@ -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<T> {
data: T;
meta: {
timestamp: string;
network: string;
transactionHash?: string;
contractId?: string;
};
}

@Injectable()
export class StellarResponseInterceptor<T> implements NestInterceptor<
T,
StellarResponse<T>
> {
constructor(private configService: ConfigService) {}

intercept(
context: ExecutionContext,
next: CallHandler,
): Observable<StellarResponse<T>> {
const network =
this.configService.get<string>('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) {

Check warning on line 49 in nftopia-backend/src/interceptors/stellar-response.interceptor.ts

View workflow job for this annotation

GitHub Actions / build-and-verify

Unsafe argument of type `any` assigned to a parameter of type `{}`
// rudimentary check
actualData = data.data;
}
}

return {
data: actualData,
meta: {
timestamp: new Date().toISOString(),
network,
...(txHash && { transactionHash: txHash }),
...(contractId && { contractId }),
},
};
}),
);
}
}
47 changes: 47 additions & 0 deletions nftopia-backend/src/interceptors/stellar-timeout.interceptor.ts
Original file line number Diff line number Diff line change
@@ -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<any> {
const defaultTimeout =
this.configService.get<number>('stellar.timeouts.rpcCall') || 30000;

const req = context.switchToHttp().getRequest();
const url = req.url;
let timeoutMs = defaultTimeout;

if (url.includes('/simulate')) {
timeoutMs =
this.configService.get<number>('stellar.timeouts.simulation') || 15000;
} else if (url.includes('/submit')) {
timeoutMs =
this.configService.get<number>('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);
}),
);
}
}
Loading