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
73 changes: 73 additions & 0 deletions src/exporters/analytics/stellar/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, it, expect, beforeEach } from '@jest/globals';
import { SorobanTransferAnalyticsExporter, TransferRecord } from './index';

describe('SorobanTransferAnalyticsExporter', () => {
let exporter: SorobanTransferAnalyticsExporter;

beforeEach(() => {
exporter = new SorobanTransferAnalyticsExporter();
});

const record1: TransferRecord = {
transferId: 't1',
sender: 'GB1',
receiver: 'GB2',
amount: 100,
asset: 'USDC',
status: 'completed',
gasUsed: 500,
txHash: '0x123',
timestamp: new Date('2026-06-29T10:00:00.000Z'),
};

const record2: TransferRecord = {
transferId: 't2',
sender: 'GB2',
receiver: 'GB3',
amount: 50,
asset: 'XLM',
status: 'failed',
gasUsed: 200,
txHash: '0x456',
timestamp: new Date('2026-06-29T10:05:00.000Z'),
};

it('should collect transfer records correctly', () => {
exporter.recordTransfer(record1);
exporter.recordTransfer(record2);

expect(exporter.getRecords()).toHaveLength(2);
expect(exporter.getRecords()[0]).toEqual(record1);
});

it('should export clean JSON format', () => {
exporter.recordTransfer(record1);
const jsonStr = exporter.export('json');
const parsed = JSON.parse(jsonStr);

expect(parsed.totalTransfers).toBe(1);
expect(parsed.totalVolume.USDC).toBe(100);
});

it('should export clean CSV format', () => {
exporter.recordTransfer(record1);
const csvStr = exporter.export('csv');

expect(csvStr).toContain('transferId,sender,receiver,amount,asset,status,gasUsed,txHash,timestamp');
expect(csvStr).toContain('t1,GB1,GB2,100,USDC,completed,500,0x123');
});

it('should export clean Prometheus exposition format', () => {
exporter.recordTransfer(record1);
exporter.recordTransfer(record2);

const prometheusStr = exporter.export('prometheus');

expect(prometheusStr).toContain('soroban_transfers_total 2');
expect(prometheusStr).toContain('soroban_transfers_by_status_total{status="completed"} 1');
expect(prometheusStr).toContain('soroban_transfers_by_status_total{status="failed"} 1');
expect(prometheusStr).toContain('soroban_transfer_volume_total{asset="USDC"} 100');
expect(prometheusStr).toContain('soroban_transfer_gas_used_total{asset="USDC"} 500');
expect(prometheusStr).toContain('soroban_transfer_gas_used_total{asset="XLM"} 200');
});
});
180 changes: 180 additions & 0 deletions src/exporters/analytics/stellar/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
/**
* Soroban Transfer Analytics Exporter (#460).
*
* Exports bridge and transfer analytics datasets for external processing.
* Supports exporting in JSON, CSV, and Prometheus formats.
*/

export type ExportFormat = 'json' | 'csv' | 'prometheus';

export interface TransferRecord {
transferId: string;
sender: string;
receiver: string;
amount: number;
asset: string;
status: 'pending' | 'completed' | 'failed';
gasUsed: number;
txHash: string;
timestamp: Date;
}

export interface AnalyticsDataset {
exportedAt: Date;
totalTransfers: number;
totalVolume: Record<string, number>; // total volume per asset
transfers: TransferRecord[];
}

export interface CsvExportOptions {
delimiter?: string;
includeHeader?: boolean;
}

