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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ jobs:
- name: Type-check (scripts)
run: npm run typecheck:scripts

- name: Mock/experimental data gating check
run: npm run check:mock-gating

test:
name: Test
runs-on: ubuntu-latest
Expand Down
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,35 @@ Because the server caches keys, a typical rotation involves:
| `INDEXER_POLL_INTERVAL_MS` | `5000` | Polling interval |
| `INDEXER_BATCH_SIZE` | `100` | Ledgers per batch |
| `ADMIN_SECRET` | — | Bearer token required by every `/api/admin/*` route (indexer). See [`docs/ADMIN_AUTH.md`](./docs/ADMIN_AUTH.md). |
| `MOCK_DATA` | `false` | Gates experimental endpoints that fabricate demo data instead of a real integration. See [Experimental & Mock Data](#experimental--mock-data). `ENABLE_EXPERIMENTAL` is accepted as an alias. |

## Experimental & Mock Data

A handful of endpoints have no real upstream integration yet (no oracle/bridge
connection, no ZK-proof verifier, no real export pipeline) and would otherwise
have to fabricate the data they return. Those endpoints are gated behind the
shared framework in [`src/config/mockData.ts`](./src/config/mockData.ts):

- **Off by default** — the endpoint responds `404` with `{ "error": "...", "mock": true }`
instead of returning synthetic data.
- **`MOCK_DATA=true`** (or `ENABLE_EXPERIMENTAL=true`) — the endpoint returns its
demo/simulated response, always annotated with `"mock": true` so no consumer can
mistake it for real data.

Currently gated endpoints:

| Endpoint | Why it's gated |
| --- | --- |
| `GET /oracle-feeds/assets/:assetPair/price` | No live oracle provider is connected; price is a static demo value. |
| `GET /arbitrage/cross-chain/opportunities`, `GET /arbitrage/cross-chain/bridges` | No live cross-chain oracle/bridge integration; prices and bridge status are static demo data. |
| `POST /arbitrage/bot/deploy`, `GET/POST /arbitrage/bot/:address/*` | Simulated bot execution — no real capital or trades, PnL drifts randomly for demonstration. |
| `GET /data-market/prices/history` | No real price-history time series exists yet; the series is synthetically generated. |
| `POST /data-market/challenge/zk-proof`, `POST /data-market/challenge/:id/verify` (zk_proof challenges) | Real zk-SNARK verification isn't implemented; outside `MOCK_DATA` mode these always fail rather than falsely report a proof as valid. |
| `POST /feed/backfill` (via `GET /feed/backfill/:requestId`) | Historical export generation isn't implemented against a real storage backend; outside `MOCK_DATA` mode the request fails with a clear `errorMessage` instead of returning a fake download URL. |

A CI check (`npm run check:mock-gating`) fails the build if a new endpoint under
`src/api/` mentions "mock" without importing the shared framework, so new
fabricated-data endpoints can't ship ungated.

## OpenTelemetry Tracing

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"lint:errors": "eslint src/api",
"lint:error-handling": "eslint 'src/api/**/*.ts' --rule 'error-handling/require-async-handler: error' --max-warnings 0",
"validate:prisma": "ts-node --transpile-only scripts/validate-prisma-references.ts",
"check:mock-gating": "ts-node --transpile-only scripts/check-mock-gating.ts",
"migrate:impact": "ts-node --transpile-only scripts/validate-prisma-references.ts --diff",
"typecheck:scripts": "tsc -p tsconfig.scripts.json --noEmit",
"test:full": "vitest run"
Expand Down
99 changes: 99 additions & 0 deletions scripts/check-mock-gating.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* Mock/experimental data gating guard (Issue #7)
*
* Any src/api file that mentions "mock" (case-insensitive, in code or
* comments) must import the shared gating framework from
* src/config/mockData.ts. This keeps fabricated/experimental data from
* leaking into API responses without going through the MOCK_DATA flag and
* being explicitly annotated with `mock: true`.
*
* Usage:
* npx ts-node --transpile-only scripts/check-mock-gating.ts
* npm run check:mock-gating
*/

import * as fs from 'fs';
import * as path from 'path';

const API_DIR = path.resolve(__dirname, '../src/api');
const FRAMEWORK_IMPORT_RE = /config\/mockData/;
const MOCK_WORD_RE = /mock/i;

interface Violation {
file: string;
line: number;
text: string;
}

/** Recursively collect all .ts files under a directory */
function collectTs(dir: string): string[] {
const entries = fs.readdirSync(dir, { withFileTypes: true });
const files: string[] = [];
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...collectTs(full));
else if (entry.isFile() && entry.name.endsWith('.ts')) files.push(full);
}
return files;
}

/**
* Any file under src/config/ is the framework itself (or a sibling config
* module) and is exempt from having to import itself.
*/
function isExempt(file: string): boolean {
return path.resolve(file) === path.resolve(API_DIR, '../config/mockData.ts');
}

