Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import {
import { CreatorEvent } from '../matches/entities/creator-event.entity';
import { CreatorEventLeaderboardEntry } from '../matches/entities/creator-event-leaderboard-entry.entity';
import { CreatorEventPayout } from '../matches/entities/creator-event-payout.entity';
import { Match } from '../matches/entities/match.entity';
import { MatchPrediction } from '../matches/entities/match-prediction.entity';
import { User } from '../users/entities/user.entity';
import { CreatorEventsService } from './creator-events.service';

describe('CreatorEventsService predictions and stats', () => {
Expand Down Expand Up @@ -86,6 +89,18 @@ describe('CreatorEventsService predictions and stats', () => {
provide: getRepositoryToken(CreatorEventLeaderboardEntry),
useValue: {},
},
{
provide: getRepositoryToken(Match),
useValue: {},
},
{
provide: getRepositoryToken(MatchPrediction),
useValue: {},
},
{
provide: getRepositoryToken(User),
useValue: {},
},
{
provide: getRepositoryToken(CreatorEventPayout),
useValue: {},
Expand Down
9 changes: 4 additions & 5 deletions backend/src/creator-events/creator-events.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import { CacheModule } from '@nestjs/cache-manager';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ContractModule } from '../contract/contract.module';
import { CreatorEvent } from '../matches/entities/creator-event.entity';
import { Match } from '../matches/entities/match.entity';
import { MatchPrediction } from '../matches/entities/match-prediction.entity';
import { User } from '../users/entities/user.entity';
import { CreatorEventLeaderboardEntry } from '../matches/entities/creator-event-leaderboard-entry.entity';
import { CreatorEventPayout } from '../matches/entities/creator-event-payout.entity';
import {
Expand All @@ -15,11 +18,7 @@ import { CreatorEventsService } from './creator-events.service';
@Module({
imports: [
ContractModule,
TypeOrmModule.forFeature([
CreatorEvent,
CreatorEventLeaderboardEntry,
CreatorEventPayout,
]),
TypeOrmModule.forFeature([CreatorEvent, Match, MatchPrediction, User, CreatorEventLeaderboardEntry, CreatorEventPayout]),
CacheModule.register(),
],
controllers: [
Expand Down
15 changes: 15 additions & 0 deletions backend/src/creator-events/creator-events.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import { ContractService } from '../contract/contract.service';
import { CreatorEvent } from '../matches/entities/creator-event.entity';
import { CreatorEventLeaderboardEntry } from '../matches/entities/creator-event-leaderboard-entry.entity';
import { CreatorEventPayout } from '../matches/entities/creator-event-payout.entity';
import { Match } from '../matches/entities/match.entity';
import { MatchPrediction } from '../matches/entities/match-prediction.entity';
import { User } from '../users/entities/user.entity';
import { CreatorEventsService } from './creator-events.service';
import { CreatorEventSearchStatus } from './dto/search-events-query.dto';

Expand Down Expand Up @@ -95,6 +98,18 @@ describe('CreatorEventsService searchEvents', () => {
provide: getRepositoryToken(CreatorEventLeaderboardEntry),
useValue: {},
},
{
provide: getRepositoryToken(Match),
useValue: {},
},
{
provide: getRepositoryToken(MatchPrediction),
useValue: {},
},
{
provide: getRepositoryToken(User),
useValue: {},
},
{
provide: getRepositoryToken(CreatorEventPayout),
useValue: {},
Expand Down
47 changes: 39 additions & 8 deletions backend/src/creator-events/creator-events.service.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,6 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Brackets, Repository } from 'typeorm';
import { CreatorEventPayout } from '../matches/entities/creator-event-payout.entity';
import {
PaginatedPayoutsDto,
PayoutEntryDto,
PayoutsQueryDto,
} from './dto/payouts.dto';
import { Brackets, In, Repository } from 'typeorm';
import {
ContractService,
ContractEvent,
Expand All @@ -15,7 +9,11 @@ import {
ContractMatch,
} from '../contract/contract.service';
import { CreatorEvent } from '../matches/entities/creator-event.entity';
import { Match } from '../matches/entities/match.entity';
import { MatchPrediction } from '../matches/entities/match-prediction.entity';
import { User } from '../users/entities/user.entity';
import { CreatorEventLeaderboardEntry } from '../matches/entities/creator-event-leaderboard-entry.entity';
import { CreatorEventPayout } from '../matches/entities/creator-event-payout.entity';
import {
EventByCodeResponseDto,
MatchPreviewDto,
Expand Down Expand Up @@ -48,6 +46,11 @@ import {
LeaderboardEntryResponse,
PaginatedLeaderboardResponse,
} from './dto/leaderboard-query.dto';
import {
PayoutsQueryDto,
PaginatedPayoutsDto,
PayoutEntryDto,
} from './dto/payouts.dto';
import {
normalizeContractPrediction,
resolveCorrectness,
Expand Down Expand Up @@ -86,9 +89,14 @@ export class CreatorEventsService {
private readonly contractService: ContractService,
@InjectRepository(CreatorEvent)
private readonly creatorEventRepository: Repository<CreatorEvent>,
@InjectRepository(Match)
private readonly matchRepository: Repository<Match>,
@InjectRepository(MatchPrediction)
private readonly matchPredictionRepository: Repository<MatchPrediction>,
@InjectRepository(User)
private readonly userRepository: Repository<User>,
@InjectRepository(CreatorEventLeaderboardEntry)
private readonly leaderboardEntryRepository: Repository<CreatorEventLeaderboardEntry>,

@InjectRepository(CreatorEventPayout)
private readonly creatorEventPayoutRepository: Repository<CreatorEventPayout>,
) {}
Expand Down Expand Up @@ -542,6 +550,28 @@ export class CreatorEventsService {
incorrectPredictions === 0 &&
pendingPredictions === 0;

let totalPoints = 0;
const userEntity = await this.userRepository.findOne({
where: { stellar_address: address },
});
if (userEntity && matches.length > 0) {
const dbMatches = await this.matchRepository.find({
where: { event: { on_chain_event_id: Number(eventId) } },
});
if (dbMatches.length > 0) {
const dbPredictions = await this.matchPredictionRepository.find({
where: {
user: { id: userEntity.id },
match: { id: In(dbMatches.map((m) => m.id)) },
},
});
totalPoints = dbPredictions.reduce(
(sum, p) => sum + p.points_earned,
0,
);
}
}

return {
address,
totalMatches: matches.length,
Expand All @@ -552,6 +582,7 @@ export class CreatorEventsService {
accuracyPercentage,
rank,
isWinner,
totalPoints,
};
}

Expand Down
3 changes: 3 additions & 0 deletions backend/src/creator-events/dto/user-score-response.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,7 @@ export class UserScoreResponseDto {
description: 'Whether user is a winner (all predictions correct)',
})
isWinner: boolean;

@ApiProperty({ description: 'Total points earned by the user' })
totalPoints: number;
}
112 changes: 89 additions & 23 deletions backend/src/indexer/indexer.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,8 @@ export class IndexerService implements OnModuleInit {
event_id: this.readBigInt(base, 'event_id'),
predictor: this.readStr(base, 'predictor'),
predicted_outcome: this.readStr(base, 'predicted_outcome'),
predicted_home_score: this.readNum(base, 'predicted_home_score'),
predicted_away_score: this.readNum(base, 'predicted_away_score'),
predicted_at: this.readNum(base, 'predicted_at'),
};
case 'MatchResultSubmitted':
Expand Down Expand Up @@ -740,9 +742,8 @@ export class IndexerService implements OnModuleInit {
): Promise<void> {
const matchId = Number(data.match_id);
const predictorAddress = this.readStr(data, 'predictor');
const predictedOutcome = this.readStr(data, 'predicted_outcome');

if (!matchId || !predictorAddress || !predictedOutcome) {
if (!matchId || !predictorAddress) {
this.logger.warn('PredictionSubmitted skipped: missing data');
return;
}
Expand All @@ -768,18 +769,6 @@ export class IndexerService implements OnModuleInit {
return;
}

const normalizedOutcome = predictedOutcome.toUpperCase();
if (
![PredictedOutcome.TEAM_A, PredictedOutcome.TEAM_B, PredictedOutcome.DRAW]
.map((o) => o.toString())
.includes(normalizedOutcome)
) {
this.logger.warn(
`PredictionSubmitted skipped: invalid outcome ${predictedOutcome}`,
);
return;
}

const existing = await this.matchPredictionRepository.findOne({
where: {
match: { id: match.id },
Expand All @@ -788,10 +777,57 @@ export class IndexerService implements OnModuleInit {
});
if (existing) return;

const predictedHomeScore =
data.predicted_home_score !== undefined && data.predicted_home_score !== null
? Number(data.predicted_home_score)
: null;
const predictedAwayScore =
data.predicted_away_score !== undefined && data.predicted_away_score !== null
? Number(data.predicted_away_score)
: null;

let predictedOutcome: PredictedOutcome;
if (predictedHomeScore !== null && predictedAwayScore !== null) {
if (predictedHomeScore > predictedAwayScore) {
predictedOutcome = PredictedOutcome.TEAM_A;
} else if (predictedHomeScore < predictedAwayScore) {
predictedOutcome = PredictedOutcome.TEAM_B;
} else {
predictedOutcome = PredictedOutcome.DRAW;
}
} else {
const rawOutcome = this.readStr(data, 'predicted_outcome');
if (!rawOutcome) {
this.logger.warn(
'PredictionSubmitted skipped: no scoreline or predicted_outcome',
);
return;
}
const normalizedOutcome = rawOutcome.toUpperCase();
if (
![
PredictedOutcome.TEAM_A,
PredictedOutcome.TEAM_B,
PredictedOutcome.DRAW,
]
.map((o) => o.toString())
.includes(normalizedOutcome)
) {
this.logger.warn(
`PredictionSubmitted skipped: invalid outcome ${rawOutcome}`,
);
return;
}
predictedOutcome = normalizedOutcome as PredictedOutcome;
}

const prediction = this.matchPredictionRepository.create({
match,
user,
predicted_outcome: normalizedOutcome as PredictedOutcome,
predicted_outcome: predictedOutcome,
predicted_home_score: predictedHomeScore,
predicted_away_score: predictedAwayScore,
points_earned: 0,
is_correct: null,
});

Expand Down Expand Up @@ -854,7 +890,7 @@ export class IndexerService implements OnModuleInit {

await this.matchRepository.save(match);

await this.gradePredictions(match.id, winningTeam);
await this.gradePredictions(match);
this.logger.log(
`Indexed MatchResultSubmitted: match=${matchId} winner=${winningTeam}`,
);
Expand All @@ -864,17 +900,47 @@ export class IndexerService implements OnModuleInit {
this.broadcasterService.broadcastMatchResolved(data);
}

private async gradePredictions(
matchId: string,
winningTeam: WinningTeam,
): Promise<void> {
private async gradePredictions(match: Match): Promise<void> {
const predictions = await this.matchPredictionRepository.find({
where: { match: { id: matchId } },
where: { match: { id: match.id } },
});

const { home_score, away_score, winning_team, points_multiplier } = match;

for (const prediction of predictions) {
prediction.is_correct =
String(prediction.predicted_outcome) === String(winningTeam);
const outcomeCorrect =
String(prediction.predicted_outcome) === String(winning_team);
prediction.is_correct = outcomeCorrect;

if (!outcomeCorrect) {
prediction.points_earned = 0;
continue;
}

if (
home_score !== null &&
away_score !== null &&
prediction.predicted_home_score !== null &&
prediction.predicted_away_score !== null
) {
const exactScore =
prediction.predicted_home_score === home_score &&
prediction.predicted_away_score === away_score;

const goalDiffCorrect =
prediction.predicted_home_score - prediction.predicted_away_score ===
home_score - away_score;

if (exactScore) {
prediction.points_earned = 4 * points_multiplier;
} else if (goalDiffCorrect) {
prediction.points_earned = 3 * points_multiplier;
} else {
prediction.points_earned = 1 * points_multiplier;
}
} else {
prediction.points_earned = 1 * points_multiplier;
}
}

if (predictions.length > 0) {
Expand Down
9 changes: 9 additions & 0 deletions backend/src/matches/entities/match-prediction.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@ export class MatchPrediction {
})
predicted_outcome: PredictedOutcome;

@Column({ type: 'int', nullable: true })
predicted_home_score: number | null;

@Column({ type: 'int', nullable: true })
predicted_away_score: number | null;

@Column({ type: 'int', default: 0 })
points_earned: number;

@Column({ type: 'boolean', nullable: true })
is_correct: boolean | null;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';

export class AddScorelineAndPointsToMatchPrediction1775900000000
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.addColumns('match_predictions', [
new TableColumn({
name: 'predicted_home_score',
type: 'int',
isNullable: true,
}),
new TableColumn({
name: 'predicted_away_score',
type: 'int',
isNullable: true,
}),
new TableColumn({
name: 'points_earned',
type: 'int',
default: 0,
}),
]);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropColumn('match_predictions', 'points_earned');
await queryRunner.dropColumn('match_predictions', 'predicted_away_score');
await queryRunner.dropColumn('match_predictions', 'predicted_home_score');
}
}