export class SorobanTransferAnalyticsExporter {
private records: TransferRecord[] = [];

/**
* Register a new transfer record.
*/
recordTransfer(record: TransferRecord): void {
this.records.push(record);
}

/**
* Get all registered records.
*/
getRecords(): TransferRecord[] {
return this.records;
}

/**
* Clear all records.
*/
clear(): void {
this.records = [];
}

/**
* Generate an analytics dataset.
*/
getDataset(): AnalyticsDataset {
const totalVolume: Record<string, number> = {};
for (const r of this.records) {
if (r.status === 'completed') {
totalVolume[r.asset] = (totalVolume[r.asset] || 0) + r.amount;
}
}

return {
exportedAt: new Date(),
totalTransfers: this.records.length,
totalVolume,
transfers: [...this.records],
};
}

/**
* Export the dataset in the specified format.
*/
export(format: ExportFormat, csvOptions?: CsvExportOptions): string {
const dataset = this.getDataset();

switch (format) {
case 'json':
return this.exportJson(dataset);
case 'csv':
return this.exportCsv(dataset, csvOptions);
case 'prometheus':
return this.exportPrometheus(dataset);
default:
throw new Error(`Unsupported export format: ${format}`);
}
}

private exportJson(dataset: AnalyticsDataset): string {
return JSON.stringify(dataset, null, 2);
}

private exportCsv(dataset: AnalyticsDataset, options?: CsvExportOptions): string {
const delimiter = options?.delimiter || ',';
const includeHeader = options?.includeHeader !== false;
const lines: string[] = [];

if (includeHeader) {
lines.push(
['transferId', 'sender', 'receiver', 'amount', 'asset', 'status', 'gasUsed', 'txHash', 'timestamp'].join(
delimiter,
),
);
}

for (const t of dataset.transfers) {
const row = [
this.escapeCsv(t.transferId, delimiter),
this.escapeCsv(t.sender, delimiter),
this.escapeCsv(t.receiver, delimiter),
t.amount.toString(),
this.escapeCsv(t.asset, delimiter),
t.status,
t.gasUsed.toString(),
this.escapeCsv(t.txHash, delimiter),
t.timestamp.toISOString(),
];
lines.push(row.join(delimiter));
}

return lines.join('\n');
}

private exportPrometheus(dataset: AnalyticsDataset): string {
let output = '';

output += '# HELP soroban_transfers_total Total number of Soroban transfers recorded.\n';
output += '# TYPE soroban_transfers_total counter\n';
output += `soroban_transfers_total ${dataset.totalTransfers}\n\n`;

// Group counts/volumes by status/asset
const statusCounts: Record<string, number> = {};
const assetVolumes: Record<string, number> = {};
const assetGasUsed: Record<string, number> = {};

for (const t of dataset.transfers) {
statusCounts[t.status] = (statusCounts[t.status] || 0) + 1;
if (t.status === 'completed') {
assetVolumes[t.asset] = (assetVolumes[t.asset] || 0) + t.amount;
}
assetGasUsed[t.asset] = (assetGasUsed[t.asset] || 0) + t.gasUsed;
}

output += '# HELP soroban_transfers_by_status_total Total transfers by status.\n';
output += '# TYPE soroban_transfers_by_status_total counter\n';
for (const [status, count] of Object.entries(statusCounts)) {
output += `soroban_transfers_by_status_total{status="${status}"} ${count}\n`;
}
output += '\n';

output += '# HELP soroban_transfer_volume_total Total transaction volume per asset.\n';
output += '# TYPE soroban_transfer_volume_total counter\n';
for (const [asset, volume] of Object.entries(assetVolumes)) {
output += `soroban_transfer_volume_total{asset="${asset}"} ${volume}\n`;
}
output += '\n';

output += '# HELP soroban_transfer_gas_used_total Total gas used by transfers per asset.\n';
output += '# TYPE soroban_transfer_gas_used_total counter\n';
for (const [asset, gas] of Object.entries(assetGasUsed)) {
output += `soroban_transfer_gas_used_total{asset="${asset}"} ${gas}\n`;
}

return output;
}

private escapeCsv(value: string, delimiter: string): string {
const clean = value.replace(/"/g, '""');
if (clean.includes(delimiter) || clean.includes('\n') || clean.includes('"')) {
return `"${clean}"`;
}
return clean;
}
}
Loading