diff --git a/client/src/lib/websocket/events.types.ts b/client/src/lib/websocket/events.types.ts index 9505c9f..e6abb14 100644 --- a/client/src/lib/websocket/events.types.ts +++ b/client/src/lib/websocket/events.types.ts @@ -9,6 +9,7 @@ export type CampaignEventType = | 'campaign.escrow_created' | 'campaign.invested' + | 'campaign.contrib_reconciled' | 'campaign.funded' | 'campaign.tranches_configured' | 'campaign.tranche_released' diff --git a/server/src/indexer/parsers/event-parser.service.spec.ts b/server/src/indexer/parsers/event-parser.service.spec.ts index 53ac4c5..05c354d 100644 --- a/server/src/indexer/parsers/event-parser.service.spec.ts +++ b/server/src/indexer/parsers/event-parser.service.spec.ts @@ -181,6 +181,68 @@ describe('EventParserService', () => { }); }); + describe('ContribReconciled', () => { + it('persists as a distinctly-tagged audit transaction without mutating totalFunded', async () => { + await service.processEvent( + rawEvent( + 'e-cr1', + ['ContribReconciled', CAMPAIGN_ID], + [INVESTOR, 1700000000n, 250n], + ), + ); + + expect(prisma.user.upsert).toHaveBeenCalledWith( + expect.objectContaining({ where: { address: INVESTOR } }), + ); + expect(prisma.transaction.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + id: 'e-cr1', + type: 'campaign.contrib_reconciled', + amount: 250n, + }), + }), + ); + // Reconciliation is bookkeeping-only; no Investment row and no totalFunded increment. + expect(prisma.investment.create).not.toHaveBeenCalled(); + expect(prisma.campaign.update).not.toHaveBeenCalled(); + }); + + it('broadcasts a distinguishable realtime event', async () => { + const emitCampaignEvent = jest.fn(); + const withRealtime = new EventParserService( + prisma as any, + { + emitCampaignEvent, + } as any, + ); + + await withRealtime.processEvent( + rawEvent( + 'e-cr-rt1', + ['ContribReconciled', CAMPAIGN_ID], + [INVESTOR, 1700000000n, 250n], + ), + ); + + expect(prisma.transaction.create).toHaveBeenCalled(); + expect(emitCampaignEvent).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'campaign.contrib_reconciled', + campaignId: '123', + }), + ); + }); + + it('logs and skips a malformed payload', async () => { + await service.processEvent( + rawEvent('e-cr-bad', ['ContribReconciled', CAMPAIGN_ID], [INVESTOR]), + ); + expect(errorSpy).toHaveBeenCalled(); + expect(prisma.transaction.create).not.toHaveBeenCalled(); + }); + }); + describe('CampaignFunded', () => { it('marks the campaign Funded with the authoritative total', async () => { await service.processEvent( @@ -689,9 +751,12 @@ describe('EventParserService', () => { describe('broadcast-after-persist wiring', () => { it('emits a realtime event only after the DB write succeeds', async () => { const emitCampaignEvent = jest.fn(); - const withRealtime = new EventParserService(prisma as any, { - emitCampaignEvent, - } as any); + const withRealtime = new EventParserService( + prisma as any, + { + emitCampaignEvent, + } as any, + ); await withRealtime.processEvent( rawEvent( @@ -713,9 +778,12 @@ describe('EventParserService', () => { it('does not emit for already-persisted (replayed) events', async () => { const emitCampaignEvent = jest.fn(); prisma.transaction.findUnique.mockResolvedValueOnce({ id: 'e-rt2' }); - const withRealtime = new EventParserService(prisma as any, { - emitCampaignEvent, - } as any); + const withRealtime = new EventParserService( + prisma as any, + { + emitCampaignEvent, + } as any, + ); await withRealtime.processEvent( rawEvent( @@ -730,9 +798,12 @@ describe('EventParserService', () => { it('does not emit when the parsed event has no campaignId', async () => { const emitCampaignEvent = jest.fn(); - const withRealtime = new EventParserService(prisma as any, { - emitCampaignEvent, - } as any); + const withRealtime = new EventParserService( + prisma as any, + { + emitCampaignEvent, + } as any, + ); await withRealtime.processEvent( rawEvent( diff --git a/server/src/indexer/parsers/event-parser.service.ts b/server/src/indexer/parsers/event-parser.service.ts index 3f30f7a..7f3d5c4 100644 --- a/server/src/indexer/parsers/event-parser.service.ts +++ b/server/src/indexer/parsers/event-parser.service.ts @@ -7,6 +7,7 @@ import type { ParsedEvent, CampaignEscrowCreatedData, CampaignInvestedData, + ContribReconciledData, CampaignFundedData, TranchesConfiguredData, TrancheReleasedData, @@ -119,6 +120,8 @@ export class EventParserService { return this.parseCampaignEscrowCreated(raw, topics, arr); case 'ContribReceived': return this.parseCampaignInvested(raw, topics, arr); + case 'ContribReconciled': + return this.parseContribReconciled(raw, topics, arr); case 'CampaignFunded': return this.parseCampaignFunded(raw, topics, arr); case 'TranchesConfigured': @@ -249,6 +252,40 @@ export class EventParserService { ); } + private parseContribReconciled( + raw: RawSorobanEvent, + topics: unknown[], + arr: unknown[], + ): ParsedEvent { + if (arr.length < 3) + throw new Error('ContribReconciled payload must have 3 elements'); + const campaignId = asString(topics[1], 'campaignId'); + const investor = asString(arr[0], 'investor'); + const timestamp = asNumber(arr[1], 'timestamp'); + const amount = asBigInt(arr[2], 'amount'); + const data: ContribReconciledData = { + campaignId, + investor, + amount: amount.toString(), + timestamp, + }; + // Tagged distinctly from 'campaign.invested' so it's never conflated with + // genuine on-chain deposits downstream. The indexer records this as an + // audit-only event (no totalFunded increment) because it represents a + // privileged bookkeeping reconciliation, not a real token transfer. + return this.buildEvent( + raw, + topics, + 'campaign.contrib_reconciled', + data as unknown as Record, + { + campaignId, + userAddress: investor, + amount, + }, + ); + } + private parseCampaignFunded( raw: RawSorobanEvent, topics: unknown[], @@ -841,6 +878,9 @@ export class EventParserService { case 'campaign.invested': await this.handleCampaignInvested(parsed); break; + case 'campaign.contrib_reconciled': + await this.handleContribReconciled(parsed); + break; case 'campaign.funded': await this.handleCampaignFunded(parsed); break; @@ -976,6 +1016,25 @@ export class EventParserService { ); } + private async handleContribReconciled(parsed: ParsedEvent): Promise { + const data = parsed.data as unknown as ContribReconciledData; + await this.ensureUser(data.investor, data.timestamp); + // Reconciliation is a privileged bookkeeping path that increases the + // on-chain total_funded without a real token transfer. The indexer + // intentionally records this as a distinctly-tagged audit Transaction row + // (created generically in persistEvent) without incrementing + // Campaign.totalFunded, so the authoritative funded amount remains driven + // by ContribReceived/CampaignFunded events. + this.logger.log( + { + campaignId: data.campaignId, + investor: data.investor, + amount: data.amount, + }, + 'Contribution reconciled (audit-only)', + ); + } + private async handleCampaignFunded(parsed: ParsedEvent): Promise { const data = parsed.data as unknown as CampaignFundedData; await this.prisma.campaign.update({ diff --git a/server/src/indexer/types/soroban-events.types.ts b/server/src/indexer/types/soroban-events.types.ts index ae5d52f..ccc4847 100644 --- a/server/src/indexer/types/soroban-events.types.ts +++ b/server/src/indexer/types/soroban-events.types.ts @@ -55,6 +55,13 @@ export interface CampaignInvestedData { timestamp: number; } +export interface ContribReconciledData { + campaignId: string; + investor: string; + amount: string; + timestamp: number; +} + export interface CampaignFundedData { campaignId: string; timestamp: number; @@ -201,6 +208,7 @@ export interface ActivityRecordedData { export type ParsedEventData = | CampaignEscrowCreatedData | CampaignInvestedData + | ContribReconciledData | CampaignFundedData | TranchesConfiguredData | TrancheReleasedData diff --git a/server/src/websocket/events.types.ts b/server/src/websocket/events.types.ts index c34515e..087761f 100644 --- a/server/src/websocket/events.types.ts +++ b/server/src/websocket/events.types.ts @@ -10,6 +10,7 @@ export type CampaignEventType = | 'campaign.escrow_created' | 'campaign.invested' + | 'campaign.contrib_reconciled' | 'campaign.funded' | 'campaign.tranches_configured' | 'campaign.tranche_released'