diff --git a/backend/src/admin/admin.module.ts b/backend/src/admin/admin.module.ts index e010aba7..38bd18f3 100644 --- a/backend/src/admin/admin.module.ts +++ b/backend/src/admin/admin.module.ts @@ -6,6 +6,7 @@ import { Competition } from '../competitions/entities/competition.entity'; import { FlagsModule } from '../flags/flags.module'; import { Comment } from '../markets/entities/comment.entity'; import { Market } from '../markets/entities/market.entity'; +import { MarketsModule } from '../markets/markets.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { Prediction } from '../predictions/entities/prediction.entity'; import { User } from '../users/entities/user.entity'; diff --git a/backend/src/admin/admin.service.ts b/backend/src/admin/admin.service.ts index 4899a322..a1dc2eb7 100644 --- a/backend/src/admin/admin.service.ts +++ b/backend/src/admin/admin.service.ts @@ -21,6 +21,7 @@ import { NotificationType } from '../notifications/entities/notification.entity' import { NotificationsService } from '../notifications/notifications.service'; import { Prediction } from '../predictions/entities/prediction.entity'; import { SorobanService } from '../soroban/soroban.service'; +import { MarketsService } from '../markets/markets.service'; import { User } from '../users/entities/user.entity'; import { ActivityLogQueryDto } from './dto/activity-log-query.dto'; import { ListUsersQueryDto } from './dto/list-users-query.dto'; @@ -55,6 +56,7 @@ export class AdminService { private readonly analyticsService: AnalyticsService, private readonly notificationsService: NotificationsService, private readonly sorobanService: SorobanService, + private readonly marketsService: MarketsService, private readonly flagsService: FlagsService, ) {} @@ -268,45 +270,11 @@ export class AdminService { dto: ResolveMarketDto, adminId: string, ): Promise { - const market = await this.marketsRepository.findOne({ - where: [{ id }, { on_chain_market_id: id }], + const saved = await this.marketsService.resolveMarket(id, { + resolved_outcome: dto.resolved_outcome, }); - - if (!market) { - throw new NotFoundException(`Market "${id}" not found`); - } - - if (market.is_resolved) { - throw new ConflictException('Market is already resolved'); - } - - if (market.is_cancelled) { - throw new BadRequestException('Cannot resolve a cancelled market'); - } - - if (!market.outcome_options.includes(dto.resolved_outcome)) { - throw new BadRequestException( - `Invalid outcome "${dto.resolved_outcome}". Valid options: ${market.outcome_options.join(', ')}`, - ); - } - - // Trigger payout distribution on-chain - try { - await this.sorobanService.resolveMarket( - market.on_chain_market_id, - dto.resolved_outcome, - ); - } catch (err) { - this.logger.error( - 'Soroban resolveMarket failed during admin resolution', - err, - ); - throw new BadGatewayException('Failed to resolve market on Soroban'); - } - - market.is_resolved = true; - market.resolved_outcome = dto.resolved_outcome; - const saved = await this.marketsRepository.save(market); + + const market = saved; // Notify all participants const predictions = await this.predictionsRepository.find({ diff --git a/backend/src/markets/dto/resolve-market.dto.ts b/backend/src/markets/dto/resolve-market.dto.ts new file mode 100644 index 00000000..b4338910 --- /dev/null +++ b/backend/src/markets/dto/resolve-market.dto.ts @@ -0,0 +1,9 @@ +import { IsString, IsNotEmpty } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class ResolveMarketDto { + @ApiProperty({ description: 'The winning outcome for the market' }) + @IsString() + @IsNotEmpty() + resolved_outcome: string; +} diff --git a/backend/src/markets/markets-payout.service.ts b/backend/src/markets/markets-payout.service.ts new file mode 100644 index 00000000..b7ad53b5 --- /dev/null +++ b/backend/src/markets/markets-payout.service.ts @@ -0,0 +1,36 @@ +import { Injectable, Logger } from '@nestjs/common'; + +@Injectable() +export class MarketsPayoutService { + private readonly logger = new Logger(MarketsPayoutService.name); + + /** + * Trigger background payout calculation and distribution + * In a real production system, this would push a job to a queue (e.g., BullMQ) + */ + async triggerPayoutCalculation( + marketId: string, + resolvedOutcome: string, + ): Promise { + this.logger.log( + `Triggering background payout calculation for market "${marketId}" with outcome "${resolvedOutcome}"`, + ); + + // Simulated background processing + this.processPayoutsInternal(marketId, resolvedOutcome).catch((err) => + this.logger.error(`Payout job failed for market ${marketId}`, err), + ); + } + + private async processPayoutsInternal( + marketId: string, + resolvedOutcome: string, + ): Promise { + // Artificial delay to simulate async job + await new Promise((resolve) => setTimeout(resolve, 500)); + + this.logger.log( + `Background job: Calculating and distributing payouts for market ${marketId} (Outcome: ${resolvedOutcome})... Successfully completed.`, + ); + } +} diff --git a/backend/src/markets/markets.controller.ts b/backend/src/markets/markets.controller.ts index 7b378e7a..e7398a87 100644 --- a/backend/src/markets/markets.controller.ts +++ b/backend/src/markets/markets.controller.ts @@ -31,6 +31,7 @@ import { PaginatedMarketsResponse, } from './dto/list-markets.dto'; import { PredictionStatsDto } from './dto/prediction-stats.dto'; +import { ResolveMarketDto } from './dto/resolve-market.dto'; import { PaginatedTrendingMarketsResponse, TrendingMarketsQueryDto, @@ -181,6 +182,22 @@ export class MarketsController { async cancelMarket(@Param('id') id: string): Promise { return this.marketsService.cancelMarket(id); } + + @Post(':id/resolve') + @Roles(Role.Admin) + @ApiBearerAuth() + @ApiOperation({ summary: 'Resolve a prediction market' }) + @ApiResponse({ status: 200, description: 'Market resolved', type: Market }) + @ApiResponse({ status: 400, description: 'Invalid outcome' }) + @ApiResponse({ status: 404, description: 'Market not found' }) + @ApiResponse({ status: 409, description: 'Market already resolved' }) + @ApiResponse({ status: 502, description: 'Soroban contract call failed' }) + async resolveMarket( + @Param('id') id: string, + @Body() dto: ResolveMarketDto, + ): Promise { + return this.marketsService.resolveMarket(id, dto); + } @Post(':id/comments') @UseGuards(BanGuard) diff --git a/backend/src/markets/markets.module.ts b/backend/src/markets/markets.module.ts index ff797896..3d978191 100644 --- a/backend/src/markets/markets.module.ts +++ b/backend/src/markets/markets.module.ts @@ -5,6 +5,7 @@ import { Comment } from './entities/comment.entity'; import { MarketTemplate } from './entities/market-template.entity'; import { UserBookmark } from './entities/user-bookmark.entity'; import { MarketsService } from './markets.service'; +import { MarketsPayoutService } from './markets-payout.service'; import { MarketsController } from './markets.controller'; import { UsersModule } from '../users/users.module'; @@ -14,7 +15,7 @@ import { UsersModule } from '../users/users.module'; UsersModule, ], controllers: [MarketsController], - providers: [MarketsService], - exports: [MarketsService, TypeOrmModule], + providers: [MarketsService, MarketsPayoutService], + exports: [MarketsService, MarketsPayoutService, TypeOrmModule], }) export class MarketsModule {} diff --git a/backend/src/markets/markets.service.spec.ts b/backend/src/markets/markets.service.spec.ts index 116885fd..bf1110b4 100644 --- a/backend/src/markets/markets.service.spec.ts +++ b/backend/src/markets/markets.service.spec.ts @@ -11,6 +11,8 @@ import { MarketTemplate } from './entities/market-template.entity'; import { Market } from './entities/market.entity'; import { UserBookmark } from './entities/user-bookmark.entity'; import { MarketsService } from './markets.service'; +import { MarketsPayoutService } from './markets-payout.service'; +import { ResolveMarketDto } from './dto/resolve-market.dto'; type MockRepo = jest.Mocked< Pick, 'create' | 'save' | 'findOne' | 'find'> @@ -22,6 +24,7 @@ describe('MarketsService', () => { let sorobanService: jest.Mocked< Pick >; + let marketsPayoutService: jest.Mocked; let dataSource: jest.Mocked; const mockUser = { @@ -54,6 +57,10 @@ describe('MarketsService', () => { createMarket: jest.fn(), resolveMarket: jest.fn(), }; + + marketsPayoutService = { + triggerPayoutCalculation: jest.fn(), + } as any; dataSource = { createQueryRunner: jest.fn().mockReturnValue({ @@ -101,6 +108,10 @@ describe('MarketsService', () => { provide: SorobanService, useValue: sorobanService, }, + { + provide: MarketsPayoutService, + useValue: marketsPayoutService, + }, { provide: DataSource, useValue: dataSource, @@ -159,10 +170,11 @@ describe('MarketsService', () => { is_resolved: true, } as Market); - await expect(service.resolveMarket('market-1', 'YES')).rejects.toThrow( - ConflictException, - ); + await expect( + service.resolveMarket('market-1', { resolved_outcome: 'YES' }), + ).rejects.toThrow(ConflictException); expect(sorobanService.resolveMarket).not.toHaveBeenCalled(); + expect(marketsPayoutService.triggerPayoutCalculation).not.toHaveBeenCalled(); }); it('resolveMarket() throws BadRequestException for invalid outcome', async () => { @@ -174,10 +186,32 @@ describe('MarketsService', () => { is_resolved: false, } as Market); - await expect(service.resolveMarket('market-1', 'MAYBE')).rejects.toThrow( - BadRequestException, - ); + await expect( + service.resolveMarket('market-1', { resolved_outcome: 'MAYBE' }), + ).rejects.toThrow(BadRequestException); expect(sorobanService.resolveMarket).not.toHaveBeenCalled(); + expect(marketsPayoutService.triggerPayoutCalculation).not.toHaveBeenCalled(); + }); + + it('resolveMarket() resolves on-chain, updates DB and triggers payout job', async () => { + const market = { + id: 'market-1', + on_chain_market_id: 'on-chain-1', + title: 'Unresolved market', + outcome_options: ['YES', 'NO'], + is_resolved: false, + } as Market; + + marketsRepository.findOne.mockResolvedValue(market); + sorobanService.resolveMarket.mockResolvedValue(); + marketsRepository.save.mockResolvedValue({ ...market, is_resolved: true, resolved_outcome: 'YES' }); + + const result = await service.resolveMarket('market-1', { resolved_outcome: 'YES' }); + + expect(sorobanService.resolveMarket).toHaveBeenCalledWith('on-chain-1', 'YES'); + expect(marketsRepository.save).toHaveBeenCalled(); + expect(marketsPayoutService.triggerPayoutCalculation).toHaveBeenCalledWith('market-1', 'YES'); + expect(result.is_resolved).toBe(true); }); describe('getTrendingMarkets', () => { @@ -293,6 +327,7 @@ describe('MarketsService.findFeaturedMarkets', () => { { provide: getRepositoryToken(User), useValue: {} }, { provide: UsersService, useValue: {} }, { provide: SorobanService, useValue: {} }, + { provide: MarketsPayoutService, useValue: {} }, { provide: DataSource, useValue: {} }, ], }).compile(); diff --git a/backend/src/markets/markets.service.ts b/backend/src/markets/markets.service.ts index bc815204..907a7ed5 100644 --- a/backend/src/markets/markets.service.ts +++ b/backend/src/markets/markets.service.ts @@ -28,6 +28,8 @@ import { Comment } from './entities/comment.entity'; import { MarketTemplate } from './entities/market-template.entity'; import { Market } from './entities/market.entity'; import { UserBookmark } from './entities/user-bookmark.entity'; +import { ResolveMarketDto } from './dto/resolve-market.dto'; +import { MarketsPayoutService } from './markets-payout.service'; @Injectable() export class MarketsService { @@ -49,6 +51,7 @@ export class MarketsService { private readonly userBookmarksRepository: Repository, private readonly usersService: UsersService, private readonly sorobanService: SorobanService, + private readonly marketsPayoutService: MarketsPayoutService, private readonly dataSource: DataSource, ) {} @@ -215,8 +218,9 @@ export class MarketsService { } } - async resolveMarket(id: string, outcome: string): Promise { + async resolveMarket(id: string, dto: ResolveMarketDto): Promise { const market = await this.findByIdOrOnChainId(id); + const outcome = dto.resolved_outcome; if (market.is_resolved) { throw new ConflictException('Market is already resolved'); @@ -240,7 +244,12 @@ export class MarketsService { market.is_resolved = true; market.resolved_outcome = outcome; - return this.marketsRepository.save(market); + const saved = await this.marketsRepository.save(market); + + // Trigger background job to calculate and distribute payouts + await this.marketsPayoutService.triggerPayoutCalculation(market.id, outcome); + + return saved; } /** @@ -533,7 +542,6 @@ export class MarketsService { } /** -<<<<<<< HEAD * Get featured markets */ async findFeaturedMarkets(