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
61 changes: 49 additions & 12 deletions backend/src/routes/generator/explorer.routes.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,52 @@
import { Router, Request, Response } from 'express';
import { Request, Response, Router } from 'express';
import {
buildExplorerLink,
ExplorerAdapterError,
ExplorerMode,
filterTransactions,
getExplorerSnapshot,
buildExplorerLink,
} from '../../services/blockExplorer.service.js';
import logger from '../../utils/logger.js';

const router: ReturnType<typeof Router> = Router();

function parseMode(req: Request): ExplorerMode | undefined {
const modeQuery = String(req.query.mode ?? '').toLowerCase();
if (modeQuery === 'live' || modeQuery === 'simulation') {
return modeQuery;
}
const useSim = String(req.query.useSimulation ?? '').toLowerCase();
if (useSim === 'true' || useSim === '1') {
return 'simulation';
}
if (useSim === 'false' || useSim === '0') {
return 'live';
}
return undefined;
}

/**
* @route GET /api/v1/generator/explorer/snapshot
* @desc Get cached ledger snapshot for hackathon research
* @desc Get cached or live ledger snapshot for hackathon research
*/
router.get('/explorer/snapshot', async (req: Request, res: Response) => {
router.get('/explorer/snapshot', async (req: Request, res: Response): Promise<void> => {
try {
const limit = req.query.limit ? Number(req.query.limit) : 25;
const seed = req.query.seed ? Number(req.query.seed) : undefined;
const snapshot = await getExplorerSnapshot({ limit, seed });
const mode = parseMode(req);

const snapshot = await getExplorerSnapshot({ limit, seed, mode });
res.json({ status: 'success', data: snapshot });
} catch (error) {
logger.error('Block explorer snapshot failed', { error });
} catch (error: unknown) {
logger.error('Block explorer snapshot failed', {
error: error instanceof Error ? error.message : String(error),
});

if (error instanceof ExplorerAdapterError) {
res.status(error.statusCode).json({ error: error.message, code: error.code });
return;
}

res.status(500).json({ error: 'Failed to fetch explorer snapshot' });
}
});
Expand All @@ -28,11 +55,13 @@ router.get('/explorer/snapshot', async (req: Request, res: Response) => {
* @route GET /api/v1/generator/explorer/search
* @desc Filter transactions by query string
*/
router.get('/explorer/search', async (req: Request, res: Response) => {
router.get('/explorer/search', async (req: Request, res: Response): Promise<void> => {
try {
const query = String(req.query.q ?? '');
const snapshot = await getExplorerSnapshot({ limit: 50 });
const mode = parseMode(req);
const snapshot = await getExplorerSnapshot({ limit: 50, mode });
const filtered = filterTransactions(snapshot.transactions, query);

res.json({
status: 'success',
data: {
Expand All @@ -41,8 +70,16 @@ router.get('/explorer/search', async (req: Request, res: Response) => {
query,
},
});
} catch (error) {
logger.error('Block explorer search failed', { error });
} catch (error: unknown) {
logger.error('Block explorer search failed', {
error: error instanceof Error ? error.message : String(error),
});

if (error instanceof ExplorerAdapterError) {
res.status(error.statusCode).json({ error: error.message, code: error.code });
return;
}

res.status(500).json({ error: 'Failed to search transactions' });
}
});
Expand All @@ -51,7 +88,7 @@ router.get('/explorer/search', async (req: Request, res: Response) => {
* @route GET /api/v1/generator/explorer/link/:hash
* @desc Build external explorer URL for a transaction hash
*/
router.get('/explorer/link/:hash', (req: Request, res: Response) => {
router.get('/explorer/link/:hash', (req: Request<{ hash: string }>, res: Response): void => {
const network = req.query.network === 'public' ? 'public' : 'testnet';
const hash = typeof req.params.hash === 'string' ? req.params.hash : '';
const link = buildExplorerLink(hash, network);
Expand Down
74 changes: 74 additions & 0 deletions backend/src/services/adapters/blockExplorerAdapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
export type ExplorerMode = 'live' | 'simulation';

export type TxStatus = 'SUCCESS' | 'PENDING' | 'FAILED';

export interface ExplorerTransaction {
id: string;
hash: string;
source: string;
destination: string;
operation: string;
amount: string;
asset: string;
fee: string;
ledger: number;
status: TxStatus;
timestamp: string;
}

export interface ExplorerSnapshotStats {
totalTransactions: number;
successRate: number;
averageFee: string;
latestLedger: number;
}

export interface ExplorerSnapshot {
transactions: ExplorerTransaction[];
stats: ExplorerSnapshotStats;
generatedAt: string;
mode?: ExplorerMode;
}

export interface GetSnapshotOptions {
limit?: number;
seed?: number;
timeoutMs?: number;
network?: string;
cacheTtl?: number;
useSimulation?: boolean;
mode?: ExplorerMode;
}

export interface ExplorerAdapter {
readonly mode: ExplorerMode;
getSnapshot(options?: GetSnapshotOptions): Promise<ExplorerSnapshot>;
fetchTransactions(limit?: number, options?: { timeoutMs?: number; seed?: number }): Promise<ExplorerTransaction[]>;
}

export class ExplorerAdapterError extends Error {
public readonly code: string;
public readonly statusCode: number;

constructor(message: string, code: string = 'EXPLORER_ADAPTER_ERROR', statusCode: number = 500) {
super(message);
this.name = 'ExplorerAdapterError';
this.code = code;
this.statusCode = statusCode;
}
}

export function computeStats(txs: ExplorerTransaction[]): ExplorerSnapshotStats {
if (txs.length === 0) {
return { totalTransactions: 0, successRate: 0, averageFee: '0', latestLedger: 0 };
}
const succeeded = txs.filter((t) => t.status === 'SUCCESS').length;
const totalFee = txs.reduce((sum, t) => sum + (Number(t.fee) || 0), 0);
const maxLedger = txs.reduce((max, t) => Math.max(max, t.ledger || 0), 0);
return {
totalTransactions: txs.length,
successRate: Math.round((succeeded / txs.length) * 100),
averageFee: (totalFee / txs.length).toFixed(0),
latestLedger: maxLedger,
};
}
47 changes: 47 additions & 0 deletions backend/src/services/adapters/explorerAdapterFactory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { ExplorerAdapter, ExplorerMode, GetSnapshotOptions } from './blockExplorerAdapter.js';
import { LiveStellarExplorerAdapter } from './liveStellarExplorerAdapter.js';
import { SimulationExplorerAdapter } from './simulationExplorerAdapter.js';

let defaultSimulationAdapter: SimulationExplorerAdapter | null = null;
let defaultLiveAdapter: LiveStellarExplorerAdapter | null = null;

export function resolveExplorerMode(options?: GetSnapshotOptions): ExplorerMode {
if (options?.mode === 'simulation' || options?.mode === 'live') {
return options.mode;
}
if (options?.useSimulation !== undefined) {
return options.useSimulation ? 'simulation' : 'live';
}

const envMode = process.env.BLOCK_EXPLORER_MODE?.toLowerCase();
if (envMode === 'simulation' || envMode === 'simulated') {
return 'simulation';
}
if (envMode === 'live') {
return 'live';
}

const envUseSim = process.env.USE_SIMULATED_EXPLORER?.toLowerCase();
if (envUseSim === 'true' || envUseSim === '1') {
return 'simulation';
}

// Default to live mode if not explicitly overridden
return 'live';
}

export function getExplorerAdapter(options?: GetSnapshotOptions): ExplorerAdapter {
const mode = resolveExplorerMode(options);

if (mode === 'simulation') {
if (!defaultSimulationAdapter) {
defaultSimulationAdapter = new SimulationExplorerAdapter();
}
return defaultSimulationAdapter;
}

if (!defaultLiveAdapter) {
defaultLiveAdapter = new LiveStellarExplorerAdapter();
}
return defaultLiveAdapter;
}
Loading