From 1fbd5f703c1802fd2f7bb0ca89dadc42b45dbdd1 Mon Sep 17 00:00:00 2001 From: Toan Date: Tue, 18 Aug 2026 10:36:15 +0700 Subject: [PATCH] fix(indexer): disambiguate dual-shape parsing by arity and enum-tag heuristic (#170) --- .../parsers/event-parser.service.spec.ts | 119 ++++++++++++++++-- .../indexer/parsers/event-parser.service.ts | 39 ++++-- 2 files changed, 141 insertions(+), 17 deletions(-) diff --git a/server/src/indexer/parsers/event-parser.service.spec.ts b/server/src/indexer/parsers/event-parser.service.spec.ts index 53ac4c5..843e1ee 100644 --- a/server/src/indexer/parsers/event-parser.service.spec.ts +++ b/server/src/indexer/parsers/event-parser.service.spec.ts @@ -581,6 +581,98 @@ describe('EventParserService', () => { expect(errorSpy).toHaveBeenCalled(); expect(prisma.campaign.upsert).not.toHaveBeenCalled(); }); + + // Regression tests for enum-decoding shapes (issue #170): + // The activity-log mirror emits ActivityAction enum which can decode as: + // 1. Bare string (e.g., 'CampaignRegistered') + // 2. Single-element array (e.g., ['CampaignRegistered']) + // 3. Object with tag (e.g., { tag: 'CampaignRegistered' }) + // All three must NOT be mistaken for a title/name. + describe('enum-decoding shape regression', () => { + it('FarmerRegistered: bare-string enum tag is not misread as name', async () => { + await service.processEvent( + rawEvent( + 'e-fr-bare', + ['FarmerRegistered', FARMER], + [FARMER, 'FarmerRegistered', 1700000000n, 12], + ), + ); + expect(prisma.user.upsert).toHaveBeenCalledWith( + expect.objectContaining({ where: { address: FARMER }, update: {} }), + ); + }); + + it('FarmerRegistered: array enum tag is not misread as name', async () => { + await service.processEvent( + rawEvent( + 'e-fr-arr', + ['FarmerRegistered', FARMER], + [FARMER, ['FarmerRegistered'], 1700000000n, 12], + ), + ); + expect(prisma.user.upsert).toHaveBeenCalledWith( + expect.objectContaining({ where: { address: FARMER }, update: {} }), + ); + }); + + it('FarmerRegistered: object enum tag is not misread as name', async () => { + await service.processEvent( + rawEvent( + 'e-fr-obj', + ['FarmerRegistered', FARMER], + [FARMER, { tag: 'FarmerRegistered' }, 1700000000n, 12], + ), + ); + expect(prisma.user.upsert).toHaveBeenCalledWith( + expect.objectContaining({ where: { address: FARMER }, update: {} }), + ); + }); + + it('CampaignRegistered: bare-string enum tag is not misread as title', async () => { + await service.processEvent( + rawEvent( + 'e-cr-bare', + ['CampaignRegistered', CAMPAIGN_ID], + [FARMER, 'CampaignRegistered', 1700000000n, 13], + ), + ); + expect(prisma.campaign.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + create: expect.objectContaining({ farmer: FARMER, title: '' }), + }), + ); + }); + + it('CampaignRegistered: array enum tag is not misread as title', async () => { + await service.processEvent( + rawEvent( + 'e-cr-arr', + ['CampaignRegistered', CAMPAIGN_ID], + [FARMER, ['CampaignRegistered'], 1700000000n, 13], + ), + ); + expect(prisma.campaign.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + create: expect.objectContaining({ farmer: FARMER, title: '' }), + }), + ); + }); + + it('CampaignRegistered: object enum tag is not misread as title', async () => { + await service.processEvent( + rawEvent( + 'e-cr-obj', + ['CampaignRegistered', CAMPAIGN_ID], + [FARMER, { tag: 'CampaignRegistered' }, 1700000000n, 13], + ), + ); + expect(prisma.campaign.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + create: expect.objectContaining({ farmer: FARMER, title: '' }), + }), + ); + }); + }); }); describe('CampaignEscrowLinked', () => { @@ -689,9 +781,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 +808,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 +828,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..138ab13 100644 --- a/server/src/indexer/parsers/event-parser.service.ts +++ b/server/src/indexer/parsers/event-parser.service.ts @@ -73,6 +73,15 @@ function isTagLike(v: unknown): boolean { return false; } +/** + * Heuristic: enum variants (ActivityAction) are PascalCase without spaces. + * User-provided titles/names typically contain spaces or lowercase. + * This distinguishes bare-string enum tags from genuine titles. + */ +function looksLikeEnumTag(v: unknown): boolean { + return typeof v === 'string' && /^[A-Z][a-zA-Z0-9]*$/.test(v); +} + @Injectable() export class EventParserService { private readonly logger = new Logger(EventParserService.name); @@ -665,8 +674,15 @@ export class EventParserService { const farmer = asString(topics[1], 'farmer'); const timestamp = asNumber(arr[arr.length - 2], 'timestamp'); const ledgerSequence = asNumber(arr[arr.length - 1], 'ledgerSequence'); - const nameCandidate = arr.length >= 4 ? arr[1] : undefined; - const name = typeof nameCandidate === 'string' ? nameCandidate : undefined; + // Disambiguate deterministically: + // - Direct call: (farmer, name:string, timestamp, ledger_sequence) => 4 elements + // - Activity mirror: (actor, timestamp, ledger_sequence) => 3 elements + // If a 4-element payload has an enum tag at index 1 (decoded as array/object), + // treat it as activity mirror and ignore the name. + const name = + arr.length === 4 && !isTagLike(arr[1]) && !looksLikeEnumTag(arr[1]) + ? asString(arr[1], 'name') + : undefined; const data: FarmerRegisteredData = { farmer, name, @@ -689,21 +705,28 @@ export class EventParserService { topics: unknown[], arr: unknown[], ): ParsedEvent { - if (arr.length < 4) + if (arr.length < 3) throw new Error( - 'CampaignRegistered payload must have at least 4 elements', + 'CampaignRegistered payload must have at least 3 elements', ); const campaignId = asString(topics[1], 'campaignId'); // value[0] is the farmer/actor address in both emission shapes. const farmer = asString(arr[0], 'farmer'); const timestamp = asNumber(arr[arr.length - 2], 'timestamp'); const ledgerSequence = asNumber(arr[arr.length - 1], 'ledgerSequence'); - // The direct campaign_registered() call publishes a plain-string title in - // slot 1; the activity-log mirror publishes an ActivityAction enum tag - // there instead. Only trust it as a title when it decoded to a string. + // Disambiguate deterministically: + // - Direct call: (farmer, title:string, timestamp, ledger_sequence) + // - Activity mirror: (actor, action_type:enum, timestamp, ledger_sequence) + // Enum decodes as array ['Variant'], { tag: 'Variant' }, or bare string 'Variant'. + // Use isTagLike for array/object; for bare strings, use PascalCase heuristic + // (enum variants are PascalCase without spaces; titles typically contain spaces/lowercase). const titleCandidate = arr[1]; const title = - typeof titleCandidate === 'string' ? titleCandidate : undefined; + !isTagLike(titleCandidate) && + !looksLikeEnumTag(titleCandidate) && + typeof titleCandidate === 'string' + ? titleCandidate + : undefined; const data: CampaignCreatedData = { campaignId, farmer,