From 74223423518a2e988aa8fd6509043762172e3212 Mon Sep 17 00:00:00 2001 From: raizo07 Date: Sat, 21 Feb 2026 18:31:26 +0100 Subject: [PATCH 1/5] feat: Soroban RPC intergration --- .../src/soroban/soroban-rpc.service.ts | 248 ++++++++++++++++++ nftopia-backend/src/soroban/soroban.module.ts | 9 + 2 files changed, 257 insertions(+) create mode 100644 nftopia-backend/src/soroban/soroban-rpc.service.ts create mode 100644 nftopia-backend/src/soroban/soroban.module.ts diff --git a/nftopia-backend/src/soroban/soroban-rpc.service.ts b/nftopia-backend/src/soroban/soroban-rpc.service.ts new file mode 100644 index 0000000..911bc8a --- /dev/null +++ b/nftopia-backend/src/soroban/soroban-rpc.service.ts @@ -0,0 +1,248 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Server, Durability, Api } from 'stellar-sdk/rpc'; +import { xdr } from 'stellar-sdk'; +import { Transaction, Networks } from 'stellar-sdk'; + +/** Stroops per XLM: 1 XLM = 10^7 stroops */ +export const STROOPS_PER_XLM = 10_000_000; + +export function xlmToStroops(xlm: string): bigint { + const [whole, frac = ''] = xlm.split('.'); + const padded = frac.padEnd(7, '0').slice(0, 7); + return BigInt(whole + padded); +} + +export function stroopsToXlm(stroops: bigint | string): string { + const s = typeof stroops === 'string' ? BigInt(stroops) : stroops; + const str = s.toString().padStart(8, '0'); + const intPart = str.slice(0, -7) || '0'; + const decPart = str.slice(-7).replace(/0+$/, '') || '0'; + return decPart === '0' ? intPart : `${intPart}.${decPart}`; +} + +export interface AuctionContractError { + code: string; + message: string; +} + +export interface HighestBidResult { + bidder: string; + amountStroops: string; + amountXlm: string; + ledgerSequence?: number; +} + +@Injectable() +export class SorobanRpcService { + private readonly logger = new Logger(SorobanRpcService.name); + private readonly server: Server; + private readonly auctionContractId: string; + + constructor(private readonly configService: ConfigService) { + const rpcUrl = this.configService.get('SOROBAN_RPC_URL') ?? 'https://soroban-testnet.stellar.org'; + this.server = new Server(rpcUrl); + const contractId = this.configService.get('AUCTION_CONTRACT_ID'); + if (!contractId) { + this.logger.warn('AUCTION_CONTRACT_ID is not set. Auction contract methods will fail.'); + } + this.auctionContractId = contractId ?? ''; + } + + getServer(): Server { + return this.server; + } + + getAuctionContractId(): string { + return this.auctionContractId; + } + + /** + * Fetch contract data by key; returns the ScVal for contract data entries. + */ + async getContractData(contractId: string, key: xdr.ScVal): Promise { + try { + const data = await this.server.getContractData(contractId, key, Durability.Persistent); + const entry = data?.val; + if (!entry) return null; + const contractData = (entry as unknown as { contractData?: () => { val: () => xdr.ScVal } }).contractData?.(); + return contractData?.val?.() ?? null; + } catch (e) { + const err = e as Error; + this.logger.debug(`getContractData failed: ${err.message}`); + return null; + } + } + + /** + * Get highest bid for an auction from contract state. + * Contract storage key format is implementation-specific; override buildAuctionStateKey if needed. + */ + async getHighestBidFromContract(auctionId: string): Promise { + if (!this.auctionContractId) return null; + try { + const key = this.buildAuctionStateKey(auctionId, 'highest_bid'); + const val = await this.getContractData(this.auctionContractId, key); + if (!val) return null; + return this.parseHighestBidScVal(val); + } catch (e) { + this.logger.warn(`getHighestBidFromContract(${auctionId}): ${(e as Error).message}`); + return null; + } + } + + /** + * Get all bids from contract when exposed; otherwise use events/DB. + */ + async getBidsFromContract(_auctionId: string): Promise> { + return []; + // Contract-specific: implement when auction contract exposes a bids list. + } + + /** + * Simulate a transaction (for fee estimation and validation). + */ + async simulateTransaction(envelopeXdr: string): Promise<{ success: boolean; error?: AuctionContractError }> { + try { + const networkPassphrase = this.configService.get('NETWORK_PASSPHRASE') ?? Networks.TESTNET; + const tx = new Transaction(envelopeXdr, networkPassphrase); + const response = await this.server.simulateTransaction(tx); + if (Api.isSimulationError(response)) { + return { + success: false, + error: this.mapContractError(response.error), + }; + } + return { success: true }; + } catch (e) { + const err = e as Error; + return { + success: false, + error: { code: 'SIMULATION_FAILED', message: err.message }, + }; + } + } + + /** + * Submit a signed transaction to the network. + */ + async sendTransaction(signedEnvelopeXdr: string): Promise<{ hash: string } | { error: AuctionContractError }> { + try { + const networkPassphrase = this.configService.get('NETWORK_PASSPHRASE') ?? Networks.TESTNET; + const tx = new Transaction(signedEnvelopeXdr, networkPassphrase); + const result = await this.server.sendTransaction(tx); + if (result.errorResult) { + return { error: this.parseErrorResult(result.errorResult) }; + } + return { hash: result.hash }; + } catch (e) { + const err = e as Error; + return { + error: { code: 'SEND_FAILED', message: err.message }, + }; + } + } + + /** + * Wait for transaction confirmation by polling getTransaction. + */ + async waitForTransaction(hash: string, _timeoutLedgers = 20): Promise { + const start = Date.now(); + const timeoutMs = 60000; + while (Date.now() - start < timeoutMs) { + try { + const tx = await this.server.getTransaction(hash); + if (tx.status === Api.GetTransactionStatus.SUCCESS) return true; + if (tx.status === Api.GetTransactionStatus.FAILED || tx.status === Api.GetTransactionStatus.NOT_FOUND) return false; + } catch { + // not found yet + } + await new Promise((r) => setTimeout(r, 2000)); + } + return false; + } + + async getLatestLedger(): Promise { + try { + const response = await this.server.getLatestLedger(); + return response.sequence; + } catch (e) { + this.logger.warn(`getLatestLedger: ${(e as Error).message}`); + return 0; + } + } + + async getEvents( + startLedger: number, + contractIds: string[], + topics: string[][] = [], + ): Promise> { + try { + const response = await this.server.getEvents({ + startLedger, + filters: [{ type: 'contract', contractIds, topics }], + }); + return (response.events ?? []).map((e: Api.EventResponse) => ({ + ledger: e.ledger, + topic: (e.topic ?? []).map((t) => (typeof t === 'string' ? t : String(t))), + value: e.value, + })); + } catch (e) { + this.logger.warn(`getEvents: ${(e as Error).message}`); + return []; + } + } + + /** Build storage key for auction state (format depends on contract; extend in subclass if needed). */ + private buildAuctionStateKey(auctionId: string, suffix: string): xdr.ScVal { + const entries: xdr.ScMapEntry[] = [ + new xdr.ScMapEntry({ key: xdr.ScVal.scvSymbol('id'), val: xdr.ScVal.scvString(auctionId) }), + new xdr.ScMapEntry({ key: xdr.ScVal.scvSymbol('kind'), val: xdr.ScVal.scvSymbol(suffix) }), + ]; + return xdr.ScVal.scvMap(entries as never); + } + + private parseHighestBidScVal(val: xdr.ScVal): HighestBidResult | null { + try { + const map = val.map?.(); + if (!map || !map.length) return null; + let bidder = ''; + let amountStroops = '0'; + const raw = val as unknown as { map?: () => Array<{ key: () => unknown; val: () => unknown }> }; + const entries = raw.map?.() ?? []; + for (const entry of entries) { + const k = entry.key(); + const v = entry.val(); + const kAny = k as { switch?: () => unknown; sym?: () => { toString?: () => string } }; + const vAny = v as { switch?: () => unknown; address?: () => { toScAddress?: () => { accountId?: () => { ed25519?: () => Buffer } } }; i128?: () => { hi?: () => unknown; lo?: () => unknown } }; + const sym = kAny.sym?.()?.toString?.() ?? ''; + if (sym === 'bidder' && vAny.address) { + const addr = vAny.address(); + const accountId = addr?.toScAddress?.()?.accountId?.(); + const ed = accountId?.ed25519?.(); + bidder = ed ? 'G' + ed.toString('base64').replace(/=/g, '') : ''; + } else if ((sym === 'amount' || sym === 'value') && vAny.i128) { + const i128 = vAny.i128(); + const hi = Number(i128?.hi?.() ?? 0); + const lo = Number(i128?.lo?.() ?? 0); + amountStroops = String(BigInt(hi) << 64n | BigInt(lo)); + } + } + if (!bidder) return null; + return { bidder, amountStroops, amountXlm: stroopsToXlm(amountStroops) }; + } catch { + return null; + } + } + + private mapContractError(rpcError: unknown): AuctionContractError { + if (rpcError && typeof rpcError === 'object' && 'message' in rpcError) { + return { code: 'CONTRACT_ERROR', message: (rpcError as { message: string }).message }; + } + return { code: 'UNKNOWN', message: String(rpcError) }; + } + + private parseErrorResult(_errorResult: xdr.TransactionResult): AuctionContractError { + return { code: 'TX_FAILED', message: 'Transaction failed on ledger' }; + } +} diff --git a/nftopia-backend/src/soroban/soroban.module.ts b/nftopia-backend/src/soroban/soroban.module.ts new file mode 100644 index 0000000..0b15f66 --- /dev/null +++ b/nftopia-backend/src/soroban/soroban.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { SorobanRpcService } from './soroban-rpc.service'; + +@Global() +@Module({ + providers: [SorobanRpcService], + exports: [SorobanRpcService], +}) +export class SorobanModule {} From 15b8bb88e7a3c0789c5de0ae3d001cbc940867d1 Mon Sep 17 00:00:00 2001 From: raizo07 Date: Sat, 21 Feb 2026 18:32:06 +0100 Subject: [PATCH 2/5] feat: Stellar signature verification --- nftopia-backend/src/auth/auth.module.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/nftopia-backend/src/auth/auth.module.ts b/nftopia-backend/src/auth/auth.module.ts index ff648a5..1bf1891 100644 --- a/nftopia-backend/src/auth/auth.module.ts +++ b/nftopia-backend/src/auth/auth.module.ts @@ -5,6 +5,7 @@ import { JwtModule } from '@nestjs/jwt'; import { PassportModule } from '@nestjs/passport'; import { StellarStrategy } from './stellar.strategy'; import { JwtStrategy } from './jwt.strategy'; +import { StellarSignatureGuard } from './stellar-signature.guard'; @Module({ imports: [ @@ -15,7 +16,7 @@ import { JwtStrategy } from './jwt.strategy'; }), ], controllers: [AuthController], - providers: [AuthService, StellarStrategy, JwtStrategy], - exports: [AuthService, JwtStrategy], + providers: [AuthService, StellarStrategy, JwtStrategy, StellarSignatureGuard], + exports: [AuthService, JwtStrategy, StellarSignatureGuard], }) export class AuthModule {} From 72f416bf027b5e56f01057a78789c245e2a12bb2 Mon Sep 17 00:00:00 2001 From: raizo07 Date: Sat, 21 Feb 2026 19:33:46 +0100 Subject: [PATCH 3/5] feat: TypeORM entities and DTOs --- nftopia-backend/src/bids/bids.controller.ts | 65 ++++ nftopia-backend/src/bids/bids.gateway.ts | 47 +++ nftopia-backend/src/bids/bids.module.ts | 22 ++ nftopia-backend/src/bids/bids.service.spec.ts | 148 +++++++++ nftopia-backend/src/bids/bids.service.ts | 283 ++++++++++++++++++ nftopia-backend/src/bids/dto/bid-query.dto.ts | 17 ++ nftopia-backend/src/bids/dto/place-bid.dto.ts | 29 ++ .../src/bids/entities/auction.entity.ts | 51 ++++ .../src/bids/entities/bid.entity.ts | 43 +++ .../src/bids/listeners/bid-event.listener.ts | 102 +++++++ 10 files changed, 807 insertions(+) create mode 100644 nftopia-backend/src/bids/bids.controller.ts create mode 100644 nftopia-backend/src/bids/bids.gateway.ts create mode 100644 nftopia-backend/src/bids/bids.module.ts create mode 100644 nftopia-backend/src/bids/bids.service.spec.ts create mode 100644 nftopia-backend/src/bids/bids.service.ts create mode 100644 nftopia-backend/src/bids/dto/bid-query.dto.ts create mode 100644 nftopia-backend/src/bids/dto/place-bid.dto.ts create mode 100644 nftopia-backend/src/bids/entities/auction.entity.ts create mode 100644 nftopia-backend/src/bids/entities/bid.entity.ts create mode 100644 nftopia-backend/src/bids/listeners/bid-event.listener.ts diff --git a/nftopia-backend/src/bids/bids.controller.ts b/nftopia-backend/src/bids/bids.controller.ts new file mode 100644 index 0000000..f6b970e --- /dev/null +++ b/nftopia-backend/src/bids/bids.controller.ts @@ -0,0 +1,65 @@ +import { + Controller, + Post, + Get, + Body, + Param, + Query, + UseGuards, + Request, +} from '@nestjs/common'; +import { BidsService } from './bids.service'; +import { PlaceBidDto } from './dto/place-bid.dto'; +import { BidQueryDto } from './dto/bid-query.dto'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { StellarSignatureGuard } from '../auth/stellar-signature.guard'; + +interface RequestWithUser extends Request { + user?: { publicKey: string }; + signedBidPublicKey?: string; +} + +@Controller('bids') +export class BidsController { + constructor(private readonly bidsService: BidsService) {} + + @Post(':auctionId') + @UseGuards(JwtAuthGuard, StellarSignatureGuard) + async placeBid( + @Param('auctionId') auctionId: string, + @Body() dto: PlaceBidDto, + @Request() req: RequestWithUser, + ) { + const publicKey = req.signedBidPublicKey ?? req.user?.publicKey; + if (!publicKey) { + throw new Error('Missing bidder public key'); + } + return this.bidsService.placeBid(auctionId, dto, publicKey); + } + + @Get('auction/:auctionId') + async getBidsByAuction( + @Param('auctionId') auctionId: string, + @Query() query: BidQueryDto, + ) { + return this.bidsService.getBidsByAuction(auctionId, query); + } + + @Get('highest/:auctionId') + async getHighestBid(@Param('auctionId') auctionId: string) { + return this.bidsService.getHighestBid(auctionId); + } + + @Get('my/:auctionId') + @UseGuards(JwtAuthGuard) + async getMyBids( + @Param('auctionId') auctionId: string, + @Request() req: RequestWithUser, + ) { + const publicKey = req.user?.publicKey; + if (!publicKey) { + throw new Error('Unauthorized'); + } + return this.bidsService.getMyBids(auctionId, publicKey); + } +} diff --git a/nftopia-backend/src/bids/bids.gateway.ts b/nftopia-backend/src/bids/bids.gateway.ts new file mode 100644 index 0000000..546b34b --- /dev/null +++ b/nftopia-backend/src/bids/bids.gateway.ts @@ -0,0 +1,47 @@ +import { + WebSocketGateway, + WebSocketServer, + SubscribeMessage, +} from '@nestjs/websockets'; +import { Logger } from '@nestjs/common'; + +/** Socket.IO server type (from @nestjs/platform-socket.io). */ +interface SocketIoServer { + to(room: string): { emit: (event: string, payload: unknown) => void }; +} + +export const BIDS_NAMESPACE = '/bids'; + +export interface NewBidPayload { + id: string; + auctionId: string; + bidderPublicKey: string; + amountXlm: string; + amountStroops: string; + transactionHash: string; + ledgerSequence: number; + createdAt: Date; +} + +@WebSocketGateway({ + namespace: BIDS_NAMESPACE, + cors: { origin: '*' }, +}) +export class BidsGateway { + @WebSocketServer() + server!: SocketIoServer; + + private readonly logger = new Logger(BidsGateway.name); + + broadcastNewBid(auctionId: string, payload: NewBidPayload): void { + this.server?.to(`auction:${auctionId}`).emit('bid_placed', payload); + this.logger.debug(`Emitted bid_placed for auction ${auctionId}`); + } + + @SubscribeMessage('subscribe_auction') + handleSubscribe(client: { join: (room: string) => void }, payload: { auctionId: string }): void { + if (payload?.auctionId) { + client.join(`auction:${payload.auctionId}`); + } + } +} diff --git a/nftopia-backend/src/bids/bids.module.ts b/nftopia-backend/src/bids/bids.module.ts new file mode 100644 index 0000000..19cf43d --- /dev/null +++ b/nftopia-backend/src/bids/bids.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { BidsController } from './bids.controller'; +import { BidsService } from './bids.service'; +import { Bid } from './entities/bid.entity'; +import { Auction } from './entities/auction.entity'; +import { BidsGateway } from './bids.gateway'; +import { BidEventListener } from './listeners/bid-event.listener'; +import { SorobanModule } from '../soroban/soroban.module'; +import { AuthModule } from '../auth/auth.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Bid, Auction]), + SorobanModule, + AuthModule, + ], + controllers: [BidsController], + providers: [BidsService, BidsGateway, BidEventListener], + exports: [BidsService], +}) +export class BidsModule {} diff --git a/nftopia-backend/src/bids/bids.service.spec.ts b/nftopia-backend/src/bids/bids.service.spec.ts new file mode 100644 index 0000000..830dead --- /dev/null +++ b/nftopia-backend/src/bids/bids.service.spec.ts @@ -0,0 +1,148 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { BidsService } from './bids.service'; +import { Bid } from './entities/bid.entity'; +import { Auction } from './entities/auction.entity'; +import { SorobanRpcService } from '../soroban/soroban-rpc.service'; +import { ConfigService } from '@nestjs/config'; +import { NotFoundException, BadRequestException, ForbiddenException } from '@nestjs/common'; + +describe('BidsService', () => { + let service: BidsService; + let auctionRepo: { findOne: jest.Mock }; + let bidRepo: { findOne: jest.Mock; create: jest.Mock; save: jest.Mock; createQueryBuilder: jest.Mock }; + let sorobanRpc: { + getHighestBidFromContract: jest.Mock; + simulateTransaction: jest.Mock; + sendTransaction: jest.Mock; + waitForTransaction: jest.Mock; + getLatestLedger: jest.Mock; + }; + let cache: { get: jest.Mock; set: jest.Mock; del: jest.Mock }; + + const mockAuction: Partial = { + auctionId: 'auction-1', + sellerPublicKey: 'GSELLER', + status: 'Active', + minIncrement: '0.05', + reservePriceXlm: '10', + }; + + beforeEach(async () => { + auctionRepo = { findOne: jest.fn() }; + bidRepo = { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + createQueryBuilder: jest.fn(() => ({ + where: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([]), + })), + }; + sorobanRpc = { + getHighestBidFromContract: jest.fn().mockResolvedValue(null), + simulateTransaction: jest.fn().mockResolvedValue({ success: true }), + sendTransaction: jest.fn().mockResolvedValue({ hash: 'tx-hash' }), + waitForTransaction: jest.fn().mockResolvedValue(true), + getLatestLedger: jest.fn().mockResolvedValue(12345), + }; + cache = { get: jest.fn().mockResolvedValue(undefined), set: jest.fn(), del: jest.fn() }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + BidsService, + { provide: getRepositoryToken(Bid), useValue: bidRepo }, + { provide: getRepositoryToken(Auction), useValue: auctionRepo }, + { provide: CACHE_MANAGER, useValue: cache }, + { provide: SorobanRpcService, useValue: sorobanRpc }, + { + provide: ConfigService, + useValue: { get: jest.fn((key: string) => (key === 'HORIZON_URL' ? 'https://horizon-testnet.stellar.org' : undefined)) }, + }, + ], + }).compile(); + + service = module.get(BidsService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('getHighestBid', () => { + it('should return from cache when present', async () => { + const cached = { bidder: 'GXXX', amountStroops: '10000000', amountXlm: '1' }; + cache.get.mockResolvedValue(cached); + const result = await service.getHighestBid('auction-1'); + expect(result).toEqual(cached); + expect(sorobanRpc.getHighestBidFromContract).not.toHaveBeenCalled(); + }); + + it('should fallback to DB when contract returns null', async () => { + cache.get.mockResolvedValue(undefined); + sorobanRpc.getHighestBidFromContract.mockResolvedValue(null); + bidRepo.findOne.mockResolvedValue({ + auctionId: 'auction-1', + bidderPublicKey: 'GBIDDER', + amountStroops: '50000000', + amountXlm: '5', + ledgerSequence: 100, + }); + const result = await service.getHighestBid('auction-1'); + expect(result).not.toBeNull(); + expect(result?.bidder).toBe('GBIDDER'); + expect(result?.amountXlm).toBe('5'); + }); + }); + + describe('placeBid', () => { + it('should throw NotFoundException when auction does not exist', async () => { + auctionRepo.findOne.mockResolvedValue(null); + await expect( + service.placeBid('missing', { amount: '10', signature: 'sig', publicKey: 'GBIDDER', signedTransactionXdr: 'xdr' }, 'GBIDDER'), + ).rejects.toThrow(NotFoundException); + }); + + it('should throw BadRequestException when auction is not Active', async () => { + auctionRepo.findOne.mockResolvedValue({ ...mockAuction, status: 'Ended' }); + await expect( + service.placeBid('auction-1', { amount: '10', signature: 'sig', publicKey: 'GBIDDER', signedTransactionXdr: 'xdr' }, 'GBIDDER'), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw ForbiddenException when bidding on own auction', async () => { + auctionRepo.findOne.mockResolvedValue(mockAuction); + await expect( + service.placeBid('auction-1', { amount: '10', signature: 'sig', publicKey: 'GSELLER', signedTransactionXdr: 'xdr' }, 'GSELLER'), + ).rejects.toThrow(ForbiddenException); + }); + + it('should throw BadRequestException when signedTransactionXdr is missing', async () => { + auctionRepo.findOne.mockResolvedValue(mockAuction); + await expect( + service.placeBid('auction-1', { amount: '10', signature: 'sig', publicKey: 'GBIDDER' }, 'GBIDDER'), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('getBidsByAuction', () => { + it('should return items and nextCursor', async () => { + bidRepo.createQueryBuilder = jest.fn(() => ({ + where: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([ + { id: '1', auctionId: 'auction-1', bidderPublicKey: 'G1', amountXlm: '1', amountStroops: '10000000', transactionHash: 'h1', ledgerSequence: 10, createdAt: new Date() }, + ]), + })); + const result = await service.getBidsByAuction('auction-1', { limit: 20 }); + expect(result.items).toHaveLength(1); + expect(result.items[0].bidderPublicKey).toBe('G1'); + }); + }); +}); diff --git a/nftopia-backend/src/bids/bids.service.ts b/nftopia-backend/src/bids/bids.service.ts new file mode 100644 index 0000000..a21ce1b --- /dev/null +++ b/nftopia-backend/src/bids/bids.service.ts @@ -0,0 +1,283 @@ +import { + Injectable, + Logger, + BadRequestException, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Inject } from '@nestjs/common'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import type { Cache } from 'cache-manager'; +import { Bid } from './entities/bid.entity'; +import { Auction } from './entities/auction.entity'; +import { PlaceBidDto } from './dto/place-bid.dto'; +import { BidQueryDto } from './dto/bid-query.dto'; +import { + SorobanRpcService, + xlmToStroops, + stroopsToXlm, + HighestBidResult, +} from '../soroban/soroban-rpc.service'; +import { ConfigService } from '@nestjs/config'; +import { Transaction, Networks } from 'stellar-sdk'; + +const HIGHEST_BID_CACHE_TTL_MS = 30_000; +const RATE_LIMIT_BIDS_PER_MINUTE = 5; +const RATE_LIMIT_WINDOW_MS = 60_000; + +@Injectable() +export class BidsService { + private readonly logger = new Logger(BidsService.name); + private readonly horizonUrl: string; + private readonly networkPassphrase: string; + /** In-memory rate limit: publicKey -> timestamps of recent bid attempts */ + private readonly rateLimitMap = new Map(); + + constructor( + @InjectRepository(Bid) private readonly bidRepo: Repository, + @InjectRepository(Auction) private readonly auctionRepo: Repository, + @Inject(CACHE_MANAGER) private readonly cache: Cache, + private readonly sorobanRpc: SorobanRpcService, + private readonly configService: ConfigService, + ) { + this.horizonUrl = + this.configService.get('HORIZON_URL') ?? 'https://horizon-testnet.stellar.org'; + this.networkPassphrase = + this.configService.get('NETWORK_PASSPHRASE') ?? Networks.TESTNET; + } + + async placeBid( + auctionId: string, + dto: PlaceBidDto, + bidderPublicKey: string, + ): Promise<{ transactionHash: string; ledgerSequence?: number }> { + const auction = await this.auctionRepo.findOne({ where: { auctionId } }); + if (!auction) { + throw new NotFoundException('Auction not found'); + } + if (auction.status !== 'Active') { + throw new BadRequestException('Auction is not active'); + } + if (auction.sellerPublicKey === bidderPublicKey) { + throw new ForbiddenException('Cannot bid on your own auction'); + } + + this.enforceRateLimit(bidderPublicKey); + + const amountXlm = dto.amount; + const amountStroops = xlmToStroops(amountXlm); + + const highest = await this.getHighestBid(auctionId); + const minAmountStroops = this.computeMinNextBidStroops( + highest?.amountStroops ?? '0', + auction.minIncrement, + ); + if (BigInt(amountStroops.toString()) < BigInt(minAmountStroops)) { + throw new BadRequestException( + `Bid must be at least ${stroopsToXlm(minAmountStroops)} XLM (min increment)`, + ); + } + + await this.checkSufficientBalance(bidderPublicKey, amountXlm); + + if (!dto.signedTransactionXdr) { + throw new BadRequestException( + 'signedTransactionXdr is required. Sign the place_bid transaction with your wallet and include it in the request.', + ); + } + + const tx = this.parseAndVerifySignedTransaction( + dto.signedTransactionXdr, + bidderPublicKey, + auctionId, + amountStroops.toString(), + ); + + const sim = await this.sorobanRpc.simulateTransaction(dto.signedTransactionXdr); + if (!sim.success) { + throw new BadRequestException(sim.error?.message ?? 'Transaction simulation failed'); + } + + const result = await this.sorobanRpc.sendTransaction(dto.signedTransactionXdr); + if ('error' in result) { + throw new BadRequestException(result.error.message); + } + + const confirmed = await this.sorobanRpc.waitForTransaction(result.hash); + const ledgerSequence = confirmed ? await this.sorobanRpc.getLatestLedger() : undefined; + + await this.invalidateHighestBidCache(auctionId); + + return { transactionHash: result.hash, ledgerSequence }; + } + + async getBidsByAuction(auctionId: string, query: BidQueryDto) { + const limit = Math.min(query.limit ?? 20, 100); + const qb = this.bidRepo + .createQueryBuilder('b') + .where('b.auctionId = :auctionId', { auctionId }) + .orderBy('b.ledgerSequence', 'DESC') + .take(limit); + + if (query.cursor != null && query.cursor > 0) { + qb.andWhere('b.ledgerSequence < :cursor', { cursor: query.cursor }); + } + + const items = await qb.getMany(); + const nextCursor = + items.length === limit && items.length > 0 ? items[items.length - 1].ledgerSequence : null; + return { + items: items.map((b) => ({ + id: b.id, + auctionId: b.auctionId, + bidderPublicKey: b.bidderPublicKey, + amountXlm: b.amountXlm, + amountStroops: b.amountStroops, + transactionHash: b.transactionHash, + ledgerSequence: b.ledgerSequence, + createdAt: b.createdAt, + })), + nextCursor, + }; + } + + async getHighestBid(auctionId: string): Promise { + const cacheKey = `bids:highest:${auctionId}`; + const cached = await this.cache.get(cacheKey); + if (cached) return cached; + + const fromContract = await this.sorobanRpc.getHighestBidFromContract(auctionId); + if (fromContract) { + await this.cache.set(cacheKey, fromContract, HIGHEST_BID_CACHE_TTL_MS); + return fromContract; + } + + const fromDb = await this.bidRepo.findOne({ + where: { auctionId }, + order: { ledgerSequence: 'DESC' }, + }); + if (!fromDb) return null; + const result: HighestBidResult = { + bidder: fromDb.bidderPublicKey, + amountStroops: fromDb.amountStroops, + amountXlm: fromDb.amountXlm, + ledgerSequence: fromDb.ledgerSequence, + }; + await this.cache.set(cacheKey, result, HIGHEST_BID_CACHE_TTL_MS); + return result; + } + + async getMyBids(auctionId: string, userPublicKey: string) { + return this.bidRepo.find({ + where: { auctionId, bidderPublicKey: userPublicKey }, + order: { ledgerSequence: 'DESC' }, + }); + } + + async upsertBidFromEvent(data: { + auctionId: string; + bidderPublicKey: string; + amountStroops: string; + transactionHash: string; + ledgerSequence: number; + }): Promise { + const amountXlm = stroopsToXlm(data.amountStroops); + let bid = await this.bidRepo.findOne({ + where: { + auctionId: data.auctionId, + transactionHash: data.transactionHash, + }, + }); + if (bid) return bid; + bid = this.bidRepo.create({ + auctionId: data.auctionId, + bidderPublicKey: data.bidderPublicKey, + amountXlm, + amountStroops: data.amountStroops, + transactionHash: data.transactionHash, + ledgerSequence: data.ledgerSequence, + }); + return this.bidRepo.save(bid); + } + + async invalidateHighestBidCache(auctionId: string): Promise { + await this.cache.del(`bids:highest:${auctionId}`); + } + + private enforceRateLimit(publicKey: string): void { + const now = Date.now(); + let timestamps = this.rateLimitMap.get(publicKey) ?? []; + timestamps = timestamps.filter((t) => now - t < RATE_LIMIT_WINDOW_MS); + if (timestamps.length >= RATE_LIMIT_BIDS_PER_MINUTE) { + throw new BadRequestException('Rate limit exceeded: maximum 5 bids per minute'); + } + timestamps.push(now); + this.rateLimitMap.set(publicKey, timestamps); + } + + private computeMinNextBidStroops( + currentHighestStroops: string, + minIncrement: string, + ): string { + const current = BigInt(currentHighestStroops); + if (current === 0n) return currentHighestStroops; + const inc = parseFloat(minIncrement); + if (inc <= 0) return (current + 1n).toString(); + if (inc <= 1) { + const next = (current * BigInt(Math.ceil(inc * 1e7)) + 9999999n) / 10000000n; + return next.toString(); + } + const fixedStroops = xlmToStroops(minIncrement.toString()); + return (current + BigInt(fixedStroops.toString())).toString(); + } + + private async checkSufficientBalance(publicKey: string, amountXlm: string): Promise { + try { + const res = await fetch(`${this.horizonUrl}/accounts/${publicKey}`); + if (!res.ok) { + this.logger.warn(`Horizon account fetch failed: ${res.status}`); + return; + } + const acc = (await res.json()) as { balances?: Array< { balance: string; asset_type: string }> }; + const xlm = acc.balances?.find((b) => b.asset_type === 'native'); + if (!xlm) { + throw new BadRequestException('Insufficient XLM balance'); + } + const balance = parseFloat(xlm.balance); + const required = parseFloat(amountXlm); + if (balance < required) { + throw new BadRequestException('Insufficient XLM balance'); + } + } catch (e) { + if (e instanceof BadRequestException) throw e; + this.logger.warn(`Balance check failed: ${(e as Error).message}`); + } + } + + private parseAndVerifySignedTransaction( + envelopeXdr: string, + expectedSource: string, + expectedAuctionId: string, + expectedAmountStroops: string, + ): Transaction { + let tx: Transaction; + try { + tx = new Transaction(envelopeXdr, this.networkPassphrase); + } catch { + throw new BadRequestException('Invalid transaction XDR'); + } + if (tx.source !== expectedSource) { + throw new BadRequestException('Transaction source does not match signing key'); + } + if (!tx.signatures?.length) { + throw new BadRequestException('Transaction is not signed'); + } + return tx; + } + + async findAuction(auctionId: string): Promise { + return this.auctionRepo.findOne({ where: { auctionId } }); + } +} diff --git a/nftopia-backend/src/bids/dto/bid-query.dto.ts b/nftopia-backend/src/bids/dto/bid-query.dto.ts new file mode 100644 index 0000000..c6408d6 --- /dev/null +++ b/nftopia-backend/src/bids/dto/bid-query.dto.ts @@ -0,0 +1,17 @@ +import { IsOptional, IsInt, Min, Max } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class BidQueryDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + limit?: number = 20; + + /** Cursor: ledger sequence for pagination (return bids with ledgerSequence < cursor). */ + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + cursor?: number; +} diff --git a/nftopia-backend/src/bids/dto/place-bid.dto.ts b/nftopia-backend/src/bids/dto/place-bid.dto.ts new file mode 100644 index 0000000..b58e080 --- /dev/null +++ b/nftopia-backend/src/bids/dto/place-bid.dto.ts @@ -0,0 +1,29 @@ +import { IsNotEmpty, IsString, IsOptional, Matches } from 'class-validator'; + +/** + * Body for POST /bids/:auctionId. + * Signature must be Ed25519 (base64) over buildBidMessage(auctionId, amount, timestamp). + */ +export class PlaceBidDto { + @IsNotEmpty() + @IsString() + amount: string; + + @IsNotEmpty() + @IsString() + signature: string; + + @IsNotEmpty() + @IsString() + @Matches(/^G[A-Z2-7]{55}$/, { message: 'publicKey must be a valid Stellar G... address' }) + publicKey: string; + + @IsOptional() + @IsString() + timestamp?: string; + + /** Signed Soroban transaction XDR (place_bid). When provided, backend verifies and submits. */ + @IsOptional() + @IsString() + signedTransactionXdr?: string; +} diff --git a/nftopia-backend/src/bids/entities/auction.entity.ts b/nftopia-backend/src/bids/entities/auction.entity.ts new file mode 100644 index 0000000..7adab5e --- /dev/null +++ b/nftopia-backend/src/bids/entities/auction.entity.ts @@ -0,0 +1,51 @@ +import { + Entity, + Column, + PrimaryColumn, + CreateDateColumn, + UpdateDateColumn, + Index, + OneToMany, +} from 'typeorm'; +import { Bid } from './bid.entity'; + +export type AuctionStatus = 'Active' | 'Ended' | 'Cancelled'; + +@Entity('auctions') +@Index(['status']) +@Index(['sellerPublicKey']) +export class Auction { + @PrimaryColumn() + auctionId: string; + + @Column() + sellerPublicKey: string; + + @Column() + nftContractId: string; + + @Column() + tokenId: string; + + @Column({ type: 'varchar', length: 20, default: 'Active' }) + status: AuctionStatus; + + @Column({ type: 'decimal', precision: 20, scale: 7, default: 0 }) + reservePriceXlm: string; + + /** Minimum increment (e.g. 0.05 for 5% or fixed XLM); stored as decimal. */ + @Column({ type: 'decimal', precision: 20, scale: 7, default: 0.05 }) + minIncrement: string; + + @Column({ type: 'timestamptz', nullable: true }) + endTime: Date | null; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + @OneToMany(() => Bid, (bid) => bid.auction) + bids: Bid[]; +} diff --git a/nftopia-backend/src/bids/entities/bid.entity.ts b/nftopia-backend/src/bids/entities/bid.entity.ts new file mode 100644 index 0000000..8ab35ba --- /dev/null +++ b/nftopia-backend/src/bids/entities/bid.entity.ts @@ -0,0 +1,43 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Auction } from './auction.entity'; + +@Entity('bids') +@Index(['auctionId', 'ledgerSequence']) +@Index(['bidderPublicKey', 'auctionId']) +export class Bid { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + auctionId: string; + + @Column() + bidderPublicKey: string; + + @Column({ type: 'decimal', precision: 20, scale: 7 }) + amountXlm: string; + + @Column({ type: 'varchar', length: 32 }) + amountStroops: string; + + @Column() + transactionHash: string; + + @Column({ type: 'int', default: 0 }) + ledgerSequence: number; + + @CreateDateColumn() + createdAt: Date; + + @ManyToOne(() => Auction, (auction) => auction.bids, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'auctionId', referencedColumnName: 'auctionId' }) + auction: Auction; +} diff --git a/nftopia-backend/src/bids/listeners/bid-event.listener.ts b/nftopia-backend/src/bids/listeners/bid-event.listener.ts new file mode 100644 index 0000000..41663f4 --- /dev/null +++ b/nftopia-backend/src/bids/listeners/bid-event.listener.ts @@ -0,0 +1,102 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { SorobanRpcService } from '../../soroban/soroban-rpc.service'; +import { BidsService } from '../bids.service'; +import { BidsGateway } from '../bids.gateway'; +import { ConfigService } from '@nestjs/config'; + +/** Topic for BidPlaced events (contract-specific; adjust to match your auction contract). */ +const BID_PLACED_TOPIC = 'BidPlaced'; + +@Injectable() +export class BidEventListener implements OnModuleInit { + private readonly logger = new Logger(BidEventListener.name); + private lastLedger = 0; + private pollIntervalMs = 5000; + + constructor( + private readonly sorobanRpc: SorobanRpcService, + private readonly bidsService: BidsService, + private readonly bidsGateway: BidsGateway, + private readonly configService: ConfigService, + ) { + const contractId = this.configService.get('AUCTION_CONTRACT_ID'); + if (!contractId) { + this.logger.warn('AUCTION_CONTRACT_ID not set; bid event listener will no-op'); + } + } + + async onModuleInit() { + this.lastLedger = await this.sorobanRpc.getLatestLedger(); + this.schedulePoll(); + } + + private schedulePoll() { + setInterval(() => this.poll(), this.pollIntervalMs); + } + + private async poll() { + const contractId = this.sorobanRpc.getAuctionContractId(); + if (!contractId) return; + + const latest = await this.sorobanRpc.getLatestLedger(); + if (latest <= this.lastLedger) return; + + const events = await this.sorobanRpc.getEvents( + this.lastLedger + 1, + [contractId], + [[BID_PLACED_TOPIC]], + ); + + for (const ev of events) { + const parsed = this.parseBidPlacedEvent(ev); + if (parsed) { + try { + const bid = await this.bidsService.upsertBidFromEvent(parsed); + await this.bidsService.invalidateHighestBidCache(parsed.auctionId); + this.bidsGateway.broadcastNewBid(parsed.auctionId, { + id: bid.id, + auctionId: bid.auctionId, + bidderPublicKey: bid.bidderPublicKey, + amountXlm: bid.amountXlm, + amountStroops: bid.amountStroops, + transactionHash: bid.transactionHash, + ledgerSequence: bid.ledgerSequence, + createdAt: bid.createdAt, + }); + } catch (e) { + this.logger.warn(`Failed to index BidPlaced event: ${(e as Error).message}`); + } + } + } + + this.lastLedger = latest; + } + + private parseBidPlacedEvent(ev: { + ledger: number; + topic: string[]; + value: unknown; + }): { + auctionId: string; + bidderPublicKey: string; + amountStroops: string; + transactionHash: string; + ledgerSequence: number; + } | null { + try { + const value = ev.value as { body?: { xdr?: string } }; + const bodyXdr = value?.body?.xdr ?? (ev as { value?: { xdr?: string } }).value?.xdr; + if (!bodyXdr) return null; + const ledger = ev.ledger || 0; + return { + auctionId: '', // Decode from event body XDR if available + bidderPublicKey: '', + amountStroops: '0', + transactionHash: '', + ledgerSequence: ledger, + }; + } catch { + return null; + } + } +} From 89ce985f37765ed0fc3bbef254f3eed7875b8419 Mon Sep 17 00:00:00 2001 From: raizo07 Date: Sat, 21 Feb 2026 19:35:59 +0100 Subject: [PATCH 4/5] feat: Soroban-Integrated BidModule to handle NFT auction bidding functionality --- nftopia-backend/package.json | 3 +- nftopia-backend/src/app.module.ts | 4 + .../src/auth/stellar-signature.guard.ts | 74 +++++++++++++++++++ nftopia-backend/src/main.ts | 2 + nftopia-backend/tsconfig.json | 3 +- 5 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 nftopia-backend/src/auth/stellar-signature.guard.ts diff --git a/nftopia-backend/package.json b/nftopia-backend/package.json index 2c8deb7..5d94150 100644 --- a/nftopia-backend/package.json +++ b/nftopia-backend/package.json @@ -27,10 +27,11 @@ "@nestjs/jwt": "^11.0.2", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", + "@nestjs/platform-socket.io": "^11.1.14", "@nestjs/schedule": "^6.1.1", "@nestjs/swagger": "^11.2.5", "@nestjs/typeorm": "^11.0.0", - "@types/cron": "^2.0.1", + "@nestjs/websockets": "^11.1.14", "cache-manager": "^7.2.8", "cache-manager-redis-store": "^3.0.1", "class-transformer": "^0.5.1", diff --git a/nftopia-backend/src/app.module.ts b/nftopia-backend/src/app.module.ts index db9933f..3ac54e1 100644 --- a/nftopia-backend/src/app.module.ts +++ b/nftopia-backend/src/app.module.ts @@ -8,6 +8,8 @@ import { AppService } from './app.service'; import { AuthModule } from './auth/auth.module'; import { UsersModule } from './users/users.module'; import { NftModule } from './nft/nft.module'; +import { SorobanModule } from './soroban/soroban.module'; +import { BidsModule } from './bids/bids.module'; import { LoggerModule } from 'nestjs-pino'; import { APP_FILTER } from '@nestjs/core'; import { HttpExceptionFilter } from './common/filters/http-exception.filter'; @@ -69,6 +71,8 @@ import { HttpExceptionFilter } from './common/filters/http-exception.filter'; }), UsersModule, ]), + SorobanModule, + BidsModule, NftModule, ], controllers: [AppController], diff --git a/nftopia-backend/src/auth/stellar-signature.guard.ts b/nftopia-backend/src/auth/stellar-signature.guard.ts new file mode 100644 index 0000000..59991b5 --- /dev/null +++ b/nftopia-backend/src/auth/stellar-signature.guard.ts @@ -0,0 +1,74 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { Request } from 'express'; +import { Keypair } from 'stellar-sdk'; + +/** + * Canonical message format for bid authorization: prevents replay and binds amount to auction. + * Frontend must sign this exact string (e.g. with Freighter/xBull signing a message). + */ +export function buildBidMessage(auctionId: string, amount: string, timestamp: string): string { + return `bid:${auctionId}:${amount}:${timestamp}`; +} + +export interface SignedBidPayload { + amount: string; + signature: string; // base64-encoded Ed25519 signature of the bid message + publicKey: string; // G... + timestamp?: string; // ISO or epoch ms; used in message; if omitted, no timestamp in message +} + +@Injectable() +export class StellarSignatureGuard implements CanActivate { + /** Max age of timestamp (ms); if payload includes timestamp, it must be within this window. */ + private static readonly MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const body = request.body as SignedBidPayload; + const auctionId = (request.params as { auctionId?: string }).auctionId; + + if (!auctionId || !body?.amount || !body?.signature || !body?.publicKey) { + throw new UnauthorizedException( + 'Missing bid signature data: amount, signature, and publicKey are required', + ); + } + + const message = body.timestamp + ? buildBidMessage(auctionId, body.amount, body.timestamp) + : buildBidMessage(auctionId, body.amount, ''); + + if (body.timestamp) { + const ts = Number(body.timestamp); + if (Number.isNaN(ts) || Date.now() - ts > StellarSignatureGuard.MAX_AGE_MS) { + throw new UnauthorizedException('Bid signature timestamp expired or invalid'); + } + } + + let signatureBuffer: Buffer; + try { + signatureBuffer = Buffer.from(body.signature, 'base64'); + } catch { + throw new UnauthorizedException('Invalid signature encoding'); + } + + try { + const keypair = Keypair.fromPublicKey(body.publicKey); + const messageBuffer = Buffer.from(message, 'utf8'); + const valid = keypair.verify(messageBuffer, signatureBuffer); + if (!valid) { + throw new UnauthorizedException('Invalid Stellar signature for bid'); + } + } catch (e) { + if (e instanceof UnauthorizedException) throw e; + throw new UnauthorizedException('Stellar signature verification failed'); + } + + (request as Request & { signedBidPublicKey: string }).signedBidPublicKey = body.publicKey; + return true; + } +} diff --git a/nftopia-backend/src/main.ts b/nftopia-backend/src/main.ts index 6974fd9..e9ebea6 100644 --- a/nftopia-backend/src/main.ts +++ b/nftopia-backend/src/main.ts @@ -4,9 +4,11 @@ import { AppModule } from './app.module'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { ValidationPipe } from '@nestjs/common'; import { Logger } from 'nestjs-pino'; +import { IoAdapter } from '@nestjs/platform-socket.io'; async function bootstrap() { const app = await NestFactory.create(AppModule); + app.useWebSocketAdapter(new IoAdapter(app)); app.useLogger(app.get(Logger)); // Enable CORS to allow requests from frontend diff --git a/nftopia-backend/tsconfig.json b/nftopia-backend/tsconfig.json index aba29b0..29fdc48 100644 --- a/nftopia-backend/tsconfig.json +++ b/nftopia-backend/tsconfig.json @@ -20,6 +20,7 @@ "forceConsistentCasingInFileNames": true, "noImplicitAny": false, "strictBindCallApply": false, - "noFallthroughCasesInSwitch": false + "noFallthroughCasesInSwitch": false, + "types": ["node"] } } From 36f586d987981f5a96f7370fda18dac820ef54b6 Mon Sep 17 00:00:00 2001 From: raizo07 Date: Sun, 22 Feb 2026 12:38:10 +0100 Subject: [PATCH 5/5] fix: resolve dependency issue --- nftopia-backend/package-lock.json | 255 +++++++++++++++++++++++++++++- 1 file changed, 249 insertions(+), 6 deletions(-) diff --git a/nftopia-backend/package-lock.json b/nftopia-backend/package-lock.json index c5aee51..4d3d23f 100644 --- a/nftopia-backend/package-lock.json +++ b/nftopia-backend/package-lock.json @@ -16,10 +16,11 @@ "@nestjs/jwt": "^11.0.2", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", + "@nestjs/platform-socket.io": "^11.1.14", "@nestjs/schedule": "^6.1.1", "@nestjs/swagger": "^11.2.5", "@nestjs/typeorm": "^11.0.0", - "@types/cron": "^2.0.1", + "@nestjs/websockets": "^11.1.14", "cache-manager": "^7.2.8", "cache-manager-redis-store": "^3.0.1", "class-transformer": "^0.5.1", @@ -2347,6 +2348,25 @@ "@nestjs/core": "^11.0.0" } }, + "node_modules/@nestjs/platform-socket.io": { + "version": "11.1.14", + "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-11.1.14.tgz", + "integrity": "sha512-LLSIWkYz4FcvUhfepillYQboo9qbjq1YtQj8XC3zyex+EaqNXvxhZntx/1uJhAjc655pJts9HfZwWXei8jrRGw==", + "license": "MIT", + "dependencies": { + "socket.io": "4.8.3", + "tslib": "2.8.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/websockets": "^11.0.0", + "rxjs": "^7.1.0" + } + }, "node_modules/@nestjs/schedule": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-6.1.1.tgz", @@ -2538,6 +2558,29 @@ "typeorm": "^0.3.0" } }, + "node_modules/@nestjs/websockets": { + "version": "11.1.14", + "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.1.14.tgz", + "integrity": "sha512-fVP6RmmrmtLIitTXN9er7BUOIjjxcdIewN/zUtBlwgfng+qKBTxpNFOs3AXXbCu8bQr2xjzhjrBTfqri0Ske7w==", + "license": "MIT", + "dependencies": { + "iterare": "1.2.1", + "object-hash": "3.0.0", + "tslib": "2.8.1" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/core": "^11.0.0", + "@nestjs/platform-socket.io": "^11.0.0", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "@nestjs/platform-socket.io": { + "optional": true + } + } + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -2705,6 +2748,12 @@ "@sinonjs/commons": "^3.0.1" } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@sqltools/formatter": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.5.tgz", @@ -2914,13 +2963,12 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/cron": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@types/cron/-/cron-2.0.1.tgz", - "integrity": "sha512-WHa/1rtNtD2Q/H0+YTTZoty+/5rcE66iAFX2IY+JuUoOACsevYyFkSYu/2vdw+G5LrmO7Lxowrqm0av4k3qWNQ==", + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", "license": "MIT", "dependencies": { - "@types/luxon": "*", "@types/node": "*" } }, @@ -4384,6 +4432,15 @@ ], "license": "MIT" }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.9.18", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.18.tgz", @@ -5408,6 +5465,78 @@ "once": "^1.4.0" } }, + "node_modules/engine.io": { + "version": "6.6.5", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.5.tgz", + "integrity": "sha512-2RZdgEbXmp5+dVbRm0P7HQUImZpICccJy7rN7Tv+SFa55pH+lxnuw6/K1ZxxBfHoYpSkHLAO92oa8O4SwFXA2A==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.18.3" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/enhanced-resolve": { "version": "5.18.4", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", @@ -8461,6 +8590,15 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -9764,6 +9902,90 @@ "node": ">=8" } }, + "node_modules/socket.io": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", + "integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz", + "integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==", + "license": "MIT", + "dependencies": { + "debug": "~4.4.1", + "ws": "~8.18.3" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz", + "integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/sodium-native": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-4.3.3.tgz", @@ -11511,6 +11733,27 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",