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 client/src/lib/websocket/events.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
export type CampaignEventType =
| 'campaign.escrow_created'
| 'campaign.invested'
| 'campaign.contrib_reconciled'
| 'campaign.funded'
| 'campaign.tranches_configured'
| 'campaign.tranche_released'
Expand Down
89 changes: 80 additions & 9 deletions server/src/indexer/parsers/event-parser.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand Down
59 changes: 59 additions & 0 deletions server/src/indexer/parsers/event-parser.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
ParsedEvent,
CampaignEscrowCreatedData,
CampaignInvestedData,
ContribReconciledData,
CampaignFundedData,
TranchesConfiguredData,
TrancheReleasedData,
Expand Down Expand Up @@ -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':
Expand Down Expand Up @@ -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<string, unknown>,
{
campaignId,
userAddress: investor,
amount,
},
);
}

private parseCampaignFunded(
raw: RawSorobanEvent,
topics: unknown[],
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -976,6 +1016,25 @@ export class EventParserService {
);
}

private async handleContribReconciled(parsed: ParsedEvent): Promise<void> {
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<void> {
const data = parsed.data as unknown as CampaignFundedData;
await this.prisma.campaign.update({
Expand Down
8 changes: 8 additions & 0 deletions server/src/indexer/types/soroban-events.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -201,6 +208,7 @@ export interface ActivityRecordedData {
export type ParsedEventData =
| CampaignEscrowCreatedData
| CampaignInvestedData
| ContribReconciledData
| CampaignFundedData
| TranchesConfiguredData
| TrancheReleasedData
Expand Down
1 change: 1 addition & 0 deletions server/src/websocket/events.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
export type CampaignEventType =
| 'campaign.escrow_created'
| 'campaign.invested'
| 'campaign.contrib_reconciled'
| 'campaign.funded'
| 'campaign.tranches_configured'
| 'campaign.tranche_released'
Expand Down