Skip to content
Merged
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
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,30 @@ Upstream RPC calls (Stellar/Soroban) are protected by a built-in circuit breaker

Live state is available at `GET /api/v1/circuit-breaker/status`. See [`docs/backend/circuit-breaker.md`](docs/backend/circuit-breaker.md) for full reference.

## New Features

### 1. Authentication Middleware (#55)
All routes under `/api/v1/admin/*` are protected by JWT authentication.
- **Header**: `Authorization: Bearer <token>`
- **Validation**: Ensures token is valid and not expired.

### 2. Event Idempotency (#67)
The `/api/v1/events` endpoint requires an `Idempotency-Key` header to prevent duplicate processing of the same smart contract event.
- **Header**: `Idempotency-Key: <unique-uuid-or-hash>`
- **Behavior**: If a key is seen again within 1 hour, the cached response is returned instead of re-processing.

### 3. Smart-Contract Event Indexer (#70)
A pipeline for indexing escrow and dispute lifecycle updates from smart contracts.
- **Endpoint**: `POST /api/v1/events`
- **Supported Events**: `escrow:created`, `escrow:completed`, `dispute:initiated`, `dispute:resolved`.

## Testing

Run unit and integration tests to verify these features:
```bash
npm test
```

## License

MIT
Expand Down
42 changes: 42 additions & 0 deletions src/middleware/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { authenticateToken, AuthRequest } from './auth';

describe('authenticateToken', () => {
let mockRequest: Partial<AuthRequest>;
let mockResponse: Partial<Response>;
let nextFunction: NextFunction = jest.fn();

beforeEach(() => {
mockRequest = {
headers: {},
};
mockResponse = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
});

it('should return 401 if no Authorization header is present', () => {
authenticateToken(mockRequest as AuthRequest, mockResponse as Response, nextFunction);
expect(mockResponse.status).toHaveBeenCalledWith(401);
expect(mockResponse.json).toHaveBeenCalledWith({ error: 'Authentication token required' });
});

it('should return 403 for an invalid token', () => {
mockRequest.headers!['authorization'] = 'Bearer invalid-token';
authenticateToken(mockRequest as AuthRequest, mockResponse as Response, nextFunction);
expect(mockResponse.status).toHaveBeenCalledWith(403);
expect(mockResponse.json).toHaveBeenCalledWith({ error: 'Invalid authentication token' });
});

it('should call next() for a valid token', () => {
const user = { id: 'user-123' };
const token = jwt.sign(user, process.env.JWT_SECRET || 'tt-dev-secret-keep-it-safe');
mockRequest.headers!['authorization'] = `Bearer ${token}`;

authenticateToken(mockRequest as AuthRequest, mockResponse as Response, nextFunction);
expect(nextFunction).toHaveBeenCalled();
expect((mockRequest as AuthRequest).user).toMatchObject(user);
});
});
68 changes: 68 additions & 0 deletions src/middleware/idempotency.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { Request, Response, NextFunction } from 'express';
import { idempotencyMiddleware, clearIdempotencyStore } from './idempotency';

describe('idempotencyMiddleware', () => {
let mockRequest: Partial<Request>;
let mockResponse: Partial<Response>;
let nextFunction: NextFunction;

beforeEach(() => {
mockRequest = {
headers: {},
};
mockResponse = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
send: jest.fn() as any,
};
nextFunction = jest.fn();
clearIdempotencyStore();
});

it('should return 400 if Idempotency-Key header is missing', () => {
idempotencyMiddleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockResponse.status).toHaveBeenCalledWith(400);
expect(mockResponse.json).toHaveBeenCalledWith({ error: 'Idempotency-Key header is required' });
});

it('should allow operation with new Idempotency-Key', () => {
mockRequest.headers!['idempotency-key'] = 'unique-key-1';
idempotencyMiddleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(nextFunction).toHaveBeenCalled();
});

it('should return 409 if Idempotency-Key is still in processing status', () => {
mockRequest.headers!['idempotency-key'] = 'processing-key';

// First call sets it to processing
idempotencyMiddleware(mockRequest as Request, mockResponse as Response, nextFunction);

// Second call with same key
idempotencyMiddleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockResponse.status).toHaveBeenCalledWith(409);
expect(mockResponse.json).toHaveBeenCalledWith({ error: 'Request is already being processed' });
});

it('should return cached response for completed Idempotency-Key', () => {
mockRequest.headers!['idempotency-key'] = 'completed-key';

// First call sets it to processing
idempotencyMiddleware(mockRequest as Request, mockResponse as Response, nextFunction);

const originalBody = { status: 'success', data: { id: 123 } };
// Simulate completion
(mockResponse.send as any)(JSON.stringify(originalBody));

// Reset mock response to track new call
mockResponse.status = jest.fn().mockReturnThis();
mockResponse.json = jest.fn();

// Replay request
idempotencyMiddleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockResponse.status).toHaveBeenCalledWith(200);
expect(mockResponse.json).toHaveBeenCalledWith({
...originalBody,
idempotencyHeader: 'replay-detected'
});
});
});
70 changes: 70 additions & 0 deletions src/middleware/idempotency.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { Request, Response, NextFunction } from 'express';

// Simple in-memory cache for idempotency keys. In a real application,
// this should be moved to a persistent store like Redis or a database.
const idempotencyStore = new Map<string, {
status: 'processing' | 'completed';
timestamp: number;
result?: any;
}>();

const CACHE_TTL = 3600 * 1000; // 1 hour TTL for idempotency keys

