Skip to content

Commit 4e2ff39

Browse files
authored
Merge pull request #94 from AdaBliss/feat/indexer-disputes
feat(indexer): sync dispute_opened and dispute_resolved on-chain events
2 parents 51b4428 + 2864ccc commit 4e2ff39

4 files changed

Lines changed: 458 additions & 18 deletions

File tree

‎src/config/env.ts‎

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,7 @@ interface EnvConfig {
1414
CORS_ORIGIN: string;
1515
RATE_LIMIT_WINDOW_MS: number;
1616
RATE_LIMIT_MAX_REQUESTS: number;
17-
APP_BASE_URL: string;
18-
UPLOAD_STORAGE_DRIVER: 'local' | 's3';
19-
UPLOAD_LOCAL_DIR: string;
20-
UPLOAD_MAX_FILE_SIZE_MB: number;
21-
AWS_REGION: string;
22-
AWS_ACCESS_KEY_ID: string;
23-
AWS_SECRET_ACCESS_KEY: string;
24-
AWS_S3_BUCKET: string;
25-
AWS_S3_SIGNED_URL_EXPIRES_SECONDS: number;
17+
DISPUTE_NOTIFICATION_WEBHOOK_URL: string;
2618
}
2719

2820
const envSchema = z.object({
@@ -36,15 +28,7 @@ const envSchema = z.object({
3628
CORS_ORIGIN: z.string().default('*'),
3729
RATE_LIMIT_WINDOW_MS: z.coerce.number().int().min(1000).default(900000),
3830
RATE_LIMIT_MAX_REQUESTS: z.coerce.number().int().min(1).default(100),
39-
APP_BASE_URL: z.string().default('http://localhost:3000'),
40-
UPLOAD_STORAGE_DRIVER: z.enum(['local', 's3']).default('local'),
41-
UPLOAD_LOCAL_DIR: z.string().default('uploads'),
42-
UPLOAD_MAX_FILE_SIZE_MB: z.coerce.number().int().min(1).max(100).default(10),
43-
AWS_REGION: z.string().default('us-east-1'),
44-
AWS_ACCESS_KEY_ID: z.string().default(''),
45-
AWS_SECRET_ACCESS_KEY: z.string().default(''),
46-
AWS_S3_BUCKET: z.string().default(''),
47-
AWS_S3_SIGNED_URL_EXPIRES_SECONDS: z.coerce.number().int().min(60).default(3600),
31+
DISPUTE_NOTIFICATION_WEBHOOK_URL: z.string().default(''),
4832
});
4933

5034
let env: EnvConfig;

‎src/indexer/disputeHandlers.ts‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import logger from '../config/logger';
2+
import { disputeService } from '../services/disputeService';
3+
4+
/**
5+
* Parsed representation of a `dispute_opened` event emitted by the
6+
* SwiftChain Soroban contract.
7+
* Contract topics: [event_type, dispute_id, delivery_id]
8+
* Contract data: { opened_by: address, reason?: string }
9+
*/
10+
export interface DisputeOpenedEvent {
11+
disputeId: string;
12+
deliveryId: string;
13+
openedBy: string;
14+
reason?: string;
15+
ledgerSequence: number;
16+
}
17+
18+
/**
19+
* Parsed representation of a `dispute_resolved` event emitted by the
20+
* SwiftChain Soroban contract.
21+
* Contract topics: [event_type, dispute_id]
22+
* Contract data: { resolution?: string }
23+
*/
24+
export interface DisputeResolvedEvent {
25+
disputeId: string;
26+
resolution?: string;
27+
ledgerSequence: number;
28+
}
29+
30+
/**
31+
* Handle a `dispute_opened` event: creates the local Dispute record and
32+
* fires a notification so support staff can act on it.
33+
*/
34+
export async function handleDisputeOpened(event: DisputeOpenedEvent): Promise<void> {
35+
if (!event.disputeId || !event.deliveryId || !event.openedBy) {
36+
logger.warn('[disputeHandlers] dispute_opened: invalid event payload', { event });
37+
return;
38+
}
39+
40+
try {
41+
await disputeService.openDispute({
42+
disputeId: event.disputeId,
43+
deliveryId: event.deliveryId,
44+
openedBy: event.openedBy,
45+
reason: event.reason,
46+
ledgerSequence: event.ledgerSequence,
47+
});
48+
} catch (err) {
49+
logger.error('[disputeHandlers] dispute_opened: failed to persist dispute', {
50+
disputeId: event.disputeId,
51+
ledgerSequence: event.ledgerSequence,
52+
error: err,
53+
});
54+
throw err;
55+
}
56+
}
57+
58+
/**
59+
* Handle a `dispute_resolved` event: updates the local Dispute status and
60+
* fires a notification. A resolution for an unknown dispute is treated as a
61+
* data-ordering anomaly (logged, not thrown) rather than an error, since
62+
* the indexer may observe events out of strict ledger order.
63+
*/
64+
export async function handleDisputeResolved(event: DisputeResolvedEvent): Promise<void> {
65+
if (!event.disputeId) {
66+
logger.warn('[disputeHandlers] dispute_resolved: invalid event payload', { event });
67+
return;
68+
}
69+
70+
try {
71+
await disputeService.resolveDispute({
72+
disputeId: event.disputeId,
73+
resolution: event.resolution,
74+
ledgerSequence: event.ledgerSequence,
75+
});
76+
} catch (err) {
77+
logger.error('[disputeHandlers] dispute_resolved: failed to update dispute', {
78+
disputeId: event.disputeId,
79+
ledgerSequence: event.ledgerSequence,
80+
error: err,
81+
});
82+
throw err;
83+
}
84+
}
85+
86+
/**
87+
* Route an incoming dispute event by its type string to the correct
88+
* handler. Unknown event types are logged and ignored.
89+
*/
90+
export async function dispatchDisputeEvent(
91+
eventType: string,
92+
event: DisputeOpenedEvent | DisputeResolvedEvent,
93+
): Promise<void> {
94+
switch (eventType) {
95+
case 'dispute_opened':
96+
await handleDisputeOpened(event as DisputeOpenedEvent);
97+
break;
98+
case 'dispute_resolved':
99+
await handleDisputeResolved(event as DisputeResolvedEvent);
100+
break;
101+
default:
102+
logger.warn('[disputeHandlers] unknown event type, ignoring', { eventType });
103+
}
104+
}

‎tests/disputeHandlers.test.ts‎

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
/**
2+
* Unit/integration tests for disputeService + disputeHandlers.
3+
*
4+
* Uses an in-memory MongoDB for persistence (project convention). The
5+
* outbound notification webhook is mocked via axios.
6+
*/
7+
8+
import mongoose from 'mongoose';
9+
import { MongoMemoryServer } from 'mongodb-memory-server';
10+
import axios from 'axios';
11+
12+
jest.mock('../src/config/logger', () => ({
13+
info: jest.fn(),
14+
warn: jest.fn(),
15+
error: jest.fn(),
16+
debug: jest.fn(),
17+
}));
18+
19+
jest.mock('axios');
20+
21+
import { Dispute, DisputeStatus } from '../src/models/Dispute';
22+
import { disputeService } from '../src/services/disputeService';
23+
import {
24+
handleDisputeOpened,
25+
handleDisputeResolved,
26+
dispatchDisputeEvent,
27+
} from '../src/indexer/disputeHandlers';
28+
import env from '../src/config/env';
29+
30+
const mockedAxiosPost = axios.post as jest.Mock;
31+
32+
let mongoServer: MongoMemoryServer;
33+
const SETUP_TIMEOUT = 120_000;
34+
35+
beforeAll(async () => {
36+
mongoServer = await MongoMemoryServer.create();
37+
await mongoose.connect(mongoServer.getUri());
38+
}, SETUP_TIMEOUT);
39+
40+
afterEach(async () => {
41+
jest.clearAllMocks();
42+
const collections = mongoose.connection.collections;
43+
for (const key in collections) {
44+
await collections[key].deleteMany({});
45+
}
46+
});
47+
48+
afterAll(async () => {
49+
await mongoose.disconnect();
50+
await mongoServer.stop();
51+
}, 15_000);
52+
53+
describe('handleDisputeOpened', () => {
54+
it('creates a local Dispute record from the on-chain event', async () => {
55+
await handleDisputeOpened({
56+
disputeId: 'dispute-1',
57+
deliveryId: 'delivery-1',
58+
openedBy: 'GABC...OPENER',
59+
reason: 'Package damaged',
60+
ledgerSequence: 1000,
61+
});
62+
63+
const dispute = await Dispute.findOne({ disputeId: 'dispute-1' });
64+
expect(dispute).not.toBeNull();
65+
expect(dispute?.status).toBe(DisputeStatus.OPEN);
66+
expect(dispute?.deliveryId).toBe('delivery-1');
67+
expect(dispute?.openedLedger).toBe(1000);
68+
});
69+
70+
it('is idempotent when the same disputeId is processed twice', async () => {
71+
const input = {
72+
disputeId: 'dispute-2',
73+
deliveryId: 'delivery-2',
74+
openedBy: 'GABC...OPENER',
75+
ledgerSequence: 500,
76+
};
77+
78+
await handleDisputeOpened(input);
79+
await handleDisputeOpened(input);
80+
81+
const count = await Dispute.countDocuments({ disputeId: 'dispute-2' });
82+
expect(count).toBe(1);
83+
});
84+
85+
it('ignores a malformed payload without throwing', async () => {
86+
await expect(
87+
handleDisputeOpened({
88+
disputeId: '',
89+
deliveryId: 'delivery-3',
90+
openedBy: 'GABC',
91+
ledgerSequence: 1,
92+
}),
93+
).resolves.not.toThrow();
94+
95+
const count = await Dispute.countDocuments();
96+
expect(count).toBe(0);
97+
});
98+
});
99+
100+
describe('handleDisputeResolved', () => {
101+
it('marks an existing dispute resolved', async () => {
102+
await Dispute.create({
103+
disputeId: 'dispute-4',
104+
deliveryId: 'delivery-4',
105+
openedBy: 'GABC...OPENER',
106+
status: DisputeStatus.OPEN,
107+
openedLedger: 100,
108+
});
109+
110+
await handleDisputeResolved({
111+
disputeId: 'dispute-4',
112+
resolution: 'Refund issued',
113+
ledgerSequence: 200,
114+
});
115+
116+
const dispute = await Dispute.findOne({ disputeId: 'dispute-4' });
117+
expect(dispute?.status).toBe(DisputeStatus.RESOLVED);
118+
expect(dispute?.resolution).toBe('Refund issued');
119+
expect(dispute?.resolvedLedger).toBe(200);
120+
expect(dispute?.resolvedAt).toBeInstanceOf(Date);
121+
});
122+
123+
it('does not throw when resolving a dispute that was never opened locally', async () => {
124+
await expect(
125+
handleDisputeResolved({
126+
disputeId: 'unknown-dispute',
127+
resolution: 'n/a',
128+
ledgerSequence: 1,
129+
}),
130+
).resolves.not.toThrow();
131+
});
132+
133+
it('notifies the configured webhook on resolution', async () => {
134+
const originalWebhookUrl = env.DISPUTE_NOTIFICATION_WEBHOOK_URL;
135+
(env as { DISPUTE_NOTIFICATION_WEBHOOK_URL: string }).DISPUTE_NOTIFICATION_WEBHOOK_URL =
136+
'https://example.com/dispute-webhook';
137+
mockedAxiosPost.mockResolvedValue({ status: 200 });
138+
139+
try {
140+
await Dispute.create({
141+
disputeId: 'dispute-5',
142+
deliveryId: 'delivery-5',
143+
openedBy: 'GABC...OPENER',
144+
status: DisputeStatus.OPEN,
145+
openedLedger: 100,
146+
});
147+
148+
await handleDisputeResolved({
149+
disputeId: 'dispute-5',
150+
resolution: 'Refund issued',
151+
ledgerSequence: 200,
152+
});
153+
154+
expect(mockedAxiosPost).toHaveBeenCalledWith(
155+
'https://example.com/dispute-webhook',
156+
expect.objectContaining({ event: 'dispute_resolved', disputeId: 'dispute-5' }),
157+
);
158+
} finally {
159+
(env as { DISPUTE_NOTIFICATION_WEBHOOK_URL: string }).DISPUTE_NOTIFICATION_WEBHOOK_URL =
160+
originalWebhookUrl;
161+
}
162+
});
163+
});
164+
165+
describe('dispatchDisputeEvent', () => {
166+
it('routes dispute_opened to handleDisputeOpened', async () => {
167+
await dispatchDisputeEvent('dispute_opened', {
168+
disputeId: 'dispute-6',
169+
deliveryId: 'delivery-6',
170+
openedBy: 'GABC',
171+
ledgerSequence: 1,
172+
});
173+
174+
const dispute = await Dispute.findOne({ disputeId: 'dispute-6' });
175+
expect(dispute).not.toBeNull();
176+
});
177+
178+
it('ignores unknown event types', async () => {
179+
await expect(
180+
dispatchDisputeEvent('something_else', {
181+
disputeId: 'dispute-7',
182+
deliveryId: 'delivery-7',
183+
openedBy: 'GABC',
184+
ledgerSequence: 1,
185+
}),
186+
).resolves.not.toThrow();
187+
188+
const count = await Dispute.countDocuments({ disputeId: 'dispute-7' });
189+
expect(count).toBe(0);
190+
});
191+
});
192+
193+
describe('disputeService.listDisputes / getDisputeById', () => {
194+
it('paginates and filters by status', async () => {
195+
await Dispute.create([
196+
{
197+
disputeId: 'a',
198+
deliveryId: 'd1',
199+
openedBy: 'G1',
200+
status: DisputeStatus.OPEN,
201+
openedLedger: 1,
202+
},
203+
{
204+
disputeId: 'b',
205+
deliveryId: 'd2',
206+
openedBy: 'G2',
207+
status: DisputeStatus.RESOLVED,
208+
openedLedger: 2,
209+
resolvedLedger: 3,
210+
},
211+
]);
212+
213+
const openOnly = await disputeService.listDisputes(1, 20, DisputeStatus.OPEN);
214+
expect(openOnly.data).toHaveLength(1);
215+
expect(openOnly.data[0].disputeId).toBe('a');
216+
expect(openOnly.pagination.total).toBe(1);
217+
});
218+
219+
it('throws a not-found error for an unknown disputeId', async () => {
220+
await expect(disputeService.getDisputeById('does-not-exist')).rejects.toThrow(/not found/i);
221+
});
222+
});

0 commit comments

Comments
 (0)