export function findViolations(dir: string = API_DIR): Violation[] {
const files = collectTs(dir);
const violations: Violation[] = [];

for (const file of files) {
if (isExempt(file)) continue;

const content = fs.readFileSync(file, 'utf-8');
if (!MOCK_WORD_RE.test(content)) continue;
if (FRAMEWORK_IMPORT_RE.test(content)) continue;

content.split('\n').forEach((line, idx) => {
if (MOCK_WORD_RE.test(line)) {
violations.push({
file: path.relative(process.cwd(), file),
line: idx + 1,
text: line.trim(),
});
}
});
}

return violations;
}

if (require.main === module) {
const violations = findViolations();

console.log('\n═══════════════════════════════════════════════════');
console.log(' Mock/Experimental Data Gating Check');
console.log('═══════════════════════════════════════════════════\n');

if (violations.length === 0) {
console.log('✅ No ungated mock/fabricated data found in src/api\n');
process.exit(0);
}

console.log(
`❌ Found ${violations.length} reference(s) to "mock" data that do not import the shared gating framework (src/config/mockData.ts):\n`,
);
for (const v of violations) {
console.log(` • ${v.file}:${v.line} ${v.text}`);
}
console.log(
'\nEvery endpoint that returns synthetic/fabricated data must import\n' +
'{ isMockDataEnabled, sendMockGated, annotateMock } from "../config/mockData"\n' +
'and gate its response behind MOCK_DATA (see README.md "Experimental & Mock Data").\n' +
'Fix the endpoint above, or if this is a legitimate false positive, reword the\n' +
'identifier/comment so it no longer contains "mock".\n',
);
process.exit(1);
}
149 changes: 122 additions & 27 deletions src/api/arbitrage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import { z } from 'zod';
import { prismaRead, prismaWrite } from '../db';
import { cacheGet, cacheSet } from '../cache';
import { annotateMock, isMockDataEnabled, sendMockGated } from '../config/mockData';
import {
buildPriceGraph,
detectNegativeCycles,
Expand All @@ -22,7 +23,7 @@

// ─── Helper ───────────────────────────────────────────────────────────────────

function paginate<T>(data: T[], page: number, limit: number) {

Check warning on line 26 in src/api/arbitrage.ts

View workflow job for this annotation

GitHub Actions / Lint

'paginate' is defined but never used. Allowed unused vars must match /^_/u
const total = data.length;
const start = (page - 1) * limit;
return {
Expand Down Expand Up @@ -1216,8 +1217,49 @@
}
>();

/**
* @swagger
* /arbitrage/bot/deploy:
* post:
* summary: Deploy a simulated arbitrage-executing bot (demo only)
* description: >
* No real capital or trades are involved. Disabled by default; set
* MOCK_DATA=true to enable. All responses are marked `mock: true`.
* tags: [Arbitrage]
* x-experimental: true
* responses:
* 201:
* description: Simulated bot deployed (only when MOCK_DATA=true)
* 404:
* description: Feature disabled (default)
* /arbitrage/bot/{address}/status:
* get:
* summary: Get simulated bot status and PnL (demo only)
* description: PnL and trade counts drift randomly for demonstration purposes.
* tags: [Arbitrage]
* x-experimental: true
* parameters:
* - in: path
* name: address
* required: true
* schema: { type: string }
* responses:
* 200:
* description: Simulated bot status (only when MOCK_DATA=true)
* 404:
* description: Feature disabled (default) or bot not found
*/
function botFeatureDisabledResponse(res: Response) {
return res.status(404).json({
error:
'Automated bot deployment is a simulated demo feature (no real capital or trades) and is disabled by default. Set MOCK_DATA=true to enable it.',
mock: true,
});
}

arbitrageRouter.post('/bot/deploy', async (req: Request, res: Response) => {
try {
if (!isMockDataEnabled()) return botFeatureDisabledResponse(res);
const config = botDeploySchema.parse(req.body);
// Generate a deterministic contract-like address for the bot instance
const address = `C${Buffer.from(JSON.stringify(config) + Date.now())
Expand All @@ -1234,63 +1276,100 @@
totalTrades: 0,
});

res.status(201).json({
success: true,
address,
status: 'running',
config,
message: 'Arbitrage bot contract deployed. Monitor via GET /bot/:address/status',
});
res.status(201).json(
annotateMock({
success: true,
address,
status: 'running',
config,
message: 'Simulated arbitrage bot deployed (no real capital or trades). Monitor via GET /bot/:address/status',
}),
);
} catch (e) {
if (e instanceof z.ZodError) return res.status(400).json({ error: e.errors });
res.status(500).json({ error: String(e) });
}
});

arbitrageRouter.get('/bot/:address/status', (req: Request, res: Response) => {
if (!isMockDataEnabled()) return botFeatureDisabledResponse(res);
const bot = deployedBots.get(req.params.address);
if (!bot) return res.status(404).json({ error: 'Bot not found' });

// Simulate some PnL drift for demonstration
// Simulate some PnL drift for demonstration — only reachable in MOCK_DATA mode
bot.pnl += Math.random() * 0.5;
bot.totalTrades += Math.floor(Math.random() * 3);

res.json({
address: bot.address,
status: bot.status,
deployedAt: bot.deployedAt,
pnl: bot.pnl.toFixed(4),
totalTrades: bot.totalTrades,
config: bot.config,
uptime: `${Math.floor((Date.now() - new Date(bot.deployedAt).getTime()) / 1000)}s`,
});
res.json(
annotateMock({
address: bot.address,
status: bot.status,
deployedAt: bot.deployedAt,
pnl: bot.pnl.toFixed(4),
totalTrades: bot.totalTrades,
config: bot.config,
uptime: `${Math.floor((Date.now() - new Date(bot.deployedAt).getTime()) / 1000)}s`,
}),
);
});

arbitrageRouter.post('/bot/:address/config', (req: Request, res: Response) => {
if (!isMockDataEnabled()) return botFeatureDisabledResponse(res);
const bot = deployedBots.get(req.params.address);
if (!bot) return res.status(404).json({ error: 'Bot not found' });

try {
const updates = botDeploySchema.partial().parse(req.body);
bot.config = { ...bot.config, ...updates };
res.json({ success: true, address: bot.address, config: bot.config });
res.json(annotateMock({ success: true, address: bot.address, config: bot.config }));
} catch (e) {
if (e instanceof z.ZodError) return res.status(400).json({ error: e.errors });
res.status(500).json({ error: String(e) });
}
});

arbitrageRouter.post('/bot/:address/pause', (req: Request, res: Response) => {
if (!isMockDataEnabled()) return botFeatureDisabledResponse(res);
const bot = deployedBots.get(req.params.address);
if (!bot) return res.status(404).json({ error: 'Bot not found' });

bot.status = bot.status === 'paused' ? 'running' : 'paused';
res.json({ success: true, address: bot.address, status: bot.status });
res.json(annotateMock({ success: true, address: bot.address, status: bot.status }));
});

// ─── Cross-Chain Arbitrage Detection (Stretch #15) ───────────────────────────

// Mock cross-chain price feeds (production: integrate with oracle/bridge APIs)
/**
* @swagger
* /arbitrage/cross-chain/opportunities:
* get:
* summary: Cross-chain arbitrage opportunities (demo only)
* description: >
* Derived from static demo price feeds, not live oracle/bridge data.
* Disabled by default; set MOCK_DATA=true to enable. Responses are
* marked `mock: true`.
* tags: [Arbitrage]
* x-experimental: true
* responses:
* 200:
* description: Demo opportunities (only when MOCK_DATA=true)
* 404:
* description: Feature disabled (default)
* /arbitrage/cross-chain/bridges:
* get:
* summary: Cross-chain bridge info (demo only)
* description: >
* Static demo bridge metadata, not a live bridge integration. Disabled
* by default; set MOCK_DATA=true to enable.
* tags: [Arbitrage]
* x-experimental: true
* responses:
* 200:
* description: Demo bridge list (only when MOCK_DATA=true)
* 404:
* description: Feature disabled (default)
*/
// Demo cross-chain price feeds — gated behind MOCK_DATA (production: integrate with oracle/bridge APIs)
const CROSS_CHAIN_PRICES: Record<string, Record<string, number>> = {
XLM: { stellar: 0.1234, ethereum: 0.1251, polygon: 0.1229 },
USDC: { stellar: 1.0, ethereum: 1.0002, polygon: 0.9998 },
Expand All @@ -1303,6 +1382,14 @@
};

arbitrageRouter.get('/cross-chain/opportunities', (_req: Request, res: Response) => {
if (!isMockDataEnabled()) {
return res.status(404).json({
error:
'Cross-chain arbitrage detection requires live oracle/bridge integrations that are not yet connected. Set MOCK_DATA=true for labeled demo data.',
mock: true,
});
}

const opportunities: unknown[] = [];

for (const [token, chainPrices] of Object.entries(CROSS_CHAIN_PRICES)) {
Expand Down Expand Up @@ -1343,12 +1430,13 @@
}
}

res.json({
opportunities,
count: opportunities.length,
supportedChains: ['stellar', 'ethereum', 'polygon'],
note: 'Cross-chain prices sourced from oracle feeds. Bridge latency affects profitability.',
});
res.json(
annotateMock({
opportunities,
count: opportunities.length,
supportedChains: ['stellar', 'ethereum', 'polygon'],
}),
);
});

arbitrageRouter.get('/cross-chain/bridges', (_req: Request, res: Response) => {
Expand All @@ -1361,5 +1449,12 @@
estimatedCostFor10kUSD: `${((10000 * info.feePct) / 100).toFixed(2)} USD`,
}));

res.json({ bridges, count: bridges.length });
return sendMockGated(
res,
{ bridges, count: bridges.length },
{
disabledMessage:
'Cross-chain bridge status requires live bridge integrations that are not yet connected. Set MOCK_DATA=true for labeled demo data.',
},
);
});
Loading
Loading