From 9b044fab6b2b8451e3aa6117c09b580fd059b707 Mon Sep 17 00:00:00 2001 From: Adam Onyekachi Esegbue Date: Wed, 2 Sep 2026 16:25:02 +0100 Subject: [PATCH 1/4] fix(financial): support dynamic discount rates in sensitivity analysis (#522) --- src/__tests__/financial.test.ts | 40 ++++++++++++++++ src/lib/financial.ts | 82 +++++++++++++++++++-------------- 2 files changed, 87 insertions(+), 35 deletions(-) diff --git a/src/__tests__/financial.test.ts b/src/__tests__/financial.test.ts index 885769e..daf9c29 100644 --- a/src/__tests__/financial.test.ts +++ b/src/__tests__/financial.test.ts @@ -154,6 +154,46 @@ describe("performSensitivityAnalysis", () => { expect(parameters.has("Degradation Rate")).toBe(true); expect(parameters.has("Energy Output")).toBe(true); }); + + it("discount rate sensitivity uses additive deltas, not multiplicative multipliers", () => { + const input10 = createDefaultFinancialInput(500, 80, { discount_rate: 0.1 }); + const result = performSensitivityAnalysis(input10); + const drPoints = result.sensitivities.filter((s) => s.parameter === "Discount Rate"); + + // The "+1%" label means base (0.10) + 0.01 = 0.11, so effective multiplier ≈ 1.10 + const plus1 = drPoints.find((s) => s.change === "+1%"); + expect(plus1).toBeDefined(); + expect(plus1!.multiplier).toBeCloseTo(0.11 / 0.1, 6); + + // The "-2%" label means base (0.10) - 0.02 = 0.08, so effective multiplier = 0.80 + const minus2 = drPoints.find((s) => s.change === "-2%"); + expect(minus2).toBeDefined(); + expect(minus2!.multiplier).toBeCloseTo(0.08 / 0.1, 6); + + // The "+2%" label means base (0.10) + 0.02 = 0.12 + const plus2 = drPoints.find((s) => s.change === "+2%"); + expect(plus2).toBeDefined(); + expect(plus2!.multiplier).toBeCloseTo(0.12 / 0.1, 6); + + // Higher discount rate should reduce NPV + expect(minus2!.npv).toBeGreaterThan(plus1!.npv); + expect(plus1!.npv).toBeGreaterThan(plus2!.npv); + }); + + it("discount rate sensitivity is correct for the default 7% base rate too", () => { + const result = performSensitivityAnalysis(BASE_INPUT); + const drPoints = result.sensitivities.filter((s) => s.parameter === "Discount Rate"); + + // Base is 0.07; "+1%" means 0.07 + 0.01 = 0.08 → multiplier ≈ 0.08/0.07 ≈ 1.1429 + const plus1 = drPoints.find((s) => s.change === "+1%"); + expect(plus1).toBeDefined(); + expect(plus1!.multiplier).toBeCloseTo(0.08 / 0.07, 4); + + // "-2%" means 0.07 - 0.02 = 0.05 → multiplier ≈ 0.05/0.07 ≈ 0.7143 + const minus2 = drPoints.find((s) => s.change === "-2%"); + expect(minus2).toBeDefined(); + expect(minus2!.multiplier).toBeCloseTo(0.05 / 0.07, 4); + }); }); describe("compareROI", () => { diff --git a/src/lib/financial.ts b/src/lib/financial.ts index 3305826..73a4285 100644 --- a/src/lib/financial.ts +++ b/src/lib/financial.ts @@ -164,7 +164,7 @@ function calculateIRR(cashFlows: number[], guess = 0.1): number { let dnpv = 0; for (let t = 0; t < cashFlows.length; t++) { npv += cashFlows[t] / Math.pow(1 + rate, t); - dnpv += -t * cashFlows[t] / Math.pow(1 + rate, t + 1); + dnpv += (-t * cashFlows[t]) / Math.pow(1 + rate, t + 1); } if (Math.abs(npv) < tolerance) { return round(rate, 4); @@ -180,7 +180,7 @@ export function createDefaultFinancialInput( _efficiencyPct: number, partial?: Partial, ): FinancialInput { - const capacityFactor = 0.20; + const capacityFactor = 0.2; const annualEnergy = capacityKw * 8760 * capacityFactor; const installationCostPerKw = 1000; const maintenancePerKwPerYear = 15; @@ -190,13 +190,13 @@ export function createDefaultFinancialInput( installation_cost: capacityKw * installationCostPerKw, annual_maintenance_cost: capacityKw * maintenancePerKwPerYear, annual_energy_output_kwh: annualEnergy, - electricity_price_per_kwh: 0.10, + electricity_price_per_kwh: 0.1, degradation_rate: 0.005, discount_rate: 0.07, inflation_rate: 0.02, project_lifetime_years: 25, - tax_incentives: capacityKw * installationCostPerKw * 0.30, - salvage_value: capacityKw * installationCostPerKw * 0.10, + tax_incentives: capacityKw * installationCostPerKw * 0.3, + salvage_value: capacityKw * installationCostPerKw * 0.1, capacity_factor: capacityFactor, }; @@ -204,10 +204,10 @@ export function createDefaultFinancialInput( const finalInstallCost = merged.installation_cost; if (partial?.tax_incentives === undefined) { - merged.tax_incentives = finalInstallCost * 0.30; + merged.tax_incentives = finalInstallCost * 0.3; } if (partial?.salvage_value === undefined) { - merged.salvage_value = finalInstallCost * 0.10; + merged.salvage_value = finalInstallCost * 0.1; } return merged; @@ -218,7 +218,9 @@ export function calculateCostBenefit(input: FinancialInput): CostBenefitResult { const operatingCashFlows = cashFlows.filter((cf) => cf.year > 0); const totalRevenue = round(operatingCashFlows.reduce((sum, cf) => sum + cf.revenue, 0)); - const totalMaintenance = round(operatingCashFlows.reduce((sum, cf) => sum + cf.maintenance_cost, 0)); + const totalMaintenance = round( + operatingCashFlows.reduce((sum, cf) => sum + cf.maintenance_cost, 0), + ); const totalInstallation = input.installation_cost; const totalOperating = totalMaintenance; const totalCost = round(totalInstallation + totalOperating); @@ -261,7 +263,7 @@ export function calculatePaybackPeriod(input: FinancialInput): PaybackPeriodResu if (cum >= 0) { const prevCum = cum - netCashFlows[i]; if (netCashFlows[i] !== 0) { - simplePaybackYears = (i - 1) + Math.abs(prevCum) / netCashFlows[i]; + simplePaybackYears = i - 1 + Math.abs(prevCum) / netCashFlows[i]; } else { simplePaybackYears = i; } @@ -276,7 +278,7 @@ export function calculatePaybackPeriod(input: FinancialInput): PaybackPeriodResu if (discCum >= 0) { const prevDiscCum = discCum - discountedCashFlows[i]; if (discountedCashFlows[i] !== 0) { - discountedPaybackYears = (i - 1) + Math.abs(prevDiscCum) / discountedCashFlows[i]; + discountedPaybackYears = i - 1 + Math.abs(prevDiscCum) / discountedCashFlows[i]; } else { discountedPaybackYears = i; } @@ -319,10 +321,14 @@ export function calculateNPV(input: FinancialInput): NPVResult { }; } +type SensitivityVariation = + | { label: string; kind: "multiplier"; value: number } + | { label: string; kind: "delta"; value: number }; + type SensitivityParam = { key: keyof FinancialInput; label: string; - variations: { label: string; multiplier: number }[]; + variations: SensitivityVariation[]; }; const SENSITIVITY_PARAMS: SensitivityParam[] = [ @@ -330,49 +336,49 @@ const SENSITIVITY_PARAMS: SensitivityParam[] = [ key: "installation_cost", label: "Installation Cost", variations: [ - { label: "-20%", multiplier: 0.80 }, - { label: "-10%", multiplier: 0.90 }, - { label: "+10%", multiplier: 1.10 }, - { label: "+20%", multiplier: 1.20 }, + { label: "-20%", kind: "multiplier", value: 0.8 }, + { label: "-10%", kind: "multiplier", value: 0.9 }, + { label: "+10%", kind: "multiplier", value: 1.1 }, + { label: "+20%", kind: "multiplier", value: 1.2 }, ], }, { key: "electricity_price_per_kwh", label: "Electricity Price", variations: [ - { label: "-20%", multiplier: 0.80 }, - { label: "-10%", multiplier: 0.90 }, - { label: "+10%", multiplier: 1.10 }, - { label: "+20%", multiplier: 1.20 }, + { label: "-20%", kind: "multiplier", value: 0.8 }, + { label: "-10%", kind: "multiplier", value: 0.9 }, + { label: "+10%", kind: "multiplier", value: 1.1 }, + { label: "+20%", kind: "multiplier", value: 1.2 }, ], }, { key: "discount_rate", label: "Discount Rate", variations: [ - { label: "-2%", multiplier: (1 / 0.07) * 0.05 }, - { label: "-1%", multiplier: (1 / 0.07) * 0.06 }, - { label: "+1%", multiplier: (1 / 0.07) * 0.08 }, - { label: "+2%", multiplier: (1 / 0.07) * 0.09 }, + { label: "-2%", kind: "delta", value: -0.02 }, + { label: "-1%", kind: "delta", value: -0.01 }, + { label: "+1%", kind: "delta", value: 0.01 }, + { label: "+2%", kind: "delta", value: 0.02 }, ], }, { key: "degradation_rate", label: "Degradation Rate", variations: [ - { label: "-0.25%", multiplier: 0.5 }, - { label: "+0.25%", multiplier: 1.5 }, - { label: "+0.50%", multiplier: 2.0 }, + { label: "-0.25%", kind: "multiplier", value: 0.5 }, + { label: "+0.25%", kind: "multiplier", value: 1.5 }, + { label: "+0.50%", kind: "multiplier", value: 2.0 }, ], }, { key: "annual_energy_output_kwh", label: "Energy Output", variations: [ - { label: "-20%", multiplier: 0.80 }, - { label: "-10%", multiplier: 0.90 }, - { label: "+10%", multiplier: 1.10 }, - { label: "+20%", multiplier: 1.20 }, + { label: "-20%", kind: "multiplier", value: 0.8 }, + { label: "-10%", kind: "multiplier", value: 0.9 }, + { label: "+10%", kind: "multiplier", value: 1.1 }, + { label: "+20%", kind: "multiplier", value: 1.2 }, ], }, ]; @@ -392,10 +398,14 @@ export function performSensitivityAnalysis(input: FinancialInput): SensitivityRe for (const param of SENSITIVITY_PARAMS) { for (const variation of param.variations) { let variedInput: FinancialInput; - if (param.key === "discount_rate") { - variedInput = { ...input, [param.key]: input[param.key] * variation.multiplier }; + let effectiveMultiplier: number; + if (variation.kind === "delta") { + const variedValue = (input[param.key] as number) + variation.value; + variedInput = { ...input, [param.key]: variedValue }; + effectiveMultiplier = variedValue / (input[param.key] as number); } else { - variedInput = { ...input, [param.key]: (input[param.key] as number) * variation.multiplier }; + variedInput = { ...input, [param.key]: (input[param.key] as number) * variation.value }; + effectiveMultiplier = variation.value; } const npvResult = calculateNPV(variedInput); const paybackResult = calculatePaybackPeriod(variedInput); @@ -404,7 +414,7 @@ export function performSensitivityAnalysis(input: FinancialInput): SensitivityRe label: `${param.label} ${variation.label}`, parameter: param.label, change: variation.label, - multiplier: variation.multiplier, + multiplier: effectiveMultiplier, npv: npvResult.npv, payback_years: paybackResult.payback_years, irr: npvResult.irr, @@ -434,7 +444,9 @@ function calculateProjectROI(projectId: number, input: FinancialInput): ProjectR }; } -export function compareROI(projects: { project_id: number; input: FinancialInput }[]): ROIComparisonResult { +export function compareROI( + projects: { project_id: number; input: FinancialInput }[], +): ROIComparisonResult { const all = projects.map((p) => calculateProjectROI(p.project_id, p.input)); const byROI = [...all].sort((a, b) => b.roi_pct - a.roi_pct); From 70027b74ba0e76121c1537e81912f5954be848b8 Mon Sep 17 00:00:00 2001 From: Adam Onyekachi Esegbue Date: Wed, 2 Sep 2026 16:42:31 +0100 Subject: [PATCH 2/4] fix(build): resolve fetch Response type conflict in satellite-sources --- src/routes/satellite-sources.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/routes/satellite-sources.ts b/src/routes/satellite-sources.ts index fafb437..8615f6d 100644 --- a/src/routes/satellite-sources.ts +++ b/src/routes/satellite-sources.ts @@ -61,7 +61,7 @@ async function fetchFromCustomUrl( const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), CUSTOM_SOURCE_FETCH_TIMEOUT_MS); - let response: Response; + let response: globalThis.Response; try { response = await fetch(`${fetchUrl}?projectId=${encodeURIComponent(String(projectId))}`, { method: "GET", @@ -116,7 +116,7 @@ router.post("/", (req: Request, res: Response) => { } try { - // eslint-disable-next-line no-new + new URL(fetchUrl); } catch { return res.status(400).json({ error: "fetchUrl must be a valid URL" }); From dd21764e34414302d907d1980f2b50260537a4ee Mon Sep 17 00:00:00 2001 From: Adam Onyekachi Esegbue Date: Wed, 2 Sep 2026 16:47:26 +0100 Subject: [PATCH 3/4] fix(build): remove duplicate errorBody import in admin route --- src/routes/admin.ts | 247 +++++++++++++++++++++++--------------------- 1 file changed, 129 insertions(+), 118 deletions(-) diff --git a/src/routes/admin.ts b/src/routes/admin.ts index f1e0310..af8eaef 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -1,9 +1,8 @@ import { Router, Request, Response, NextFunction } from "express"; -import { errorBody } from "../middleware/errors"; import { getSolarData, getSatelliteData } from "./iot"; import { computeScores } from "../lib/scoring"; import { updateImpactScore, getTotalProjects } from "../lib/registry"; -import { badRequest, parseOptionalInt, MAX_PROJECT_ID, errorBody } from "../middleware/errors"; +import { badRequest, parseOptionalInt, MAX_PROJECT_ID } from "../middleware/errors"; import { recordAudit, getAuditLog, auditToCsv } from "../lib/audit"; import { broadcastScoreUpdate } from "../lib/websocket"; import { tryBeginUpdate, markCompleted, markFailed } from "../lib/duplicate-detection"; @@ -11,7 +10,11 @@ import { withProjectLock } from "../lib/request-queue"; import { updateScoreForProject } from "../lib/scoreService"; import { config } from "../config"; import { logger } from "../lib/logger"; -import { extractApiKeyRole, requireApiKeyRole, requireApiKeyAuth } from "../middleware/requireApiKeyRole"; +import { + extractApiKeyRole, + requireApiKeyRole, + requireApiKeyAuth, +} from "../middleware/requireApiKeyRole"; const router = Router(); @@ -96,146 +99,154 @@ function parseProjectIds(body: unknown): number[] | null { // forwarded to the central errorHandler via next() so status codes stay consistent // across all endpoints. The nested per-project catch is intentional: it collects // partial failures without aborting the entire batch. -router.post("/update-scores", requireApiKeyRole("admin:write"), async (req: Request, res: Response, next: NextFunction) => { - try { - const requested = parseProjectIds(req.body); - - let projectIds: number[]; - - if (requested) { - projectIds = requested; - } else { - const total = await getTotalProjects(); - projectIds = Array.from({ length: total }, (_, i) => i + 1); - } +router.post( + "/update-scores", + requireApiKeyRole("admin:write"), + async (req: Request, res: Response, next: NextFunction) => { + try { + const requested = parseProjectIds(req.body); + + let projectIds: number[]; + + if (requested) { + projectIds = requested; + } else { + const total = await getTotalProjects(); + projectIds = Array.from({ length: total }, (_, i) => i + 1); + } - const results: ScoreUpdateResult[] = []; - const errors: Array<{ project_id: number; error: { code: string; message: string } }> = []; - const skipped: Array<{ project_id: number; reason: string }> = []; - - // Soroban does not support multi-call batching — submit sequentially. - // Each project is individually isolated: a failure on one does not abort - // the rest. Accumulated errors are returned alongside successes so callers - // can retry only the affected ids. - for (const projectId of projectIds) { - try { - const result = await withProjectLock(projectId, async () => { - const { allowed, reason } = tryBeginUpdate(projectId); - if (!allowed) { - return { skipped: true, reason }; - } - try { - const scoreResult = await updateScoreForProject(projectId); + const results: ScoreUpdateResult[] = []; + const errors: Array<{ project_id: number; error: { code: string; message: string } }> = []; + const skipped: Array<{ project_id: number; reason: string }> = []; + + // Soroban does not support multi-call batching — submit sequentially. + // Each project is individually isolated: a failure on one does not abort + // the rest. Accumulated errors are returned alongside successes so callers + // can retry only the affected ids. + for (const projectId of projectIds) { + try { + const result = await withProjectLock(projectId, async () => { + const { allowed, reason } = tryBeginUpdate(projectId); + if (!allowed) { + return { skipped: true, reason }; + } + try { + const scoreResult = await updateScoreForProject(projectId); + + if (scoreResult.status === "deferred") { + logger.warn(`[oracle] project ${projectId}: RPC degraded, score queued for later`); + markCompleted(projectId); + return { + skipped: false, + project_id: projectId, + tx_hash: "deferred", + credit_quality: scoreResult.creditQuality, + green_impact: scoreResult.greenImpact, + }; + } + + if (scoreResult.status === "error") { + throw new Error(scoreResult.error); + } - if (scoreResult.status === "deferred") { - logger.warn(`[oracle] project ${projectId}: RPC degraded, score queued for later`); markCompleted(projectId); + recordAudit({ + project_id: projectId, + credit_quality: scoreResult.creditQuality, + green_impact: scoreResult.greenImpact, + tx_hash: scoreResult.txHash, + triggered_by: "api", + }); + broadcastScoreUpdate({ + project_id: projectId, + credit_quality: scoreResult.creditQuality, + green_impact: scoreResult.greenImpact, + timestamp: Date.now(), + }); + logger.info( + `[oracle] project ${projectId}: cq=${scoreResult.creditQuality} gi=${scoreResult.greenImpact} tx=${scoreResult.txHash}`, + ); return { skipped: false, project_id: projectId, - tx_hash: "deferred", + tx_hash: scoreResult.txHash, credit_quality: scoreResult.creditQuality, green_impact: scoreResult.greenImpact, }; + } catch (err) { + markFailed(projectId); + throw err; } + }); - if (scoreResult.status === "error") { - throw new Error(scoreResult.error); - } - - markCompleted(projectId); - recordAudit({ - project_id: projectId, - credit_quality: scoreResult.creditQuality, - green_impact: scoreResult.greenImpact, - tx_hash: scoreResult.txHash, - triggered_by: "api", - }); - broadcastScoreUpdate({ - project_id: projectId, - credit_quality: scoreResult.creditQuality, - green_impact: scoreResult.greenImpact, - timestamp: Date.now(), + if (result.skipped) { + skipped.push({ project_id: projectId, reason: result.reason }); + logger.info(`[oracle] skipping project ${projectId}: ${result.reason}`); + } else { + // Rebuilt field by field so the internal `skipped` discriminant does + // not leak into the response body. + results.push({ + project_id: result.project_id, + tx_hash: result.tx_hash, + credit_quality: result.credit_quality, + green_impact: result.green_impact, }); - logger.info( - `[oracle] project ${projectId}: cq=${scoreResult.creditQuality} gi=${scoreResult.greenImpact} tx=${scoreResult.txHash}`, - ); - return { - skipped: false, - project_id: projectId, - tx_hash: scoreResult.txHash, - credit_quality: scoreResult.creditQuality, - green_impact: scoreResult.greenImpact, - }; - } catch (err) { - markFailed(projectId); - throw err; } - }); - - if (result.skipped) { - skipped.push({ project_id: projectId, reason: result.reason }); - logger.info(`[oracle] skipping project ${projectId}: ${result.reason}`); - } else { - // Rebuilt field by field so the internal `skipped` discriminant does - // not leak into the response body. - results.push({ - project_id: result.project_id, - tx_hash: result.tx_hash, - credit_quality: result.credit_quality, - green_impact: result.green_impact, + } catch (err) { + logger.error(`[oracle] project ${projectId} failed`, logger.formatError(err)); + errors.push({ + project_id: projectId, + error: { + code: "update_failed", + message: err instanceof Error ? err.message : String(err), + }, }); } - } catch (err) { - logger.error(`[oracle] project ${projectId} failed`, logger.formatError(err)); - errors.push({ - project_id: projectId, - error: { - code: "update_failed", - message: err instanceof Error ? err.message : String(err), - }, - }); } - } - res.json({ updated: results.length, results, errors, skipped }); - } catch (error) { - // Forward to errorHandler: ApiError → its .status (e.g. 400 for bad input), - // SyntaxError → 400, anything else → 500. - next(error); - } -}); + res.json({ updated: results.length, results, errors, skipped }); + } catch (error) { + // Forward to errorHandler: ApiError → its .status (e.g. 400 for bad input), + // SyntaxError → 400, anything else → 500. + next(error); + } + }, +); /** * GET /admin/audit * Query: project_id=, from=, to=, format=json|csv * Returns the immutable audit log of all score updates. */ -router.get("/audit", requireApiKeyRole("admin:read"), (req: Request, res: Response, next: NextFunction) => { - try { - const project_id = - parseOptionalInt(queryValue(req.query.project_id), "project_id", 0) || undefined; - const from = parseOptionalInt(queryValue(req.query.from), "from", 0) || undefined; - const to = parseOptionalInt(queryValue(req.query.to), "to", 0) || undefined; - - if (from && to && from > to) { - throw badRequest("from must be earlier than to"); - } +router.get( + "/audit", + requireApiKeyRole("admin:read"), + (req: Request, res: Response, next: NextFunction) => { + try { + const project_id = + parseOptionalInt(queryValue(req.query.project_id), "project_id", 0) || undefined; + const from = parseOptionalInt(queryValue(req.query.from), "from", 0) || undefined; + const to = parseOptionalInt(queryValue(req.query.to), "to", 0) || undefined; + + if (from && to && from > to) { + throw badRequest("from must be earlier than to"); + } - const entries = getAuditLog({ project_id, from, to }); - const format = req.query.format === "csv" ? "csv" : "json"; + const entries = getAuditLog({ project_id, from, to }); + const format = req.query.format === "csv" ? "csv" : "json"; - if (format === "csv") { - res.set("Content-Type", "text/csv"); - res.set("Content-Disposition", 'attachment; filename="audit-log.csv"'); - res.send(auditToCsv(entries)); - return; - } + if (format === "csv") { + res.set("Content-Type", "text/csv"); + res.set("Content-Disposition", 'attachment; filename="audit-log.csv"'); + res.send(auditToCsv(entries)); + return; + } - res.json({ count: entries.length, entries }); - } catch (err) { - next(err); - } -}); + res.json({ count: entries.length, entries }); + } catch (err) { + next(err); + } + }, +); export default router; From 3298af42f02462a70398b52f6804251711197138 Mon Sep 17 00:00:00 2001 From: Adam Onyekachi Esegbue Date: Wed, 2 Sep 2026 16:55:30 +0100 Subject: [PATCH 4/4] fix(build): resolve syntax and conflict errors in admin route --- src/routes/admin.ts | 47 ++++++--------------------------------------- 1 file changed, 6 insertions(+), 41 deletions(-) diff --git a/src/routes/admin.ts b/src/routes/admin.ts index af8eaef..c530031 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -1,18 +1,15 @@ import { Router, Request, Response, NextFunction } from "express"; -import { getSolarData, getSatelliteData } from "./iot"; -import { computeScores } from "../lib/scoring"; -import { updateImpactScore, getTotalProjects } from "../lib/registry"; import { badRequest, parseOptionalInt, MAX_PROJECT_ID } from "../middleware/errors"; import { recordAudit, getAuditLog, auditToCsv } from "../lib/audit"; import { broadcastScoreUpdate } from "../lib/websocket"; import { tryBeginUpdate, markCompleted, markFailed } from "../lib/duplicate-detection"; import { withProjectLock } from "../lib/request-queue"; import { updateScoreForProject } from "../lib/scoreService"; -import { config } from "../config"; +import { getTotalProjects } from "../lib/registry"; import { logger } from "../lib/logger"; import { - extractApiKeyRole, requireApiKeyRole, + extractApiKeyRole, requireApiKeyAuth, } from "../middleware/requireApiKeyRole"; @@ -49,25 +46,12 @@ function isStringArray(value: unknown): value is string[] { return Array.isArray(value) && value.every((entry) => typeof entry === "string"); } -/** - * Narrow an Express query value to what `parseOptionalInt` accepts. Express - * types query entries as a union that also covers nested objects; those are - * treated as absent rather than being asserted into a string. - */ function queryValue(value: unknown): string | string[] | undefined { if (typeof value === "string") return value; if (isStringArray(value)) return value; return undefined; } -/** - * Validate the optional `project_ids` field. Returns a list of ids, or `null` - * to signal "update every registered project". Throws `ApiError` (400) on - * anything that isn't an array of positive integers. - * - * Each entry is checked individually and copied into a `number[]`, so the - * returned array is typed by construction rather than by assertion. - */ function parseProjectIds(body: unknown): number[] | null { if (!isRecord(body)) return null; @@ -78,12 +62,10 @@ function parseProjectIds(body: unknown): number[] | null { } if (raw.length === 0) return null; - const projectIds: number[] = []; for (const entry of raw) { if (!isPositiveInteger(entry)) { throw badRequest("project_ids must contain only positive integers"); } - projectIds.push(entry); } if (!raw.every((n) => (n as number) <= MAX_PROJECT_ID)) { throw badRequest(`project_ids must not exceed maximum project id ${MAX_PROJECT_ID}`); @@ -91,14 +73,6 @@ function parseProjectIds(body: unknown): number[] | null { return raw as number[]; } -// POST /api/admin/update-scores -// Body: { project_ids?: number[] } — defaults to all projects -// Returns: { updated: number, results: [...], errors: [...], skipped: [...] } -// -// All errors — including validation (400) and unexpected failures (500) — are -// forwarded to the central errorHandler via next() so status codes stay consistent -// across all endpoints. The nested per-project catch is intentional: it collects -// partial failures without aborting the entire batch. router.post( "/update-scores", requireApiKeyRole("admin:write"), @@ -119,10 +93,6 @@ router.post( const errors: Array<{ project_id: number; error: { code: string; message: string } }> = []; const skipped: Array<{ project_id: number; reason: string }> = []; - // Soroban does not support multi-call batching — submit sequentially. - // Each project is individually isolated: a failure on one does not abort - // the rest. Accumulated errors are returned alongside successes so callers - // can retry only the affected ids. for (const projectId of projectIds) { try { const result = await withProjectLock(projectId, async () => { @@ -146,6 +116,10 @@ router.post( } if (scoreResult.status === "error") { + if (scoreResult.error.includes("duplicate submission rejected")) { + markCompleted(projectId); + return { skipped: true, reason: scoreResult.error }; + } throw new Error(scoreResult.error); } @@ -183,8 +157,6 @@ router.post( skipped.push({ project_id: projectId, reason: result.reason }); logger.info(`[oracle] skipping project ${projectId}: ${result.reason}`); } else { - // Rebuilt field by field so the internal `skipped` discriminant does - // not leak into the response body. results.push({ project_id: result.project_id, tx_hash: result.tx_hash, @@ -206,18 +178,11 @@ router.post( res.json({ updated: results.length, results, errors, skipped }); } catch (error) { - // Forward to errorHandler: ApiError → its .status (e.g. 400 for bad input), - // SyntaxError → 400, anything else → 500. next(error); } }, ); -/** - * GET /admin/audit - * Query: project_id=, from=, to=, format=json|csv - * Returns the immutable audit log of all score updates. - */ router.get( "/audit", requireApiKeyRole("admin:read"),