From e929eab6f315acdc07779a3e7be66589944cc864 Mon Sep 17 00:00:00 2001 From: "Cyne Jarvis J. Zarceno" <165563006+Kuhai9801@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:41:42 +0800 Subject: [PATCH 1/2] Add creator event campaign fields --- backend/src/contract/contract.service.ts | 6 + .../entities/creator-event.entity.ts | 32 ++ backend/src/indexer/indexer.service.spec.ts | 281 +++++++++++++ backend/src/indexer/indexer.service.ts | 368 +++++++++++++++--- .../matches/entities/creator-event.entity.ts | 31 ++ ...800000000-AddCreatorEventCampaignFields.ts | 122 ++++++ 6 files changed, 790 insertions(+), 50 deletions(-) create mode 100644 backend/src/migrations/1775800000000-AddCreatorEventCampaignFields.ts diff --git a/backend/src/contract/contract.service.ts b/backend/src/contract/contract.service.ts index 7a1065e7..3fd959fa 100644 --- a/backend/src/contract/contract.service.ts +++ b/backend/src/contract/contract.service.ts @@ -23,6 +23,12 @@ export interface ContractEvent { maxParticipants: number; participantCount: number; isActive: boolean; + prizePool?: string; + rewardDistribution?: number[]; + entryFee?: string; + category?: string; + bannerUrl?: string | null; + isFinalized?: boolean; } export interface ContractMatch { diff --git a/backend/src/creator-events/entities/creator-event.entity.ts b/backend/src/creator-events/entities/creator-event.entity.ts index 17c8c1c1..c1c59d15 100644 --- a/backend/src/creator-events/entities/creator-event.entity.ts +++ b/backend/src/creator-events/entities/creator-event.entity.ts @@ -60,6 +60,38 @@ export class CreatorEvent { @ApiPropertyOptional() on_chain_created_at?: Date; + @Column({ type: 'timestamptz' }) + @ApiProperty({ description: 'Campaign start time indexed from chain data' }) + start_time: Date; + + @Column({ type: 'timestamptz' }) + @ApiProperty({ description: 'Campaign end time indexed from chain data' }) + end_time: Date; + + @Column({ type: 'bigint', default: '0' }) + @ApiProperty({ description: 'Total campaign prize pool in stroops' }) + prize_pool: string; + + @Column({ type: 'integer', array: true, default: () => "'{}'::integer[]" }) + @ApiProperty({ type: [Number], description: 'Reward split percentages' }) + reward_distribution: number[]; + + @Column({ type: 'bigint', default: '0' }) + @ApiProperty({ description: 'Entry fee in stroops' }) + entry_fee: string; + + @Column({ type: 'varchar', length: 100, default: 'general' }) + @ApiProperty({ description: 'Normalized campaign category slug' }) + category: string; + + @Column({ type: 'varchar', length: 2048, nullable: true }) + @ApiPropertyOptional({ description: 'Optional campaign banner URL' }) + banner_url?: string | null; + + @Column({ type: 'boolean', default: false }) + @ApiProperty({ description: 'Whether the campaign has been finalized' }) + is_finalized: boolean; + @Column({ type: 'int', default: 0 }) @ApiProperty() max_participants: number; diff --git a/backend/src/indexer/indexer.service.spec.ts b/backend/src/indexer/indexer.service.spec.ts index 3f2eb3f5..2f784deb 100644 --- a/backend/src/indexer/indexer.service.spec.ts +++ b/backend/src/indexer/indexer.service.spec.ts @@ -232,6 +232,287 @@ describe('IndexerService', () => { }); }); + describe('EventCreated campaign metadata', () => { + beforeEach(() => { + creatorEventRepository.findOne.mockResolvedValue(null); + (creatorEventRepository.create as jest.Mock).mockImplementation( + (event: unknown) => event as CreatorEvent, + ); + (creatorEventRepository.save as jest.Mock).mockImplementation( + async (event: unknown) => event as CreatorEvent, + ); + }); + + it('recognizes canonical contract event.created topics', () => { + expect((service as any).detectEventType(['event', 'created'], {})).toBe( + 'EventCreated', + ); + }); + + it('reads wrapped Soroban topic values', () => { + expect( + (service as any).readTopic([ + { value: { symbol: 'event' } }, + { sym: 'created' }, + ]), + ).toEqual(['event', 'created']); + }); + + it('requests JSON-formatted event payloads from Soroban RPC', async () => { + configService.get.mockImplementation((key: string) => { + if (key === 'SOROBAN_RPC_URL') return 'https://rpc.example'; + if (key === 'SOROBAN_CONTRACT_ID') return 'CCONTRACT'; + return undefined; + }); + const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ result: { events: [], latestLedger: 100 } }), + } as unknown as Response); + + try { + await (service as any).fetchEventsFromContract(50); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://rpc.example', + expect.objectContaining({ + method: 'POST', + body: expect.any(String), + }), + ); + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(String(init.body)) as { + params: { xdrFormat?: string }; + }; + expect(body.params.xdrFormat).toBe('json'); + } finally { + fetchMock.mockRestore(); + } + }); + + it('extracts legacy positional EventCreated tuple payloads', () => { + const data = (service as any).extractEventData('EventCreated', [ + '45', + 'GCREATOR', + 'ABC12345', + ]); + + expect(data).toMatchObject({ + event_id: '45', + creator: 'GCREATOR', + title: '', + description: '', + creation_fee_paid: '0', + invite_code: 'ABC12345', + prize_pool: '0', + reward_distribution: [], + entry_fee: '0', + category: 'general', + banner_url: null, + is_finalized: false, + }); + }); + + it('extracts old positional EventCreated payloads that predate campaign fields', () => { + const data = (service as any).extractEventData('EventCreated', [ + '45', + 'GCREATOR', + 'Legacy Cup', + 'Predict the winner', + '10000000', + 1710000000, + 'ABC12345', + 250, + ]); + + expect(data).toMatchObject({ + event_id: '45', + creator: 'GCREATOR', + title: 'Legacy Cup', + description: 'Predict the winner', + creation_fee_paid: '10000000', + created_at: 1710000000, + invite_code: 'ABC12345', + max_participants: 250, + start_time: null, + end_time: null, + prize_pool: '0', + reward_distribution: [], + entry_fee: '0', + category: 'general', + banner_url: null, + is_finalized: false, + }); + }); + + it('extracts extended positional EventCreated tuple payloads', () => { + const data = (service as any).extractEventData('EventCreated', { + vec: [ + '46', + { address: 'GCREATOR' }, + 'World Cup', + 'Predict the bracket', + '10000000', + 1710000000, + 1710003600, + 1710086400, + 'ZXCVBN12', + '500', + '7500000000', + { vec: [60, '30', 10] }, + '2500000', + 'International Football', + 'https://example.com/world-cup.png', + 1, + ], + }); + + expect(data).toMatchObject({ + event_id: '46', + creator: 'GCREATOR', + title: 'World Cup', + description: 'Predict the bracket', + creation_fee_paid: '10000000', + created_at: 1710000000, + start_time: 1710003600, + end_time: 1710086400, + invite_code: 'ZXCVBN12', + max_participants: 500, + prize_pool: '7500000000', + reward_distribution: [60, 30, 10], + entry_fee: '2500000', + category: 'international-football', + banner_url: 'https://example.com/world-cup.png', + is_finalized: true, + }); + }); + + it('extracts the extended campaign fields from EventCreated payloads', () => { + const data = (service as any).extractEventData('EventCreated', { + event_id: '42', + creator: 'GCREATOR', + title: 'Champions League', + description: 'Predict every knockout match', + creation_fee_paid: '10000000', + created_at: 1710000000, + start_time: '1710003600', + end_time: 1710086400, + invite_code: 'ABC12345', + max_participants: '250', + prize_pool: '5000000000', + reward_distribution: '[50, 30, 20]', + entry_fee: '2500000', + category: ' Football ', + banner_url: 'https://example.com/banner.png', + is_finalized: 'true', + }); + + expect(data).toMatchObject({ + event_id: '42', + start_time: 1710003600, + end_time: 1710086400, + prize_pool: '5000000000', + reward_distribution: [50, 30, 20], + entry_fee: '2500000', + category: 'football', + banner_url: 'https://example.com/banner.png', + is_finalized: true, + }); + }); + + it('persists extended campaign fields when present', async () => { + await (service as any).handleEventCreated({ + event_id: '42', + creator: 'GCREATOR', + title: 'Champions League', + description: 'Predict every knockout match', + creation_fee_paid: '10000000', + created_at: 1710000000, + start_time: 1710003600, + end_time: 1710086400, + invite_code: 'ABC12345', + max_participants: 250, + prize_pool: '5000000000', + reward_distribution: [50, '30', 20], + entry_fee: '2500000', + category: 'football', + banner_url: 'https://example.com/banner.png', + is_finalized: true, + }); + + expect(creatorEventRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + on_chain_event_id: 42, + start_time: new Date(1710003600 * 1000), + end_time: new Date(1710086400 * 1000), + prize_pool: '5000000000', + reward_distribution: [50, 30, 20], + entry_fee: '2500000', + category: 'football', + banner_url: 'https://example.com/banner.png', + is_finalized: true, + }), + ); + }); + + it('applies sensible defaults for legacy EventCreated payloads', async () => { + await (service as any).handleEventCreated({ + event_id: '43', + creator: 'GCREATOR', + title: 'Legacy Event', + description: 'Old contract payload', + creation_fee_paid: '10000000', + created_at: 1710000000, + }); + + expect(creatorEventRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + on_chain_event_id: 43, + start_time: new Date(1710000000 * 1000), + end_time: new Date((1710000000 + 90 * 24 * 60 * 60) * 1000), + prize_pool: '0', + reward_distribution: [], + entry_fee: '0', + category: 'general', + banner_url: null, + is_finalized: false, + }), + ); + }); + + it('guards malformed optional campaign metadata without dropping the event', async () => { + await (service as any).handleEventCreated({ + event_id: '44', + creator: 'GCREATOR', + title: 'Malformed Metadata Event', + description: 'Payload with optional-field edge cases', + creation_fee_paid: '10000000', + created_at: 1710000000, + start_time: 1710003600, + end_time: 1700000000, + prize_pool: '-1', + reward_distribution: [50, -5, 30.5, '20', ''], + entry_fee: 'not-a-number', + category: ' Formula 1 / Racing ', + banner_url: ' ', + is_finalized: 2, + }); + + expect(creatorEventRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + on_chain_event_id: 44, + start_time: new Date(1710003600 * 1000), + end_time: new Date((1710003600 + 90 * 24 * 60 * 60) * 1000), + prize_pool: '0', + reward_distribution: [50, 20], + entry_fee: '0', + category: 'formula-1-racing', + banner_url: null, + is_finalized: false, + }), + ); + }); + }); + describe('retryFailedEvents', () => { it('should retry failed events', async () => { const failedEvent = { diff --git a/backend/src/indexer/indexer.service.ts b/backend/src/indexer/indexer.service.ts index 6e381546..02ec90f4 100644 --- a/backend/src/indexer/indexer.service.ts +++ b/backend/src/indexer/indexer.service.ts @@ -25,6 +25,9 @@ const CHECKPOINT_LEDGER_KEY_LATEST = 'indexer:latest_contract_ledger'; const MAX_RETRIES = 5; const DLQ_THRESHOLD = 5; const BATCH_SIZE = 100; +const DEFAULT_CREATOR_EVENT_CATEGORY = 'general'; +// Matches MAX_EVENT_DURATION_SECONDS in contracts/creator-event-manager. +const DEFAULT_EVENT_DURATION_SECONDS = 7_776_000; @Injectable() export class IndexerService implements OnModuleInit { @@ -191,6 +194,7 @@ export class IndexerService implements OnModuleInit { params: { startLedger: fromLedger, filters: [{ type: 'contract', contractIds: [contractId] }], + xdrFormat: 'json', limit: BATCH_SIZE, }, }), @@ -283,12 +287,8 @@ export class IndexerService implements OnModuleInit { if (!Array.isArray(value)) return []; return value .map((item) => { - if (typeof item === 'string') return item; - if (item && typeof item === 'object') { - const obj = item as Record; - if (typeof obj.symbol === 'string') return obj.symbol; - if (typeof obj.value === 'string') return obj.value; - } + const unwrapped = this.unwrapIndexerValue(item); + if (typeof unwrapped === 'string') return unwrapped; return null; }) .filter((item): item is string => item !== null); @@ -300,28 +300,45 @@ export class IndexerService implements OnModuleInit { ): string | null { const lowerTopics = topic.map((t) => t.toLowerCase()); + const explicitTypeCandidate = this.unwrapIndexerValue( + value.event ?? value.event_type, + ); const explicitType = - typeof value.event === 'string' - ? value.event - : typeof value.event_type === 'string' - ? value.event_type - : null; + typeof explicitTypeCandidate === 'string' ? explicitTypeCandidate : null; if (explicitType) return explicitType; const topicStr = lowerTopics.join('.'); + const hasTopicPair = (domain: string, action: string): boolean => + lowerTopics.some( + (topic, index) => + topic === domain && lowerTopics[index + 1] === action, + ); - if (topicStr.includes('eventcreated')) return 'EventCreated'; - if (topicStr.includes('matchadded')) return 'MatchAdded'; - if (topicStr.includes('userjoined')) return 'UserJoinedEvent'; - if (topicStr.includes('predictionsubmitted')) return 'PredictionSubmitted'; + if (topicStr.includes('eventcreated') || hasTopicPair('event', 'created')) + return 'EventCreated'; + if (topicStr.includes('matchadded') || hasTopicPair('match', 'created')) + return 'MatchAdded'; + if (topicStr.includes('userjoined') || hasTopicPair('event', 'joined')) + return 'UserJoinedEvent'; + if ( + topicStr.includes('predictionsubmitted') || + hasTopicPair('prediction', 'submitted') + ) + return 'PredictionSubmitted'; if ( topicStr.includes('matchresultsubmitted') || - topicStr.includes('reslvd') + topicStr.includes('reslvd') || + hasTopicPair('match', 'result_submitted') ) return 'MatchResultSubmitted'; - if (topicStr.includes('winnersverified')) return 'WinnersVerified'; - if (topicStr.includes('eventcancelled')) return 'EventCancelled'; + if ( + topicStr.includes('winnersverified') || + hasTopicPair('event', 'winners_verified') + ) + return 'WinnersVerified'; + if (topicStr.includes('eventcancelled') || hasTopicPair('event', 'cancelled')) + return 'EventCancelled'; if (topicStr.includes('feeupdated')) return 'FeeUpdated'; if (topicStr.includes('addressverified')) return 'AddressVerified'; if (topicStr.includes('addressunverified')) return 'AddressUnverified'; @@ -343,17 +360,36 @@ export class IndexerService implements OnModuleInit { const base = { ...rawValue }; switch (eventType) { - case 'EventCreated': + case 'EventCreated': { + const eventCreated = this.readEventCreatedPayload(rawValue); + return { - event_id: this.readBigInt(base, 'event_id'), - creator: this.readStr(base, 'creator'), - title: this.readStr(base, 'title'), - description: this.readStr(base, 'description'), - creation_fee_paid: this.readBigInt(base, 'creation_fee_paid'), - created_at: this.readNum(base, 'created_at'), - invite_code: this.readStr(base, 'invite_code'), - max_participants: this.readNum(base, 'max_participants'), + event_id: this.readBigInt(eventCreated, 'event_id'), + creator: this.readStr(eventCreated, 'creator'), + title: this.readStr(eventCreated, 'title'), + description: this.readStr(eventCreated, 'description'), + creation_fee_paid: this.readBigInt(eventCreated, 'creation_fee_paid'), + created_at: this.readNum(eventCreated, 'created_at'), + start_time: this.readNum(eventCreated, 'start_time'), + end_time: this.readNum(eventCreated, 'end_time'), + invite_code: this.readStr(eventCreated, 'invite_code'), + max_participants: this.readNum(eventCreated, 'max_participants'), + prize_pool: this.readUnsignedBigInt(eventCreated, 'prize_pool'), + reward_distribution: this.readNumberArray( + eventCreated, + 'reward_distribution', + ), + entry_fee: this.readUnsignedBigInt(eventCreated, 'entry_fee'), + category: this.normalizeCategory( + this.readStr(eventCreated, 'category'), + ), + banner_url: this.normalizeNullableString( + this.readStr(eventCreated, 'banner_url'), + 2048, + ), + is_finalized: this.readBool(eventCreated, 'is_finalized') ?? false, }; + } case 'MatchAdded': return { match_id: this.readBigInt(base, 'match_id'), @@ -530,19 +566,38 @@ export class IndexerService implements OnModuleInit { }); if (existing) return; + const createdAt = this.readUnixTimestamp(data, 'created_at') ?? new Date(); + const startTime = this.readUnixTimestamp(data, 'start_time') ?? createdAt; + const parsedEndTime = this.readUnixTimestamp(data, 'end_time'); + const endTime = + parsedEndTime && parsedEndTime.getTime() > startTime.getTime() + ? parsedEndTime + : new Date( + startTime.getTime() + DEFAULT_EVENT_DURATION_SECONDS * 1000, + ); + const creatorEvent = this.creatorEventRepository.create({ on_chain_event_id: onChainEventId, creator_address: this.readStr(data, 'creator'), title: this.readStr(data, 'title') || `Event ${onChainEventId}`, description: this.readStr(data, 'description'), - creation_fee_paid: this.readStr(data, 'creation_fee_paid') || '0', - on_chain_created_at: data.created_at - ? new Date(Number(data.created_at) * 1000) - : new Date(), + creation_fee_paid: this.readUnsignedBigInt(data, 'creation_fee_paid'), + on_chain_created_at: createdAt, + start_time: startTime, + end_time: endTime, + prize_pool: this.readUnsignedBigInt(data, 'prize_pool'), + reward_distribution: this.readNumberArray(data, 'reward_distribution'), + entry_fee: this.readUnsignedBigInt(data, 'entry_fee'), + category: this.normalizeCategory(this.readStr(data, 'category')), + banner_url: this.normalizeNullableString( + this.readStr(data, 'banner_url'), + 2048, + ), + is_finalized: this.readBool(data, 'is_finalized') ?? false, is_active: true, is_cancelled: false, invite_code: this.readStr(data, 'invite_code') || null, - max_participants: Number(data.max_participants ?? 0), + max_participants: this.readNum(data, 'max_participants') ?? 0, participant_count: 0, match_count: 0, }); @@ -1019,11 +1074,188 @@ export class IndexerService implements OnModuleInit { await this.checkpointRepository.upsert({ key, value, meta: null }, ['key']); } + private readEventCreatedPayload(rawValue: unknown): Record { + const base = + rawValue && typeof rawValue === 'object' && !Array.isArray(rawValue) + ? { ...(rawValue as Record) } + : {}; + const positional = this.readPositionalValues(rawValue); + + const readValue = (key: string, positionalIndex: number): unknown => { + if (base[key] !== undefined) return base[key]; + return positional[positionalIndex]; + }; + + if (positional.length > 0 && positional.length <= 3) { + return { + ...base, + event_id: readValue('event_id', 0), + creator: readValue('creator', 1), + invite_code: readValue('invite_code', 2), + }; + } + + if (positional.length > 0 && positional.length <= 8) { + return { + ...base, + event_id: readValue('event_id', 0), + creator: readValue('creator', 1), + title: readValue('title', 2), + description: readValue('description', 3), + creation_fee_paid: readValue('creation_fee_paid', 4), + created_at: readValue('created_at', 5), + invite_code: readValue('invite_code', 6), + max_participants: readValue('max_participants', 7), + }; + } + + return { + ...base, + event_id: readValue('event_id', 0), + creator: readValue('creator', 1), + title: readValue('title', 2), + description: readValue('description', 3), + creation_fee_paid: readValue('creation_fee_paid', 4), + created_at: readValue('created_at', 5), + start_time: readValue('start_time', 6), + end_time: readValue('end_time', 7), + invite_code: readValue('invite_code', 8), + max_participants: readValue('max_participants', 9), + prize_pool: readValue('prize_pool', 10), + reward_distribution: readValue('reward_distribution', 11), + entry_fee: readValue('entry_fee', 12), + category: readValue('category', 13), + banner_url: readValue('banner_url', 14), + is_finalized: readValue('is_finalized', 15), + }; + } + + private readPositionalValues(rawValue: unknown): unknown[] { + const value = this.unwrapIndexerValue(rawValue); + if (Array.isArray(value)) return value; + if (value && typeof value === 'object') { + const record = value as Record; + if (Array.isArray(record.vec)) return record.vec; + if (Array.isArray(record.values)) return record.values; + } + return []; + } + + private readBool(data: Record, key: string): boolean | null { + const val = this.unwrapIndexerValue(data[key]); + if (val === null || val === undefined) return null; + if (typeof val === 'boolean') return val; + if (typeof val === 'number') { + if (val === 1) return true; + if (val === 0) return false; + return null; + } + if (typeof val === 'bigint') { + if (val === 1n) return true; + if (val === 0n) return false; + return null; + } + if (typeof val === 'string') { + const normalized = val.trim().toLowerCase(); + if (['true', '1', 'yes'].includes(normalized)) return true; + if (['false', '0', 'no'].includes(normalized)) return false; + } + return null; + } + + private readNumberArray(data: Record, key: string): number[] { + const unwrapped = this.unwrapIndexerValue(data[key]); + const val = + unwrapped && + typeof unwrapped === 'object' && + !Array.isArray(unwrapped) && + Array.isArray((unwrapped as Record).vec) + ? (unwrapped as Record).vec + : unwrapped; + + if (Array.isArray(val)) return this.normalizeNumberArray(val); + if (typeof val === 'string') { + const trimmed = val.trim(); + if (!trimmed) return []; + + try { + const parsed = JSON.parse(trimmed) as unknown; + if (Array.isArray(parsed)) return this.normalizeNumberArray(parsed); + } catch { + // Fall through to comma-separated parsing for legacy/manual payloads. + } + + return this.normalizeNumberArray(trimmed.split(',')); + } + + return []; + } + + private normalizeNumberArray(values: unknown[]): number[] { + return values + .map((item) => this.toNumber(item)) + .filter( + (item): item is number => + item !== null && Number.isSafeInteger(item) && item >= 0, + ); + } + + private readUnixTimestamp( + data: Record, + key: string, + ): Date | null { + const seconds = this.readNum(data, key); + if ( + seconds === null || + seconds <= 0 || + !Number.isSafeInteger(seconds) + ) { + return null; + } + + const milliseconds = seconds * 1000; + if (!Number.isFinite(milliseconds)) return null; + + const date = new Date(milliseconds); + return Number.isFinite(date.getTime()) ? date : null; + } + + private readUnsignedBigInt( + data: Record, + key: string, + ): string { + const normalized = this.readBigInt(data, key); + return normalized.startsWith('-') ? '0' : normalized; + } + + private normalizeCategory(category: string): string { + const normalized = category + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 100) + .replace(/^-+|-+$/g, ''); + + return normalized || DEFAULT_CREATOR_EVENT_CATEGORY; + } + + private normalizeNullableString( + value: string, + maxLength: number, + ): string | null { + const normalized = value.trim(); + if (!normalized) return null; + return normalized.slice(0, maxLength); + } + private readStr(data: Record, key: string): string { - const val = data[key]; + const val = this.unwrapIndexerValue(data[key]); if (val === null || val === undefined) return ''; if (typeof val === 'string') return val; if (typeof val === 'number' || typeof val === 'boolean') return String(val); + if (typeof val === 'bigint' || typeof val === 'symbol') return String(val); if (typeof val === 'object') { try { return JSON.stringify(val); @@ -1031,42 +1263,78 @@ export class IndexerService implements OnModuleInit { return ''; } } - if (typeof val === 'symbol' || typeof val === 'bigint') { - return String(val); - } return ''; } private readNum(data: Record, key: string): number | null { - const val = data[key]; - if (typeof val === 'number' && Number.isFinite(val)) return val; - if (typeof val === 'string') { - const n = Number(val); - return Number.isFinite(n) ? n : null; - } - return null; + return this.toNumber(data[key]); } private readBigInt(data: Record, key: string): string { - const val = data[key]; - if (val == null) return '0'; + const val = this.unwrapIndexerValue(data[key]); + if (val === null || val === undefined || val === '') return '0'; + + if (typeof val === 'bigint') return val.toString(); + if (typeof val === 'number') { + if (!Number.isSafeInteger(val)) return '0'; + return BigInt(val).toString(); + } if (typeof val === 'string') { + const trimmed = val.trim(); + if (!trimmed) return '0'; + try { - return BigInt(val).toString(); + return BigInt(trimmed).toString(); } catch { return '0'; } } - if (typeof val === 'number') return BigInt(val).toString(); + return '0'; } private toNumber(value: unknown): number | null { - if (typeof value === 'number' && Number.isFinite(value)) return value; - if (typeof value === 'string') { - const parsed = Number(value); + const val = this.unwrapIndexerValue(value); + if (typeof val === 'number' && Number.isFinite(val)) return val; + if (typeof val === 'bigint') { + const parsed = Number(val); + return Number.isSafeInteger(parsed) ? parsed : null; + } + if (typeof val === 'string') { + const trimmed = val.trim(); + if (!trimmed) return null; + const parsed = Number(trimmed); return Number.isFinite(parsed) ? parsed : null; } return null; } + + private unwrapIndexerValue(value: unknown): unknown { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return value; + } + + const record = value as Record; + if ('value' in record) return this.unwrapIndexerValue(record.value); + + for (const key of [ + 'symbol', + 'sym', + 'string', + 'str', + 'address', + 'u64', + 'i64', + 'u32', + 'i32', + 'u128', + 'i128', + 'bool', + 'boolean', + ]) { + if (key in record) return this.unwrapIndexerValue(record[key]); + } + + return value; + } } diff --git a/backend/src/matches/entities/creator-event.entity.ts b/backend/src/matches/entities/creator-event.entity.ts index cd5089a2..78696023 100644 --- a/backend/src/matches/entities/creator-event.entity.ts +++ b/backend/src/matches/entities/creator-event.entity.ts @@ -16,6 +16,13 @@ import { Match } from './match.entity'; @Index(['creator_address', 'created_at']) @Index(['participant_count']) @Index(['match_count']) +@Index('IDX_creator_events_category', ['category']) +@Index('IDX_creator_events_campaign_window', [ + 'is_active', + 'is_cancelled', + 'start_time', + 'end_time', +]) export class CreatorEvent { @PrimaryGeneratedColumn('uuid') id: string; @@ -38,6 +45,30 @@ export class CreatorEvent { @Column({ type: 'timestamptz' }) on_chain_created_at: Date; + @Column({ type: 'timestamptz' }) + start_time: Date; + + @Column({ type: 'timestamptz' }) + end_time: Date; + + @Column({ type: 'bigint', default: '0' }) + prize_pool: string; + + @Column({ type: 'integer', array: true, default: () => "'{}'::integer[]" }) + reward_distribution: number[]; + + @Column({ type: 'bigint', default: '0' }) + entry_fee: string; + + @Column({ type: 'varchar', length: 100, default: 'general' }) + category: string; + + @Column({ type: 'varchar', length: 2048, nullable: true }) + banner_url: string | null; + + @Column({ default: false }) + is_finalized: boolean; + @Column({ default: true }) is_active: boolean; diff --git a/backend/src/migrations/1775800000000-AddCreatorEventCampaignFields.ts b/backend/src/migrations/1775800000000-AddCreatorEventCampaignFields.ts new file mode 100644 index 00000000..4faac0db --- /dev/null +++ b/backend/src/migrations/1775800000000-AddCreatorEventCampaignFields.ts @@ -0,0 +1,122 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddCreatorEventCampaignFields1775800000000 + implements MigrationInterface +{ + name = 'AddCreatorEventCampaignFields1775800000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "creator_events" + ADD COLUMN IF NOT EXISTS "start_time" TIMESTAMP WITH TIME ZONE, + ADD COLUMN IF NOT EXISTS "end_time" TIMESTAMP WITH TIME ZONE, + ADD COLUMN IF NOT EXISTS "prize_pool" BIGINT DEFAULT '0', + ADD COLUMN IF NOT EXISTS "reward_distribution" INTEGER[] DEFAULT '{}'::integer[], + ADD COLUMN IF NOT EXISTS "entry_fee" BIGINT DEFAULT '0', + ADD COLUMN IF NOT EXISTS "category" VARCHAR(100) DEFAULT 'general', + ADD COLUMN IF NOT EXISTS "banner_url" VARCHAR(2048), + ADD COLUMN IF NOT EXISTS "is_finalized" BOOLEAN DEFAULT false + `); + + await queryRunner.query(` + WITH backfill AS ( + SELECT + "id", + COALESCE("start_time", "on_chain_created_at", "created_at", NOW()) AS normalized_start_time, + COALESCE( + NULLIF( + btrim( + substring( + regexp_replace( + regexp_replace(lower(btrim("category")), '[^a-z0-9_-]+', '-', 'g'), + '-+', + '-', + 'g' + ) + FROM 1 FOR 100 + ), + '-' + ), + '' + ), + 'general' + ) AS normalized_category + FROM "creator_events" + ) + UPDATE "creator_events" AS creator_event + SET + "start_time" = backfill.normalized_start_time, + "end_time" = CASE + WHEN creator_event."end_time" IS NULL + OR creator_event."end_time" <= backfill.normalized_start_time + THEN backfill.normalized_start_time + INTERVAL '90 days' + ELSE creator_event."end_time" + END, + "prize_pool" = COALESCE(creator_event."prize_pool", 0), + "reward_distribution" = COALESCE(creator_event."reward_distribution", '{}'::integer[]), + "entry_fee" = COALESCE(creator_event."entry_fee", 0), + "category" = backfill.normalized_category, + "is_finalized" = COALESCE(creator_event."is_finalized", false) + FROM backfill + WHERE + creator_event."id" = backfill."id" AND + ( + creator_event."start_time" IS NULL OR + creator_event."end_time" IS NULL OR + creator_event."end_time" <= backfill.normalized_start_time OR + creator_event."prize_pool" IS NULL OR + creator_event."reward_distribution" IS NULL OR + creator_event."entry_fee" IS NULL OR + creator_event."category" IS DISTINCT FROM backfill.normalized_category OR + creator_event."is_finalized" IS NULL + ) + `); + + await queryRunner.query(` + ALTER TABLE "creator_events" + ALTER COLUMN "start_time" SET NOT NULL, + ALTER COLUMN "end_time" SET NOT NULL, + ALTER COLUMN "prize_pool" SET DEFAULT '0', + ALTER COLUMN "prize_pool" SET NOT NULL, + ALTER COLUMN "reward_distribution" SET DEFAULT '{}'::integer[], + ALTER COLUMN "reward_distribution" SET NOT NULL, + ALTER COLUMN "entry_fee" SET DEFAULT '0', + ALTER COLUMN "entry_fee" SET NOT NULL, + ALTER COLUMN "category" SET DEFAULT 'general', + ALTER COLUMN "category" SET NOT NULL, + ALTER COLUMN "is_finalized" SET DEFAULT false, + ALTER COLUMN "is_finalized" SET NOT NULL + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_creator_events_category" + ON "creator_events" ("category") + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_creator_events_campaign_window" + ON "creator_events" ("is_active", "is_cancelled", "start_time", "end_time") + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS "IDX_creator_events_campaign_window"`, + ); + await queryRunner.query( + `DROP INDEX IF EXISTS "IDX_creator_events_category"`, + ); + + await queryRunner.query(` + ALTER TABLE "creator_events" + DROP COLUMN IF EXISTS "is_finalized", + DROP COLUMN IF EXISTS "banner_url", + DROP COLUMN IF EXISTS "category", + DROP COLUMN IF EXISTS "entry_fee", + DROP COLUMN IF EXISTS "reward_distribution", + DROP COLUMN IF EXISTS "prize_pool", + DROP COLUMN IF EXISTS "end_time", + DROP COLUMN IF EXISTS "start_time" + `); + } +} From d7d16c8b0c0fd0d63486c5bcc8019663831bc70a Mon Sep 17 00:00:00 2001 From: "Cyne Jarvis J. Zarceno" <165563006+Kuhai9801@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:55:47 +0800 Subject: [PATCH 2/2] fix: satisfy backend lint --- backend/src/indexer/indexer.service.spec.ts | 5 +++- backend/src/indexer/indexer.service.ts | 23 +++++++++---------- ...800000000-AddCreatorEventCampaignFields.ts | 4 +--- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/backend/src/indexer/indexer.service.spec.ts b/backend/src/indexer/indexer.service.spec.ts index 2f784deb..798fd8ad 100644 --- a/backend/src/indexer/indexer.service.spec.ts +++ b/backend/src/indexer/indexer.service.spec.ts @@ -280,7 +280,10 @@ describe('IndexerService', () => { }), ); const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; - const body = JSON.parse(String(init.body)) as { + if (typeof init.body !== 'string') { + throw new Error('Expected Soroban RPC request body to be a string'); + } + const body = JSON.parse(init.body) as { params: { xdrFormat?: string }; }; expect(body.params.xdrFormat).toBe('json'); diff --git a/backend/src/indexer/indexer.service.ts b/backend/src/indexer/indexer.service.ts index 02ec90f4..7da367b1 100644 --- a/backend/src/indexer/indexer.service.ts +++ b/backend/src/indexer/indexer.service.ts @@ -311,8 +311,7 @@ export class IndexerService implements OnModuleInit { const topicStr = lowerTopics.join('.'); const hasTopicPair = (domain: string, action: string): boolean => lowerTopics.some( - (topic, index) => - topic === domain && lowerTopics[index + 1] === action, + (topic, index) => topic === domain && lowerTopics[index + 1] === action, ); if (topicStr.includes('eventcreated') || hasTopicPair('event', 'created')) @@ -337,7 +336,10 @@ export class IndexerService implements OnModuleInit { hasTopicPair('event', 'winners_verified') ) return 'WinnersVerified'; - if (topicStr.includes('eventcancelled') || hasTopicPair('event', 'cancelled')) + if ( + topicStr.includes('eventcancelled') || + hasTopicPair('event', 'cancelled') + ) return 'EventCancelled'; if (topicStr.includes('feeupdated')) return 'FeeUpdated'; if (topicStr.includes('addressverified')) return 'AddressVerified'; @@ -572,9 +574,7 @@ export class IndexerService implements OnModuleInit { const endTime = parsedEndTime && parsedEndTime.getTime() > startTime.getTime() ? parsedEndTime - : new Date( - startTime.getTime() + DEFAULT_EVENT_DURATION_SECONDS * 1000, - ); + : new Date(startTime.getTime() + DEFAULT_EVENT_DURATION_SECONDS * 1000); const creatorEvent = this.creatorEventRepository.create({ on_chain_event_id: onChainEventId, @@ -1163,7 +1163,10 @@ export class IndexerService implements OnModuleInit { return null; } - private readNumberArray(data: Record, key: string): number[] { + private readNumberArray( + data: Record, + key: string, + ): number[] { const unwrapped = this.unwrapIndexerValue(data[key]); const val = unwrapped && @@ -1205,11 +1208,7 @@ export class IndexerService implements OnModuleInit { key: string, ): Date | null { const seconds = this.readNum(data, key); - if ( - seconds === null || - seconds <= 0 || - !Number.isSafeInteger(seconds) - ) { + if (seconds === null || seconds <= 0 || !Number.isSafeInteger(seconds)) { return null; } diff --git a/backend/src/migrations/1775800000000-AddCreatorEventCampaignFields.ts b/backend/src/migrations/1775800000000-AddCreatorEventCampaignFields.ts index 4faac0db..049ad0df 100644 --- a/backend/src/migrations/1775800000000-AddCreatorEventCampaignFields.ts +++ b/backend/src/migrations/1775800000000-AddCreatorEventCampaignFields.ts @@ -1,8 +1,6 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; -export class AddCreatorEventCampaignFields1775800000000 - implements MigrationInterface -{ +export class AddCreatorEventCampaignFields1775800000000 implements MigrationInterface { name = 'AddCreatorEventCampaignFields1775800000000'; public async up(queryRunner: QueryRunner): Promise {