/**
* Middleware to enforce idempotency keys for protected operations
*/
export const idempotencyMiddleware = (req: Request, res: Response, next: NextFunction) => {
const idempotencyKey = req.headers['idempotency-key'] as string;

if (!idempotencyKey) {
return res.status(400).json({ error: 'Idempotency-Key header is required' });
}

// Basic cleanup of old keys (can be optimized or moved to a separate job)
const now = Date.now();
for (const [key, value] of idempotencyStore.entries()) {
if (now - value.timestamp > CACHE_TTL) {
idempotencyStore.delete(key);
}
}

const existingEntry = idempotencyStore.get(idempotencyKey);

if (existingEntry) {
if (existingEntry.status === 'processing') {
return res.status(409).json({ error: 'Request is already being processed' });
}

// If it's already completed, return its cached result
return res.status(existingEntry.status === 'completed' ? 200 : 409).json({
...existingEntry.result,
idempotencyHeader: 'replay-detected'
});
}

// Pre-register the key to prevent race conditions (basic version)
idempotencyStore.set(idempotencyKey, {
status: 'processing',
timestamp: Date.now()
});

// Proxy the original send method to capture the result
const originalSend = res.send;
res.send = function (body: any): Response {
idempotencyStore.set(idempotencyKey, {
status: 'completed',
timestamp: Date.now(),
result: typeof body === 'string' ? JSON.parse(body) : body
});
return originalSend.apply(res, arguments as any);
};

next();
};

/**
* Clean up the idempotency store manually (for testing or maintenance)
*/
export const clearIdempotencyStore = () => {
idempotencyStore.clear();
};
59 changes: 59 additions & 0 deletions src/services/indexer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { EventIndexerService, EventType, SmartContractEvent } from './indexer';

describe('EventIndexerService', () => {
let indexer: EventIndexerService;

beforeEach(() => {
indexer = new EventIndexerService();
});

const sampleEvent: SmartContractEvent = {
contractId: '0x123',
eventType: EventType.EscrowCreated,
payload: { amount: 100 },
timestamp: new Date().toISOString()
};

it('should process and index an event', async () => {
const result = await indexer.processEvent(sampleEvent);
expect(result.status).toBe('indexed');
expect(indexer.getEvents()).toHaveLength(1);
expect(indexer.getEvents()[0]).toMatchObject(sampleEvent);
});

it('should filter events by contractId', async () => {
await indexer.processEvent(sampleEvent);
await indexer.processEvent({
...sampleEvent,
contractId: '0x456',
eventType: EventType.DisputeInitiated
});

const result = indexer.getEventsByContractId('0x123');
expect(result).toHaveLength(1);
expect(result[0].contractId).toBe('0x123');
});

it('should throw error for invalid event data', async () => {
const invalidEvent = { ...sampleEvent, eventType: undefined } as any;
await expect(indexer.processEvent(invalidEvent)).rejects.toThrow('Invalid event data');
});

it('should correctly process different event types', async () => {
const consoleSpy = jest.spyOn(console, 'log');

await indexer.processEvent({
...sampleEvent,
eventType: EventType.DisputeInitiated
});
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('[Indexer] Dispute initiated'));

await indexer.processEvent({
...sampleEvent,
eventType: EventType.DisputeResolved
});
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('[Indexer] Dispute resolved'));

consoleSpy.mockRestore();
});
});
77 changes: 77 additions & 0 deletions src/services/indexer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { Response, Request } from 'express';

export enum EventType {
EscrowCreated = 'escrow:created',
EscrowCompleted = 'escrow:completed',
DisputeInitiated = 'dispute:initiated',
DisputeResolved = 'dispute:resolved',
}

export interface SmartContractEvent {
contractId: string;
eventType: EventType;
idempotencyKey?: string;
payload: any;
timestamp: string;
}

/**
* Service to index smart contract events
*/
export class EventIndexerService {
private indexedEvents: Map<string, SmartContractEvent> = new Map();

constructor() {
this.indexedEvents = new Map();
}

/**
* Process and index a smart contract event
*/
public async processEvent(event: SmartContractEvent): Promise<{ status: string; eventId: string }> {
// Pipeline logic:
// 1. Validation (ensure contractId, type exists)
// 2. Logic to handle specific event types (e.g., escrow or dispute updates)
// 3. Persist (in-memory for now)

if (!event.contractId || !event.eventType) {
throw new Error('Invalid event data');
}

// Pipeline processing:
switch (event.eventType) {
case EventType.EscrowCreated:
console.log(`[Indexer] New escrow created for contract: ${event.contractId}`);
break;
case EventType.DisputeInitiated:
console.log(`[Indexer] Dispute initiated for contract: ${event.contractId}`);
break;
case EventType.DisputeResolved:
console.log(`[Indexer] Dispute resolved for contract: ${event.contractId}`);
break;
default:
console.log(`[Indexer] Processing generic event: ${event.eventType}`);
}

const eventId = `ev-${Date.now()}-${Math.random().toString(36).substring(7)}`;
this.indexedEvents.set(eventId, event);

return { status: 'indexed', eventId };
}

/**
* Fetch indexed events (for demonstration)
*/
public getEvents() {
return Array.from(this.indexedEvents.values());
}

/**
* Fetch specific event by contract ID
*/
public getEventsByContractId(contractId: string) {
return Array.from(this.indexedEvents.values()).filter(e => e.contractId === contractId);
}
}

export const indexerService = new EventIndexerService();
Loading