Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/src/admin/admin.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
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';

Check failure on line 9 in backend/src/admin/admin.module.ts

View workflow job for this annotation

GitHub Actions / Lint

'MarketsModule' is defined but never used
import { NotificationsModule } from '../notifications/notifications.module';
import { Prediction } from '../predictions/entities/prediction.entity';
import { User } from '../users/entities/user.entity';
Expand Down
44 changes: 6 additions & 38 deletions backend/src/admin/admin.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
) {}

Expand Down Expand Up @@ -268,45 +270,11 @@ export class AdminService {
dto: ResolveMarketDto,
adminId: string,
): Promise<Market> {
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({
Expand Down
9 changes: 9 additions & 0 deletions backend/src/markets/dto/resolve-market.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
36 changes: 36 additions & 0 deletions backend/src/markets/markets-payout.service.ts
Original file line number Diff line number Diff line change
@@ -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(

Check failure on line 11 in backend/src/markets/markets-payout.service.ts

View workflow job for this annotation

GitHub Actions / Lint

Async method 'triggerPayoutCalculation' has no 'await' expression
marketId: string,
resolvedOutcome: string,
): Promise<void> {
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<void> {
// 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.`,
);
}
}
17 changes: 17 additions & 0 deletions backend/src/markets/markets.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -181,6 +182,22 @@ export class MarketsController {
async cancelMarket(@Param('id') id: string): Promise<Market> {
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<Market> {
return this.marketsService.resolveMarket(id, dto);
}

@Post(':id/comments')
@UseGuards(BanGuard)
Expand Down
5 changes: 3 additions & 2 deletions backend/src/markets/markets.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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 {}
47 changes: 41 additions & 6 deletions backend/src/markets/markets.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
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<Repository<Market>, 'create' | 'save' | 'findOne' | 'find'>
Expand All @@ -22,6 +24,7 @@
let sorobanService: jest.Mocked<
Pick<SorobanService, 'createMarket' | 'resolveMarket'>
>;
let marketsPayoutService: jest.Mocked<MarketsPayoutService>;
let dataSource: jest.Mocked<DataSource>;

const mockUser = {
Expand Down Expand Up @@ -54,6 +57,10 @@
createMarket: jest.fn(),
resolveMarket: jest.fn(),
};

marketsPayoutService = {
triggerPayoutCalculation: jest.fn(),
} as any;

dataSource = {
createQueryRunner: jest.fn().mockReturnValue({
Expand Down Expand Up @@ -101,6 +108,10 @@
provide: SorobanService,
useValue: sorobanService,
},
{
provide: MarketsPayoutService,
useValue: marketsPayoutService,
},
{
provide: DataSource,
useValue: dataSource,
Expand Down Expand Up @@ -159,10 +170,11 @@
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 () => {
Expand All @@ -174,10 +186,32 @@
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', () => {
Expand Down Expand Up @@ -293,6 +327,7 @@
{ provide: getRepositoryToken(User), useValue: {} },
{ provide: UsersService, useValue: {} },
{ provide: SorobanService, useValue: {} },
{ provide: MarketsPayoutService, useValue: {} },
{ provide: DataSource, useValue: {} },
],
}).compile();
Expand Down Expand Up @@ -328,7 +363,7 @@
expect(mockQueryBuilder.where).toHaveBeenCalledWith(
'market.is_featured = true',
);
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(

Check warning on line 366 in backend/src/markets/markets.service.spec.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe argument of type `any` assigned to a parameter of type `SelectQueryBuilder<Market>`
'market.is_public = true',
);
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
Expand Down
14 changes: 11 additions & 3 deletions backend/src/markets/markets.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -49,6 +51,7 @@ export class MarketsService {
private readonly userBookmarksRepository: Repository<UserBookmark>,
private readonly usersService: UsersService,
private readonly sorobanService: SorobanService,
private readonly marketsPayoutService: MarketsPayoutService,
private readonly dataSource: DataSource,
) {}

Expand Down Expand Up @@ -215,8 +218,9 @@ export class MarketsService {
}
}

async resolveMarket(id: string, outcome: string): Promise<Market> {
async resolveMarket(id: string, dto: ResolveMarketDto): Promise<Market> {
const market = await this.findByIdOrOnChainId(id);
const outcome = dto.resolved_outcome;

if (market.is_resolved) {
throw new ConflictException('Market is already resolved');
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -533,7 +542,6 @@ export class MarketsService {
}

/**
<<<<<<< HEAD
* Get featured markets
*/
async findFeaturedMarkets(
Expand Down
Loading