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
533 changes: 271 additions & 262 deletions backend/package-lock.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
-- CreateTable
CREATE TABLE "SharePriceSnapshot" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"sharePrice" TEXT NOT NULL,
"recordedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"ledgerSeq" INTEGER,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- CreateIndex
CREATE INDEX "SharePriceSnapshot_recordedAt_idx" ON "SharePriceSnapshot"("recordedAt");

-- CreateIndex
CREATE INDEX "SharePriceSnapshot_ledgerSeq_idx" ON "SharePriceSnapshot"("ledgerSeq");
11 changes: 11 additions & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,17 @@ model VaultState {
updatedAt DateTime @updatedAt
}

model SharePriceSnapshot {
id Int @id @default(autoincrement())
sharePrice String
recordedAt DateTime @default(now())
ledgerSeq Int?
createdAt DateTime @default(now())

@@index([recordedAt])
@@index([ledgerSeq])
}

model Transaction {
id String @id @default(uuid())
user String
Expand Down
8 changes: 7 additions & 1 deletion backend/src/__tests__/adminFeatures.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { resetAuditLogs } from '../auditLog';
describe('Admin backend features', () => {
const adminKey = 'admin-feature-test-key';
const authHeader = { Authorization: `ApiKey ${adminKey}` };
const walletAddress = 'GABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz234567';

beforeAll(() => {
registerApiKey(adminKey);
Expand Down Expand Up @@ -36,12 +37,17 @@ describe('Admin backend features', () => {

expect(webhookResponse.status).toBe(201);

await request(app)
.post('/admin/allowlist/add')
.set(authHeader)
.send({ walletAddress, reason: 'admin-feature-test' });

const depositResponse = await request(app)
.post('/api/v1/vault/deposits')
.send({
amount: '125.00',
asset: 'USDC',
walletAddress: 'GABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz234567',
walletAddress,
});

expect(depositResponse.status).toBe(201);
Expand Down
166 changes: 166 additions & 0 deletions backend/src/__tests__/eventPollingService.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { EventPollingService } from '../eventPollingService';
import { getPrismaClient } from '../prismaClient';
import { createStellarRpcFetchMock } from './mocks/stellarRpc';

const prisma = getPrismaClient();

Expand Down Expand Up @@ -349,4 +350,169 @@
expect(true).toBe(true);
});
});

describe('Failure and Gap-Recovery Scenarios', () => {
it('retries after an RPC timeout without dropping events', async () => {
let cursor = 1000;
const storedEvents = new Set<string>();
let eventsCallCount = 0;

(prisma.eventCursor.findUnique as jest.Mock).mockImplementation(async () => ({
id: 1,
lastLedgerSeq: cursor,
}));

(prisma.eventCursor.upsert as jest.Mock).mockImplementation(async ({ update }) => {
cursor = update.lastLedgerSeq;
return { id: 1, lastLedgerSeq: cursor };
});

(prisma.processedEvent.findUnique as jest.Mock).mockImplementation(async ({ where }) => {
return storedEvents.has(where.id) ? { id: where.id } : null;
});

(prisma.processedEvent.upsert as jest.Mock).mockImplementation(async ({ create }) => {
storedEvents.add(create.id);
return create;
});

global.fetch = createStellarRpcFetchMock(async ({ method }) => {
if (method === 'getLatestLedger') {
return { result: { sequence: 1002 } };
}

eventsCallCount += 1;
if (eventsCallCount === 1) {
throw new Error('RPC timeout');
}

return {
result: {
events: [
{
id: 'event-1001',
type: 'contract',
ledger: 1001,
contractId: 'CTEST123',
txHash: 'tx-1001',
},
{
id: 'event-1002',
type: 'contract',
ledger: 1002,
contractId: 'CTEST123',
txHash: 'tx-1002',
},
],
},
};
});

await (service as any).pollEvents();

Check warning on line 411 in backend/src/__tests__/eventPollingService.test.ts

View workflow job for this annotation

GitHub Actions / backend-governance

Unexpected any. Specify a different type

Check warning on line 411 in backend/src/__tests__/eventPollingService.test.ts

View workflow job for this annotation

GitHub Actions / Backend lint + test

Unexpected any. Specify a different type
expect(cursor).toBe(1000);
expect(prisma.processedEvent.upsert).not.toHaveBeenCalled();

await (service as any).pollEvents();

Check warning on line 415 in backend/src/__tests__/eventPollingService.test.ts

View workflow job for this annotation

GitHub Actions / backend-governance

Unexpected any. Specify a different type

Check warning on line 415 in backend/src/__tests__/eventPollingService.test.ts

View workflow job for this annotation

GitHub Actions / Backend lint + test

Unexpected any. Specify a different type

expect(prisma.processedEvent.upsert).toHaveBeenCalledTimes(2);
expect(cursor).toBe(1002);
expect(storedEvents.has('event-1001')).toBe(true);
expect(storedEvents.has('event-1002')).toBe(true);
});

it('re-fetches all missing ledger ranges during replay', async () => {
const requestedRanges: Array<{ start: number; end: number }> = [];
const replayService = new EventPollingService({
...mockConfig,
batchSize: 100,
});

(prisma.eventCursor.findUnique as jest.Mock).mockResolvedValue({
id: 1,
lastLedgerSeq: 1000,
});

(prisma.eventCursor.upsert as jest.Mock).mockResolvedValue({});
(prisma.processedEvent.findUnique as jest.Mock).mockResolvedValue(null);

global.fetch = createStellarRpcFetchMock(async ({ method, params }) => {
if (method === 'getLatestLedger') {
return { result: { sequence: 1200 } };
}

requestedRanges.push({
start: params.startLedger,
end: params.startLedger + 99,
});

return {
result: {
events: [],
},
};
});

await replayService.start();
await replayService.stop();

expect(requestedRanges).toEqual([
{ start: 1001, end: 1100 },
{ start: 1101, end: 1200 },
]);
expect(prisma.eventCursor.upsert).toHaveBeenCalledTimes(2);
});

it('handles duplicate event delivery idempotently with one DB record', async () => {
const storedEvents = new Set<string>();

(prisma.eventCursor.findUnique as jest.Mock).mockResolvedValue({
id: 1,
lastLedgerSeq: 1000,
});

(prisma.eventCursor.upsert as jest.Mock).mockResolvedValue({});

(prisma.processedEvent.findUnique as jest.Mock).mockImplementation(async ({ where }) => {
return storedEvents.has(where.id) ? { id: where.id } : null;
});

(prisma.processedEvent.upsert as jest.Mock).mockImplementation(async ({ create }) => {
storedEvents.add(create.id);
return create;
});

global.fetch = createStellarRpcFetchMock(async ({ method }) => {
if (method === 'getLatestLedger') {
return { result: { sequence: 1010 } };
}

return {
result: {
events: [
{
id: 'duplicate-event',
type: 'contract',
ledger: 1005,
contractId: 'CTEST123',
txHash: 'tx-dup',
},
{
id: 'duplicate-event',
type: 'contract',
ledger: 1005,
contractId: 'CTEST123',
txHash: 'tx-dup',
},
],
},
};
});

await service.start();

expect(prisma.processedEvent.upsert).toHaveBeenCalledTimes(1);
expect(storedEvents.size).toBe(1);
expect(storedEvents.has('duplicate-event')).toBe(true);
});
});
});
14 changes: 12 additions & 2 deletions backend/src/__tests__/governance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ describe('Backend governance', () => {
walletAddress: 'GABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz234567',
};

await request(app)
.post('/admin/allowlist/add')
.set('Authorization', `ApiKey ${adminApiKey}`)
.send({ walletAddress: payload.walletAddress, reason: 'governance-test' });

const first = await request(app)
.post('/api/v1/vault/deposits')
.set('x-idempotency-key', 'deposit-key-1')
Expand All @@ -50,13 +55,18 @@ describe('Backend governance', () => {
});

it('rejects conflicting requests that reuse the same idempotency key', async () => {
await request(app)
.post('/admin/allowlist/add')
.set('Authorization', `ApiKey ${adminApiKey}`)
.send({ walletAddress: targetWallet, reason: 'governance-test' });

const first = await request(app)
.post('/api/v1/vault/deposits')
.set('x-idempotency-key', 'deposit-key-2')
.send({
amount: 250,
asset: 'USDC',
walletAddress: 'GABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz234567',
walletAddress: targetWallet,
});

const second = await request(app)
Expand All @@ -65,7 +75,7 @@ describe('Backend governance', () => {
.send({
amount: 300,
asset: 'USDC',
walletAddress: 'GABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz234567',
walletAddress: targetWallet,
});

expect(first.status).toBe(201);
Expand Down
6 changes: 3 additions & 3 deletions backend/src/__tests__/latencyMonitoring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@
});

// Manually trigger alert check for testing
await (newService as any).checkSLOViolations();

Check warning on line 192 in backend/src/__tests__/latencyMonitoring.test.ts

View workflow job for this annotation

GitHub Actions / backend-governance

Unexpected any. Specify a different type

Check warning on line 192 in backend/src/__tests__/latencyMonitoring.test.ts

View workflow job for this annotation

GitHub Actions / Backend lint + test

Unexpected any. Specify a different type

expect(global.fetch).toHaveBeenCalledWith(
'https://hooks.slack.com/test',
Expand All @@ -216,7 +216,7 @@
});

// Manually trigger alert check for testing
await (newService as any).checkSLOViolations();

Check warning on line 219 in backend/src/__tests__/latencyMonitoring.test.ts

View workflow job for this annotation

GitHub Actions / backend-governance

Unexpected any. Specify a different type

Check warning on line 219 in backend/src/__tests__/latencyMonitoring.test.ts

View workflow job for this annotation

GitHub Actions / Backend lint + test

Unexpected any. Specify a different type

expect(global.fetch).toHaveBeenCalledWith(
'https://events.pagerduty.com/v2/enqueue',
Expand All @@ -243,7 +243,7 @@
});

// Should not throw error, just log warning
await expect((newService as any).checkSLOViolations()).resolves.not.toThrow();

Check warning on line 246 in backend/src/__tests__/latencyMonitoring.test.ts

View workflow job for this annotation

GitHub Actions / backend-governance

Unexpected any. Specify a different type

Check warning on line 246 in backend/src/__tests__/latencyMonitoring.test.ts

View workflow job for this annotation

GitHub Actions / Backend lint + test

Unexpected any. Specify a different type
expect(global.fetch).not.toHaveBeenCalled();
});
});
Expand Down Expand Up @@ -302,15 +302,15 @@
freshService.recordLatency(endpoint, 500);

