Skip to content

Commit 5ed1777

Browse files
bandanadivyaAnubhav Singh
authored andcommitted
feat: implement volatility circuit breaker, update_allocations, structured import errors, and import route fix
- Add CircuitBreakerConfig to contract types and call check_volatility in execute_rebalance_internal before computing trades (Issue 1) - Add update_allocations write method with validation and AllocationsUpdated event emission (Issue 4) - Cap portfolioImportService errors at 100 with +N more summary (Issue 2) - Resolve duplicate import route wiring: mount portfolioImportRouter as peer in routes.ts instead of nested in portfoliosRouter (Issue 3) - Fix syntax error in analytics.routes.ts (orphaned lines) - Add unit/integration tests for all changes
1 parent 11ca2c0 commit 5ed1777

10 files changed

Lines changed: 532 additions & 13 deletions

backend/src/api/analytics.routes.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,6 @@ export const analyticsRouter = Router()
5959
const stellarService = new StellarService()
6060
const reflectorService = new ReflectorService()
6161

62-
63-
return fail(res, 500, 'INTERNAL_ERROR', getErrorMessage(error))
64-
}
65-
})
66-
6762
analyticsRouter.get('/portfolio/:id/analytics', async (req: Request, res: Response) => {
6863
try {
6964
const portfolioId = req.params.id

backend/src/api/portfolios.routes.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ import { ok, fail } from '../utils/apiResponse.js'
2222
import { ConflictError } from '../types/index.js'
2323
import { createPortfolioSchema, updatePortfolioSchema, portfolioExportQuerySchema, rebalancePortfolioSchema, portfolioHistoryQuerySchema, portfolioRebalanceHistoryQuerySchema, createDraftSchema, updateDraftSchema } from './validation.js'
2424
import type { Portfolio } from '../types/index.js'
25-
import { portfolioImportRouter } from './portfolioImportRoutes.js'
2625

2726
import type { ExecuteRebalanceOptions } from '../services/stellar.js'
2827
import { acquireWorkerLock, releaseWorkerLock } from '../queue/workers/workerRuntime.js'
@@ -40,9 +39,6 @@ function mapRebalanceOptions(body: any): ExecuteRebalanceOptions {
4039

4140
export const portfoliosRouter = Router()
4241

43-
// Mount bulk import routes
44-
portfoliosRouter.use(portfolioImportRouter)
45-
4642

4743
portfoliosRouter.get('/portfolios', async (req: Request, res: Response) => {
4844
try {

backend/src/api/routes.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Router } from 'express'
22
import { portfoliosRouter } from './portfolios.routes.js'
3+
import { portfolioImportRouter } from './portfolioImportRoutes.js'
34
import { rebalancingRouter } from './rebalancing.routes.js'
45
import { opsRouter } from './ops.routes.js'
56
import { notificationsRouter } from './notifications.routes.js'
@@ -13,6 +14,7 @@ import { adminRouter } from './admin.routes.js'
1314
export const portfolioRouter = Router()
1415

1516
portfolioRouter.use(portfoliosRouter)
17+
portfolioRouter.use(portfolioImportRouter)
1618
portfolioRouter.use(rebalancingRouter)
1719
portfolioRouter.use(opsRouter)
1820
portfolioRouter.use(notificationsRouter)

backend/src/services/portfolioImportService.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@ export type BulkImportValidationError = {
1616
code: string
1717
message: string
1818
errors: BulkImportRowError[]
19-
// meta fields are helpful for frontend UX
2019
totalRows: number
2120
validRows: number
21+
truncatedErrors?: number
2222
}
2323

2424
export type ParsedBulkImportResult = {
@@ -27,6 +27,7 @@ export type ParsedBulkImportResult = {
2727
}
2828

2929
const MAX_ASSETS = 10
30+
const MAX_REPORTED_ERRORS = 100
3031

3132
function normalizeAssetCode(input: string): string {
3233
return input.trim().toUpperCase()
@@ -260,12 +261,19 @@ export async function validateAndBuildAllocations(params: {
260261
}
261262

262263
if (errors.length > 0) {
264+
const truncatedCount = errors.length > MAX_REPORTED_ERRORS ? errors.length - MAX_REPORTED_ERRORS : 0
265+
const cappedErrors = errors.length > MAX_REPORTED_ERRORS
266+
? [...errors.slice(0, MAX_REPORTED_ERRORS), { row: 0, field: 'summary', message: `+${truncatedCount} more errors` }]
267+
: errors
263268
return {
264269
code: 'VALIDATION_ERROR',
265-
message: 'Bulk import validation failed',
266-
errors,
270+
message: truncatedCount > 0
271+
? `Bulk import validation failed (${truncatedCount} additional errors not shown)`
272+
: 'Bulk import validation failed',
273+
errors: cappedErrors,
267274
totalRows: rows.length,
268275
validRows: rows.length - errors.length,
276+
truncatedErrors: truncatedCount > 0 ? truncatedCount : undefined,
269277
}
270278
}
271279

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'
2+
import express from 'express'
3+
import type { Express } from 'express'
4+
import cors from 'cors'
5+
import request from 'supertest'
6+
import { mkdirSync, rmSync, existsSync } from 'node:fs'
7+
import { join } from 'node:path'
8+
import { tmpdir } from 'node:os'
9+
10+
vi.mock('../utils/logger.js', () => ({
11+
logger: {
12+
info: vi.fn(),
13+
warn: vi.fn(),
14+
error: vi.fn(),
15+
},
16+
}))
17+
18+
async function createApp(): Promise<Express> {
19+
const app = express()
20+
app.use(cors({ origin: true, credentials: true }))
21+
app.use(express.json({ limit: '10mb' }))
22+
app.use(express.text({ type: 'text/csv' }))
23+
app.set('trust proxy', 1)
24+
25+
const { portfolioRouter } = await import('../api/routes.js') as any
26+
app.use('/api/v1', portfolioRouter)
27+
28+
return app
29+
}
30+
31+
describe('Portfolio Import Route Integration', () => {
32+
let app: Express
33+
let testDbPath: string
34+
35+
beforeAll(async () => {
36+
process.env.NODE_ENV = 'test'
37+
const testDir = join(tmpdir(), `stellar-import-test-${Date.now()}-${Math.random().toString(36).slice(2)}`)
38+
mkdirSync(testDir, { recursive: true })
39+
testDbPath = join(testDir, 'test.db')
40+
process.env.DB_PATH = testDbPath
41+
app = await createApp()
42+
})
43+
44+
afterAll(() => {
45+
if (existsSync(testDbPath)) {
46+
try { rmSync(testDbPath, { force: true }) } catch {}
47+
}
48+
delete process.env.DB_PATH
49+
})
50+
51+
it('POST /api/v1/portfolio/import responds with no route collisions', async () => {
52+
const res = await request(app)
53+
.post('/api/v1/portfolio/import')
54+
.send({
55+
userAddress: 'GIMPORTTEST123456789ABCDEF',
56+
allocations: [
57+
{ asset: 'XLM', allocation_pct: 60 },
58+
{ asset: 'USDC', allocation_pct: 40 },
59+
],
60+
})
61+
62+
expect([200, 201, 400, 500]).toContain(res.status)
63+
if (res.status === 400) {
64+
expect(res.body.error).toBeDefined()
65+
}
66+
})
67+
68+
it('POST /api/v1/portfolio/import returns validation errors for bad payload', async () => {
69+
const res = await request(app)
70+
.post('/api/v1/portfolio/import')
71+
.send({
72+
userAddress: 'GIMPORTTEST123456789ABCDEF',
73+
allocations: [
74+
{ asset: 'XLM', allocation_pct: 60 },
75+
{ asset: 'USDC', allocation_pct: 30 },
76+
],
77+
})
78+
79+
expect(res.status).toBe(400)
80+
expect(res.body.error).toBeDefined()
81+
})
82+
83+
it('POST /api/v1/portfolio/import rejects missing userAddress', async () => {
84+
const res = await request(app)
85+
.post('/api/v1/portfolio/import')
86+
.send({
87+
allocations: [
88+
{ asset: 'XLM', allocation_pct: 60 },
89+
{ asset: 'USDC', allocation_pct: 40 },
90+
],
91+
})
92+
93+
expect(res.status).toBe(400)
94+
})
95+
96+
it('existing portfolio CRUD routes remain unaffected', async () => {
97+
const res = await request(app)
98+
.post('/api/v1/portfolio')
99+
.send({
100+
userAddress: 'GIMPORTTEST123456789ABCDEF',
101+
allocations: { XLM: 60, USDC: 40 },
102+
threshold: 5,
103+
})
104+
105+
expect([200, 201]).toContain(res.status)
106+
expect(res.body.success).toBe(true)
107+
})
108+
})
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { describe, it, expect, vi } from 'vitest'
2+
import {
3+
parseCsvText,
4+
validateAndBuildAllocations,
5+
coerceJsonRows,
6+
} from '../services/portfolioImportService.js'
7+
8+
vi.mock('../services/assetRegistryService.js', () => ({
9+
assetRegistryService: {
10+
getBySymbol: (sym: string) => {
11+
const registry: Record<string, { enabled: boolean; isQuarantined: boolean }> = {
12+
XLM: { enabled: true, isQuarantined: false },
13+
USDC: { enabled: true, isQuarantined: false },
14+
BTC: { enabled: true, isQuarantined: false },
15+
ETH: { enabled: true, isQuarantined: false },
16+
}
17+
return registry[sym] ?? null
18+
},
19+
},
20+
}))
21+
22+
describe('portfolioImportService - structured error array', () => {
23+
it('returns all row-level errors for mixed valid/invalid CSV rows', async () => {
24+
const csv = [
25+
'asset,allocation_pct',
26+
'XLM,30',
27+
'BAD1,20',
28+
'USDC,25',
29+
',10',
30+
'BTC,abc',
31+
'ETH,15',
32+
].join('\n')
33+
34+
const parsed = parseCsvText(csv)
35+
const result = await validateAndBuildAllocations({
36+
rows: parsed.rows,
37+
initialRowErrors: parsed.errors,
38+
})
39+
40+
expect('errors' in result).toBe(true)
41+
if ('errors' in result) {
42+
const rowErrors = result.errors.filter((e) => e.row >= 2)
43+
expect(rowErrors.length).toBeGreaterThanOrEqual(3)
44+
}
45+
})
46+
47+
it('continues validating remaining rows after one row fails', async () => {
48+
const rows = [
49+
{ asset: 'XLM', allocation_pct: 50 },
50+
{ asset: '', allocation_pct: 20 },
51+
{ asset: 'USDC', allocation_pct: 30 },
52+
]
53+
54+
const { rows: parsedRows, errors: initialErrors } = {
55+
rows,
56+
errors: [] as any[],
57+
}
58+
59+
const result = await validateAndBuildAllocations({
60+
rows: parsedRows,
61+
initialRowErrors: initialErrors,
62+
})
63+
64+
expect('errors' in result).toBe(true)
65+
if ('errors' in result) {
66+
const assetErrors = result.errors.filter(
67+
(e) => e.field === 'asset' && e.row === 3,
68+
)
69+
expect(assetErrors.length).toBeGreaterThan(0)
70+
}
71+
})
72+
73+
it('caps reported errors at 100 with +N more summary', async () => {
74+
const rows: { asset: string; allocation_pct: number }[] = []
75+
for (let i = 0; i < 150; i++) {
76+
rows.push({ asset: '', allocation_pct: NaN })
77+
}
78+
79+
const result = await validateAndBuildAllocations({
80+
rows,
81+
initialRowErrors: [],
82+
})
83+
84+
expect('errors' in result).toBe(true)
85+
if ('errors' in result) {
86+
expect(result.errors.length).toBeLessThanOrEqual(102)
87+
const summaryError = result.errors.find((e) => e.field === 'summary')
88+
expect(summaryError).toBeDefined()
89+
expect(summaryError!.message).toMatch(/\+\d+ more/)
90+
expect(result.truncatedErrors).toBeDefined()
91+
expect(result.truncatedErrors).toBeGreaterThan(0)
92+
}
93+
})
94+
95+
it('returns valid allocations when all rows are correct', async () => {
96+
const rows = [
97+
{ asset: 'XLM', allocation_pct: 60 },
98+
{ asset: 'USDC', allocation_pct: 40 },
99+
]
100+
101+
const result = await validateAndBuildAllocations({
102+
rows,
103+
initialRowErrors: [],
104+
})
105+
106+
expect('allocations' in result).toBe(true)
107+
if ('allocations' in result) {
108+
expect(result.allocations.XLM).toBe(60)
109+
expect(result.allocations.USDC).toBe(40)
110+
}
111+
})
112+
113+
it('coerceJsonRows collects errors for bad rows without stopping', () => {
114+
const jsonRows = [
115+
{ asset: 'XLM', allocation_pct: 50 },
116+
{ asset: '', allocation_pct: 20 },
117+
{ asset: 'USDC', allocation_pct: 'bad' },
118+
{ asset: 'BTC', allocation_pct: 30 },
119+
]
120+
121+
const { errors } = coerceJsonRows(jsonRows)
122+
expect(errors.length).toBeGreaterThanOrEqual(2)
123+
})
124+
})

contracts/src/circuit_breaker.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ pub fn check_volatility(
1111
for (asset, current_price) in current_prices.iter() {
1212
let records = (config.window_seconds / 60).max(1) as u32;
1313

14-
if let Some(historical_price) = client.twap(&crate::reflector::Asset::Stellar(asset.clone()), records) {
14+
if let Some(historical_price) = client.twap(&crate::reflector::Asset::Stellar(asset.clone()), &records) {
1515
if historical_price > 0 {
1616
let diff = current_price - historical_price;
1717
let diff_abs = if diff < 0 { -diff } else { diff };

0 commit comments

Comments
 (0)