const metricsBefore = freshService.getDetailedMetrics();
const metricBefore = metricsBefore.find(m => m.endpoint === endpoint);
const metricBefore = metricsBefore.find((m: any) => m.endpoint === endpoint);

Check warning on line 305 in backend/src/__tests__/latencyMonitoring.test.ts

View workflow job for this annotation

GitHub Actions / backend-governance

Unexpected any. Specify a different type

Check warning on line 305 in backend/src/__tests__/latencyMonitoring.test.ts

View workflow job for this annotation

GitHub Actions / Backend lint + test

Unexpected any. Specify a different type
expect(metricBefore?.currentP95).toBe(500);
expect(metricBefore?.isBreaching).toBe(true);

// Wait for the evaluation window to expire
return new Promise<void>((resolve) => {
setTimeout(() => {
const metricsAfter = freshService.getDetailedMetrics();
const metricAfter = metricsAfter.find(m => m.endpoint === endpoint);
const metricAfter = metricsAfter.find((m: any) => m.endpoint === endpoint);

Check warning on line 313 in backend/src/__tests__/latencyMonitoring.test.ts

View workflow job for this annotation

GitHub Actions / backend-governance

Unexpected any. Specify a different type

Check warning on line 313 in backend/src/__tests__/latencyMonitoring.test.ts

View workflow job for this annotation

GitHub Actions / Backend lint + test

Unexpected any. Specify a different type
// Stale data should be pruned, P95 should drop to 0
expect(metricAfter?.currentP95).toBe(0);
expect(metricAfter?.isBreaching).toBe(false);
Expand All @@ -337,7 +337,7 @@
freshService.recordLatency(endpoint, 100);

const metrics = freshService.getDetailedMetrics();
const metric = metrics.find(m => m.endpoint === endpoint);
const metric = metrics.find((m: any) => m.endpoint === endpoint);

Check warning on line 340 in backend/src/__tests__/latencyMonitoring.test.ts

View workflow job for this annotation

GitHub Actions / backend-governance

Unexpected any. Specify a different type

Check warning on line 340 in backend/src/__tests__/latencyMonitoring.test.ts

View workflow job for this annotation

GitHub Actions / Backend lint + test

Unexpected any. Specify a different type
// Only the fresh data point should count
expect(metric?.dataPoints).toBe(1);
expect(metric?.currentP95).toBe(100);
Expand Down
18 changes: 18 additions & 0 deletions backend/src/__tests__/mocks/stellarRpc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
type RpcMethod = 'getLatestLedger' | 'getEvents';

interface RpcRequest {
method: RpcMethod;
params?: any;
}

type RpcHandler = (request: RpcRequest) => Promise<any>;

export function createStellarRpcFetchMock(handler: RpcHandler): jest.Mock {
return jest.fn(async (_url: string, options: any) => {
const body = JSON.parse(options.body || '{}');
const payload = await handler({ method: body.method, params: body.params });
return {
json: async () => payload,
};
});
}
Loading
Loading