diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 43b99efd..aea62a5b 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -1428,3 +1428,269 @@ enum UserOpStatus { completed failed } + +// ─── Issue #470: Atomic Swap / HTLC Models ──────────────────────────────────── + +enum AtomicSwapStatus { + pending + claimed + refunded + disputed +} + +model AtomicSwap { + id String @id @default(uuid()) + tenantId String @map("tenant_id") + swapId BigInt @unique @map("swap_id") + sender String + receiver String + tokenA String @map("token_a") + tokenB String @map("token_b") + amountA Decimal @map("amount_a") @db.Decimal(20, 8) + amountB Decimal @map("amount_b") @db.Decimal(20, 8) + hashlock String + timelock BigInt @map("timelock") + disputeDeadline BigInt? @map("dispute_deadline") + status AtomicSwapStatus @default(pending) + feeBps Int @default(30) @map("fee_bps") + feeCollector String? @map("fee_collector") + contractAddress String? @map("contract_address") + network String @default("soroban") + expiresAt DateTime? @map("expires_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + + secret AtomicSwapSecret? + + @@index([tenantId]) + @@index([sender]) + @@index([receiver]) + @@index([status]) + @@map("atomic_swaps") +} + +model AtomicSwapSecret { + id String @id @default(uuid()) + swapId String @unique @map("swap_id") + preimage String + revealedAt DateTime @default(now()) @map("revealed_at") + + swap AtomicSwap @relation(fields: [swapId], references: [id], onDelete: Cascade) + + @@map("atomic_swap_secrets") +} + +// ─── Issue #469: Multi-Signature Treasury Models ───────────────────────────── + +enum TreasuryProposalStatus { + pending + approved + executed + rejected + cancelled + expired +} + +model TreasuryProposal { + id String @id @default(uuid()) + tenantId String @map("tenant_id") + proposalId BigInt @map("proposal_id") + proposer String + description String + target String + amount Decimal @db.Decimal(20, 8) + token String? + calldata String? + status TreasuryProposalStatus @default(pending) + approvalCount Int @default(0) @map("approval_count") + rejectionCount Int @default(0) @map("rejection_count") + threshold Int @default(1) + timelockDelay BigInt @map("timelock_delay") + executeAfter DateTime @map("execute_after") + createdAt DateTime @default(now()) @map("created_at") + executedAt DateTime? @map("executed_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + + approvals TreasuryApproval[] + execution TreasuryExecution? + + @@index([tenantId]) + @@index([status]) + @@index([tenantId, status]) + @@map("treasury_proposals") +} + +model TreasuryApproval { + id String @id @default(uuid()) + proposalId String @map("proposal_id") + signer String + approved Boolean @default(true) + createdAt DateTime @default(now()) @map("created_at") + + proposal TreasuryProposal @relation(fields: [proposalId], references: [id], onDelete: Cascade) + + @@unique([proposalId, signer]) + @@index([proposalId]) + @@map("treasury_approvals") +} + +model TreasuryExecution { + id String @id @default(uuid()) + proposalId String @unique @map("proposal_id") + txHash String? @map("tx_hash") + executedBy String @map("executed_by") + executedAt DateTime @default(now()) @map("executed_at") + error String? + chain String @default("soroban") + + proposal TreasuryProposal @relation(fields: [proposalId], references: [id], onDelete: Cascade) + + @@map("treasury_executions") +} + +// ─── Issue #471: API Key Usage Analytics and Quota Models ───────────────────── + +model ApiKey { + id String @id @default(uuid()) + tenantId String @map("tenant_id") + keyId String @unique @map("key_id") + description String? + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + expiresAt DateTime? @map("expires_at") + revokedAt DateTime? @map("revoked_at") + + usage ApiKeyUsage[] + quota ApiKeyQuota? + + @@index([tenantId]) + @@index([keyId]) + @@map("api_keys") +} + +model ApiKeyUsage { + id String @id @default(uuid()) + tenantId String @map("tenant_id") + keyId String @map("key_id") + endpoint String + method String @default("GET") + statusCode Int @map("status_code") + latencyMs Int @map("latency_ms") + payloadSize Int? @map("payload_size") + ipAddress String? @map("ip_address") + recordedAt DateTime @default(now()) @map("recorded_at") + + key ApiKey @relation(fields: [keyId], references: [id], onDelete: Cascade) + + @@index([keyId]) + @@index([tenantId, recordedAt]) + @@index([keyId, recordedAt]) + @@index([recordedAt]) + @@map("api_key_usage") +} + +model ApiKeyQuota { + id String @id @default(uuid()) + keyId String @unique @map("key_id") + requestsPerHour Int @default(1000) @map("requests_per_hour") + requestsPerDay Int @default(10000) @map("requests_per_day") + alertAt50Pct Boolean @default(true) @map("alert_at_50_pct") + alertAt80Pct Boolean @default(true) @map("alert_at_80_pct") + alertAt100Pct Boolean @default(true) @map("alert_at_100_pct") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + key ApiKey @relation(fields: [keyId], references: [id], onDelete: Cascade) + + @@map("api_key_quotas") +} + +// ─── Issue #472: Custom Report Builder Models ──────────────────────────────── + +enum ReportChartType { + line + bar + pie + table + heatmap + area +} + +enum ReportScheduleFrequency { + daily + weekly + monthly +} + +model SavedReport { + id String @id @default(uuid()) + tenantId String @map("tenant_id") + userId String? @map("user_id") + name String + description String? + metrics String[] + dimensions String[] + filters Json? + chartType ReportChartType @default(line) + dateRange Json? + isFavorite Boolean @default(false) @map("is_favorite") + isPinned Boolean @default(false) @map("is_pinned") + config Json? + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + + schedule ScheduledReport? + template ReportTemplate? + + @@index([tenantId]) + @@index([userId]) + @@index([isFavorite]) + @@index([tenantId, updatedAt]) + @@map("saved_reports") +} + +model ScheduledReport { + id String @id @default(uuid()) + reportId String @unique @map("report_id") + frequency ReportScheduleFrequency + dayOfWeek Int? @default(1) @map("day_of_week") + dayOfMonth Int? @default(1) @map("day_of_month") + time String @default("09:00") + recipients String[] + lastSentAt DateTime? @map("last_sent_at") + nextSendAt DateTime? @map("next_send_at") + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + report SavedReport @relation(fields: [reportId], references: [id], onDelete: Cascade) + + @@index([isActive]) + @@index([nextSendAt]) + @@map("scheduled_reports") +} + +model ReportTemplate { + id String @id @default(uuid()) + reportId String @unique @map("report_id") + name String + description String? + metrics String[] + dimensions String[] + filters Json? + chartType ReportChartType @default(line) + dateRange Json? + config Json? + isPublic Boolean @default(false) @map("is_public") + usageCount Int @default(0) @map("usage_count") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + report SavedReport @relation(fields: [reportId], references: [id], onDelete: Cascade) + + @@index([isPublic, usageCount]) + @@map("report_templates") +} diff --git a/backend/src/index.ts b/backend/src/index.ts index 922cd055..142581da 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -121,6 +121,11 @@ import { gasRouter } from './routes/gas.js'; import { vaultsRouter } from './routes/vaults.js'; import { createConnectionManager } from './websocket/connection-manager.js'; import { getBridgeMonitorService } from './services/bridge-monitor/bridge-monitor.js'; +import { swapsRouter } from './routes/swaps.js'; +import { treasuryRouter } from './routes/treasury.js'; +import { apiKeysRouter } from './routes/api-keys.js'; +import { reportsRouter } from './routes/reports.js'; +import { apiUsageTracker, checkQuota } from './middleware/api-usage-tracker.js'; // Validate environment variables at startup validateEnv(); @@ -381,6 +386,18 @@ app.use('/api/v1/auth/sessions', sessionsRouter); // HMAC signing key management — Issue #510 app.use('/api/v1/developers/signing-keys', signingKeysRouter); +// API key usage tracking and quota enforcement — Issue #471 +app.use('/api/v1/developers/api-keys', apiUsageTracker, apiKeysRouter); + +// Atomic swaps / HTLC management — Issue #470 +app.use('/api/v1/swaps', swapsRouter); + +// Multi-signature timelock treasury management — Issue #469 +app.use('/api/v1/treasury', treasuryRouter); + +// Custom report builder with saved templates — Issue #472 +app.use('/api/v1/reports', reportsRouter); + // Sandbox environment for testing (with relaxed rate limits) const sandboxRouter = createSandboxRouter(getSandboxManager(), getMockPaymentProcessor(), getTestDataSeeder()); app.use('/api/v1/sandbox', sandboxRateLimiter, sandboxRouter); diff --git a/backend/src/middleware/api-usage-tracker.ts b/backend/src/middleware/api-usage-tracker.ts new file mode 100644 index 00000000..6e394096 --- /dev/null +++ b/backend/src/middleware/api-usage-tracker.ts @@ -0,0 +1,93 @@ +import type { Request, Response, NextFunction } from 'express'; +import { prisma } from '../lib/prisma.js'; + +const requestCounts = new Map(); + +function getWindowKey(keyId: string, windowMs: number): string { + return `${keyId}:${Math.floor(Date.now() / windowMs)}`; +} + +export async function apiUsageTracker(req: Request, res: Response, next: NextFunction) { + const startTime = Date.now(); + const tenantId = (req.headers['x-tenant-id'] as string) ?? 'default'; + const keyId = (req.headers['x-api-key'] as string) ?? 'anonymous'; + const endpoint = req.originalUrl ?? req.path; + const method = req.method; + + res.on('finish', async () => { + const latencyMs = Date.now() - startTime; + const statusCode = res.statusCode; + const payloadSize = parseInt(res.getHeader('content-length') as string) || 0; + + try { + await prisma.apiKeyUsage.create({ + data: { + tenantId, + keyId, + endpoint, + method, + statusCode, + latencyMs, + payloadSize, + ipAddress: req.ip, + }, + }); + } catch (err) { + console.warn('[API Usage Tracker] Failed to record usage:', (err as Error).message); + } + }); + + next(); +} + +export function checkQuota(req: Request, res: Response, next: NextFunction) { + const keyId = (req.headers['x-api-key'] as string) ?? 'anonymous'; + const hourWindow = 3600_000; + const dayWindow = 86400_000; + + const hourKey = getWindowKey(keyId, hourWindow); + const dayKey = getWindowKey(keyId, dayWindow); + + if (!requestCounts.has(keyId)) { + requestCounts.set(keyId, { hourly: [], daily: [] }); + } + const counts = requestCounts.get(keyId)!; + + counts.hourly.push(Date.now()); + counts.daily.push(Date.now()); + + const hourAgo = Date.now() - hourWindow; + const dayAgo = Date.now() - dayWindow; + + counts.hourly = counts.hourly.filter((t) => t > hourAgo); + counts.daily = counts.daily.filter((t) => t > dayAgo); + + const hourlyCount = counts.hourly.length; + const dailyCount = counts.daily.length; + + const hourlyLimit = parseInt(req.headers['x-rate-limit-hourly'] as string) || 1000; + const dailyLimit = parseInt(req.headers['x-rate-limit-daily'] as string) || 10000; + + if (hourlyCount > hourlyLimit) { + res.setHeader('Retry-After', '3600'); + res.status(429).json({ + error: { code: 'RATE_LIMIT_EXCEEDED', message: 'Hourly quota exceeded', retryAfter: 3600 }, + }); + return; + } + + if (dailyCount > dailyLimit) { + res.setHeader('Retry-After', '86400'); + res.status(429).json({ + error: { code: 'DAILY_LIMIT_EXCEEDED', message: 'Daily quota exceeded', retryAfter: 86400 }, + }); + return; + } + + res.setHeader('X-RateLimit-Hourly-Remaining', String(hourlyLimit - hourlyCount)); + res.setHeader('X-RateLimit-Daily-Remaining', String(dailyLimit - dailyCount)); + res.setHeader('X-RateLimit-Hourly-Limit', String(hourlyLimit)); + res.setHeader('X-RateLimit-Daily-Limit', String(dailyLimit)); + + next(); +} diff --git a/backend/src/routes/api-keys.ts b/backend/src/routes/api-keys.ts new file mode 100644 index 00000000..c9eb4f67 --- /dev/null +++ b/backend/src/routes/api-keys.ts @@ -0,0 +1,80 @@ +import { Router } from 'express'; +import { prisma } from '../lib/prisma.js'; +import { asyncHandler } from '../middleware/errorHandler.js'; +import { AppError } from '../middleware/errorHandler.js'; +import { quotaManagerService } from '../services/keys/quota-manager.js'; + +export const apiKeysRouter = Router(); + +function resolveTenant(req: any): string { + return (req.headers['x-tenant-id'] as string) ?? 'default'; +} + +apiKeysRouter.post('/', asyncHandler(async (req, res) => { + const tenantId = resolveTenant(req); + const { description, expiresAt } = req.body as { description?: string; expiresAt?: string }; + const keyId = `ak_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; + + const key = await prisma.apiKey.create({ + data: { + tenantId, + keyId, + description, + expiresAt: expiresAt ? new Date(expiresAt) : null, + }, + }); + res.status(201).json({ keyId: key.keyId, description: key.description }); +})); + +apiKeysRouter.get('/', asyncHandler(async (req, res) => { + const tenantId = resolveTenant(req); + const keys = await prisma.apiKey.findMany({ + where: { tenantId }, + orderBy: { createdAt: 'desc' }, + include: { + _count: { select: { usage: true } }, + quota: true, + }, + }); + res.json({ keys }); +})); + +apiKeysRouter.get('/:keyId', asyncHandler(async (req, res) => { + const tenantId = resolveTenant(req); + const key = await prisma.apiKey.findUnique({ + where: { keyId: req.params.keyId }, + include: { quota: true }, + }); + if (!key || key.tenantId !== tenantId) throw new AppError(404, 'API key not found', 'KEY_NOT_FOUND'); + res.json(key); +})); + +apiKeysRouter.delete('/:keyId', asyncHandler(async (req, res) => { + const tenantId = resolveTenant(req); + const key = await prisma.apiKey.findUnique({ where: { keyId: req.params.keyId } }); + if (!key || key.tenantId !== tenantId) throw new AppError(404, 'API key not found', 'KEY_NOT_FOUND'); + await prisma.apiKey.update({ where: { keyId: req.params.keyId }, data: { isActive: false, revokedAt: new Date() } }); + res.json({ success: true }); +})); + +apiKeysRouter.get('/:keyId/usage', asyncHandler(async (req, res) => { + const tenantId = resolveTenant(req); + const key = await prisma.apiKey.findUnique({ where: { keyId: req.params.keyId } }); + if (!key || key.tenantId !== tenantId) throw new AppError(404, 'API key not found', 'KEY_NOT_FOUND'); + const summary = await quotaManagerService.getUsageSummary(req.params.keyId); + res.json(summary); +})); + +apiKeysRouter.put('/:keyId/quota', asyncHandler(async (req, res) => { + const tenantId = resolveTenant(req); + const key = await prisma.apiKey.findUnique({ where: { keyId: req.params.keyId } }); + if (!key || key.tenantId !== tenantId) throw new AppError(404, 'API key not found', 'KEY_NOT_FOUND'); + const quota = await quotaManagerService.updateQuota(req.params.keyId, req.body); + res.json(quota); +})); + +apiKeysRouter.get('/analytics/summary', asyncHandler(async (req, res) => { + const tenantId = resolveTenant(req); + const summary = await quotaManagerService.getTenantUsageSummary(tenantId); + res.json(summary); +})); diff --git a/backend/src/routes/reports.ts b/backend/src/routes/reports.ts new file mode 100644 index 00000000..4a329451 --- /dev/null +++ b/backend/src/routes/reports.ts @@ -0,0 +1,80 @@ +import { Router } from 'express'; +import { asyncHandler } from '../middleware/errorHandler.js'; +import { customReportService } from '../services/reports/custom-report.js'; +import { AppError } from '../middleware/errorHandler.js'; + +export const reportsRouter = Router(); + +function resolveTenant(req: any): string { + return (req.headers['x-tenant-id'] as string) ?? 'default'; +} + +reportsRouter.post('/', asyncHandler(async (req, res) => { + const tenantId = resolveTenant(req); + const report = await customReportService.createReport({ ...req.body, tenantId }); + res.status(201).json(report); +})); + +reportsRouter.get('/', asyncHandler(async (req, res) => { + const tenantId = resolveTenant(req); + const { userId, isFavorite, search, limit } = req.query as any; + const reports = await customReportService.listReports(tenantId, { + userId, + isFavorite: isFavorite === 'true' ? true : isFavorite === 'false' ? false : undefined, + search, + limit: limit ? parseInt(limit) : undefined, + }); + res.json({ reports }); +})); + +reportsRouter.get('/:id', asyncHandler(async (req, res) => { + const report = await customReportService.getReport(req.params.id); + res.json(report); +})); + +reportsRouter.put('/:id', asyncHandler(async (req, res) => { + const report = await customReportService.updateReport(req.params.id, req.body); + res.json(report); +})); + +reportsRouter.delete('/:id', asyncHandler(async (req, res) => { + await customReportService.deleteReport(req.params.id); + res.json({ success: true }); +})); + +reportsRouter.post('/:id/favorite', asyncHandler(async (req, res) => { + const report = await customReportService.toggleFavorite(req.params.id); + res.json(report); +})); + +reportsRouter.post('/:id/pin', asyncHandler(async (req, res) => { + const report = await customReportService.togglePinned(req.params.id); + res.json(report); +})); + +reportsRouter.post('/:id/schedule', asyncHandler(async (req, res) => { + const schedule = await customReportService.scheduleReport({ reportId: req.params.id, ...req.body }); + res.json(schedule); +})); + +reportsRouter.delete('/:id/schedule', asyncHandler(async (req, res) => { + await customReportService.unscheduleReport(req.params.id); + res.json({ success: true }); +})); + +reportsRouter.post('/:id/template', asyncHandler(async (req, res) => { + const { name, description, isPublic } = req.body as { name: string; description?: string; isPublic?: boolean }; + if (!name) throw new AppError(400, 'Template name required', 'MISSING_NAME'); + const template = await customReportService.saveAsTemplate(req.params.id, name, description, isPublic); + res.status(201).json(template); +})); + +reportsRouter.get('/templates/list', asyncHandler(async (_req, res) => { + const templates = await customReportService.listTemplates(); + res.json({ templates }); +})); + +reportsRouter.get('/:id/data', asyncHandler(async (req, res) => { + const data = await customReportService.generateReportData(req.params.id); + res.json(data); +})); diff --git a/backend/src/routes/swaps.ts b/backend/src/routes/swaps.ts new file mode 100644 index 00000000..6227b302 --- /dev/null +++ b/backend/src/routes/swaps.ts @@ -0,0 +1,41 @@ +import { Router } from 'express'; +import { prisma } from '../lib/prisma.js'; +import { asyncHandler } from '../middleware/errorHandler.js'; +import { AppError } from '../middleware/errorHandler.js'; +import { htlcManagerService } from '../services/swaps/htlc-manager.js'; + +export const swapsRouter = Router(); + +function resolveTenant(req: any): string { + return (req.headers['x-tenant-id'] as string) ?? 'default'; +} + +swapsRouter.post('/', asyncHandler(async (req, res) => { + const tenantId = resolveTenant(req); + const swap = await htlcManagerService.createSwap({ ...req.body, tenantId }); + res.status(201).json(swap); +})); + +swapsRouter.get('/', asyncHandler(async (req, res) => { + const tenantId = resolveTenant(req); + const { status, limit } = req.query as any; + const swaps = await htlcManagerService.listSwaps(tenantId, { status, limit: limit ? parseInt(limit) : undefined }); + res.json({ swaps }); +})); + +swapsRouter.get('/:id', asyncHandler(async (req, res) => { + const swap = await htlcManagerService.getSwap(req.params.id); + res.json(swap); +})); + +swapsRouter.post('/:id/claim', asyncHandler(async (req, res) => { + const { preimage } = req.body as { preimage: string }; + if (!preimage) throw new AppError(400, 'Preimage is required', 'MISSING_PREIMAGE'); + const swap = await htlcManagerService.claimSwap({ swapId: req.params.id, preimage }); + res.json(swap); +})); + +swapsRouter.post('/:id/refund', asyncHandler(async (req, res) => { + const swap = await htlcManagerService.refundSwap(req.params.id); + res.json(swap); +})); diff --git a/backend/src/routes/treasury.ts b/backend/src/routes/treasury.ts new file mode 100644 index 00000000..aa42bf42 --- /dev/null +++ b/backend/src/routes/treasury.ts @@ -0,0 +1,59 @@ +import { Router } from 'express'; +import { asyncHandler } from '../middleware/errorHandler.js'; +import { treasuryService } from '../services/treasury/treasury.service.js'; +import { AppError } from '../middleware/errorHandler.js'; + +export const treasuryRouter = Router(); + +function resolveTenant(req: any): string { + return (req.headers['x-tenant-id'] as string) ?? 'default'; +} + +treasuryRouter.post('/proposals', asyncHandler(async (req, res) => { + const tenantId = resolveTenant(req); + const proposal = await treasuryService.propose({ ...req.body, tenantId }); + res.status(201).json(proposal); +})); + +treasuryRouter.get('/proposals', asyncHandler(async (req, res) => { + const tenantId = resolveTenant(req); + const { status, limit } = req.query as any; + const proposals = await treasuryService.listProposals(tenantId, { + status, + limit: limit ? parseInt(limit) : undefined, + }); + res.json({ proposals }); +})); + +treasuryRouter.get('/proposals/:id', asyncHandler(async (req, res) => { + const proposal = await treasuryService.getProposal(req.params.id); + res.json(proposal); +})); + +treasuryRouter.post('/proposals/:id/approve', asyncHandler(async (req, res) => { + const { signer } = req.body as { signer: string }; + if (!signer) throw new AppError(400, 'Signer address required', 'MISSING_SIGNER'); + const proposal = await treasuryService.approve(req.params.id, signer); + res.json(proposal); +})); + +treasuryRouter.post('/proposals/:id/reject', asyncHandler(async (req, res) => { + const { signer } = req.body as { signer: string }; + if (!signer) throw new AppError(400, 'Signer address required', 'MISSING_SIGNER'); + const proposal = await treasuryService.reject(req.params.id, signer); + res.json(proposal); +})); + +treasuryRouter.post('/proposals/:id/execute', asyncHandler(async (req, res) => { + const { executedBy, txHash } = req.body as { executedBy: string; txHash?: string }; + if (!executedBy) throw new AppError(400, 'Executor address required', 'MISSING_EXECUTOR'); + const execution = await treasuryService.execute(req.params.id, executedBy, txHash); + res.json(execution); +})); + +treasuryRouter.post('/proposals/:id/cancel', asyncHandler(async (req, res) => { + const { caller } = req.body as { caller: string }; + if (!caller) throw new AppError(400, 'Caller address required', 'MISSING_CALLER'); + const proposal = await treasuryService.cancel(req.params.id, caller); + res.json(proposal); +})); diff --git a/backend/src/services/keys/quota-manager.ts b/backend/src/services/keys/quota-manager.ts new file mode 100644 index 00000000..03f05f10 --- /dev/null +++ b/backend/src/services/keys/quota-manager.ts @@ -0,0 +1,101 @@ +import { prisma } from '../../lib/prisma.js'; +import { AppError } from '../../middleware/errorHandler.js'; + +export interface QuotaConfig { + requestsPerHour: number; + requestsPerDay: number; + alertAt50Pct: boolean; + alertAt80Pct: boolean; + alertAt100Pct: boolean; +} + +export class QuotaManagerService { + async getOrCreateQuota(keyId: string): Promise { + let quota = await prisma.apiKeyQuota.findUnique({ where: { keyId } }); + if (!quota) { + quota = await prisma.apiKeyQuota.create({ + data: { keyId }, + }); + } + return { + requestsPerHour: quota.requestsPerHour, + requestsPerDay: quota.requestsPerDay, + alertAt50Pct: quota.alertAt50Pct, + alertAt80Pct: quota.alertAt80Pct, + alertAt100Pct: quota.alertAt100Pct, + }; + } + + async updateQuota(keyId: string, data: Partial) { + return prisma.apiKeyQuota.upsert({ + where: { keyId }, + create: { keyId, ...data }, + update: data, + }); + } + + async getUsageSummary(keyId: string) { + const now = new Date(); + const hourAgo = new Date(now.getTime() - 3600_000); + const dayAgo = new Date(now.getTime() - 86400_000); + + const [hourlyCount, dailyCount, recentUsage, quota] = await Promise.all([ + prisma.apiKeyUsage.count({ + where: { keyId, recordedAt: { gte: hourAgo } }, + }), + prisma.apiKeyUsage.count({ + where: { keyId, recordedAt: { gte: dayAgo } }, + }), + prisma.apiKeyUsage.findMany({ + where: { keyId, recordedAt: { gte: dayAgo } }, + orderBy: { recordedAt: 'desc' }, + take: 100, + }), + prisma.apiKeyQuota.findUnique({ where: { keyId } }), + ]); + + return { + keyId, + hourlyCount, + dailyCount, + hourlyLimit: quota?.requestsPerHour ?? 1000, + dailyLimit: quota?.requestsPerDay ?? 10000, + usage: recentUsage, + }; + } + + async getTenantUsageSummary(tenantId: string) { + const now = new Date(); + const hourAgo = new Date(now.getTime() - 3600_000); + + const [totalHourly, keys, topEndpoints] = await Promise.all([ + prisma.apiKeyUsage.count({ + where: { tenantId, recordedAt: { gte: hourAgo } }, + }), + prisma.apiKey.findMany({ + where: { tenantId, isActive: true }, + include: { + _count: { select: { usage: true } }, + quota: true, + }, + }), + prisma.apiKeyUsage.groupBy({ + by: ['endpoint'], + where: { tenantId, recordedAt: { gte: hourAgo } }, + _count: { id: true }, + _avg: { latencyMs: true }, + orderBy: { _count: { id: 'desc' } }, + take: 10, + }), + ]); + + return { + totalHourlyRequests: totalHourly, + activeKeys: keys.length, + keys, + topEndpoints, + }; + } +} + +export const quotaManagerService = new QuotaManagerService(); diff --git a/backend/src/services/reports/custom-report.ts b/backend/src/services/reports/custom-report.ts new file mode 100644 index 00000000..40dfff2b --- /dev/null +++ b/backend/src/services/reports/custom-report.ts @@ -0,0 +1,230 @@ +import { prisma } from '../lib/prisma.js'; +import { AppError } from '../middleware/errorHandler.js'; + +export interface CreateReportInput { + tenantId: string; + userId?: string; + name: string; + description?: string; + metrics: string[]; + dimensions: string[]; + filters?: Record; + chartType: string; + dateRange?: Record; + config?: Record; +} + +export interface ScheduleReportInput { + reportId: string; + frequency: string; + dayOfWeek?: number; + dayOfMonth?: number; + time?: string; + recipients: string[]; +} + +export class CustomReportService { + async createReport(data: CreateReportInput) { + return prisma.savedReport.create({ + data: { + tenantId: data.tenantId, + userId: data.userId, + name: data.name, + description: data.description, + metrics: data.metrics, + dimensions: data.dimensions, + filters: data.filters ?? {}, + chartType: data.chartType as any, + dateRange: data.dateRange ?? {}, + config: data.config ?? {}, + }, + }); + } + + async getReport(id: string) { + const report = await prisma.savedReport.findUnique({ + where: { id }, + include: { schedule: true, template: true }, + }); + if (!report) throw new AppError(404, 'Report not found', 'REPORT_NOT_FOUND'); + return report; + } + + async listReports(tenantId: string, options?: { userId?: string; isFavorite?: boolean; search?: string; limit?: number }) { + const where: any = { tenantId }; + if (options?.userId) where.userId = options.userId; + if (options?.isFavorite !== undefined) where.isFavorite = options.isFavorite; + if (options?.search) { + where.OR = [ + { name: { contains: options.search, mode: 'insensitive' } }, + { description: { contains: options.search, mode: 'insensitive' } }, + ]; + } + return prisma.savedReport.findMany({ + where, + orderBy: [{ isFavorite: 'desc' }, { updatedAt: 'desc' }], + take: options?.limit ?? 50, + include: { schedule: true, template: true }, + }); + } + + async updateReport(id: string, data: Partial) { + const report = await prisma.savedReport.findUnique({ where: { id } }); + if (!report) throw new AppError(404, 'Report not found', 'REPORT_NOT_FOUND'); + return prisma.savedReport.update({ where: { id }, data: data as any }); + } + + async deleteReport(id: string) { + const report = await prisma.savedReport.findUnique({ where: { id } }); + if (!report) throw new AppError(404, 'Report not found', 'REPORT_NOT_FOUND'); + return prisma.savedReport.update({ where: { id }, data: { deletedAt: new Date() } }); + } + + async toggleFavorite(id: string) { + const report = await prisma.savedReport.findUnique({ where: { id } }); + if (!report) throw new AppError(404, 'Report not found', 'REPORT_NOT_FOUND'); + return prisma.savedReport.update({ where: { id }, data: { isFavorite: !report.isFavorite } }); + } + + async togglePinned(id: string) { + const report = await prisma.savedReport.findUnique({ where: { id } }); + if (!report) throw new AppError(404, 'Report not found', 'REPORT_NOT_FOUND'); + return prisma.savedReport.update({ where: { id }, data: { isPinned: !report.isPinned } }); + } + + async scheduleReport(data: ScheduleReportInput) { + const report = await prisma.savedReport.findUnique({ where: { id: data.reportId } }); + if (!report) throw new AppError(404, 'Report not found', 'REPORT_NOT_FOUND'); + + const nextSendAt = this.calculateNextSend(data.frequency, data.dayOfWeek, data.dayOfMonth, data.time ?? '09:00'); + + return prisma.scheduledReport.upsert({ + where: { reportId: data.reportId }, + create: { + reportId: data.reportId, + frequency: data.frequency as any, + dayOfWeek: data.dayOfWeek, + dayOfMonth: data.dayOfMonth, + time: data.time ?? '09:00', + recipients: data.recipients, + nextSendAt, + }, + update: { + frequency: data.frequency as any, + dayOfWeek: data.dayOfWeek, + dayOfMonth: data.dayOfMonth, + time: data.time ?? '09:00', + recipients: data.recipients, + nextSendAt, + }, + }); + } + + async unscheduleReport(reportId: string) { + const schedule = await prisma.scheduledReport.findUnique({ where: { reportId } }); + if (!schedule) throw new AppError(404, 'Schedule not found', 'SCHEDULE_NOT_FOUND'); + return prisma.scheduledReport.update({ where: { reportId }, data: { isActive: false } }); + } + + async saveAsTemplate(reportId: string, name: string, description?: string, isPublic = false) { + const report = await prisma.savedReport.findUnique({ where: { id: reportId } }); + if (!report) throw new AppError(404, 'Report not found', 'REPORT_NOT_FOUND'); + + return prisma.reportTemplate.upsert({ + where: { reportId }, + create: { + reportId, + name, + description, + metrics: report.metrics, + dimensions: report.dimensions, + filters: report.filters, + chartType: report.chartType, + dateRange: report.dateRange, + config: report.config, + isPublic, + }, + update: { + name, + description, + isPublic, + usageCount: { increment: 1 }, + }, + }); + } + + async listTemplates(isPublic = true) { + return prisma.reportTemplate.findMany({ + where: isPublic ? { isPublic: true } : {}, + orderBy: { usageCount: 'desc' }, + take: 50, + }); + } + + async generateReportData(reportId: string) { + const report = await prisma.savedReport.findUnique({ where: { id: reportId } }); + if (!report) throw new AppError(404, 'Report not found', 'REPORT_NOT_FOUND'); + + const metrics = report.metrics; + const dimensions = report.dimensions; + const dateRange = (report.dateRange as any) ?? {}; + + const dateFilter = dateRange.start && dateRange.end + ? { gte: new Date(dateRange.start), lte: new Date(dateRange.end) } + : { gte: new Date(Date.now() - 30 * 86400_000) }; + + const groupBy: any[] = []; + const select: any = {}; + + for (const dimension of dimensions) { + groupBy.push(dimension); + select[dimension] = true; + } + + for (const metric of metrics) { + if (metric === 'request_count') { + select._count = { id: true }; + } else if (metric === 'total_amount') { + select._sum = { amount: true }; + } else if (metric === 'avg_latency') { + select._avg = { latencyMs: true }; + } + } + + return { + report: { + id: report.id, + name: report.name, + chartType: report.chartType, + metrics: report.metrics, + dimensions: report.dimensions, + }, + data: [], + generatedAt: new Date().toISOString(), + }; + } + + private calculateNextSend(frequency: string, dayOfWeek?: number, dayOfMonth?: number, time?: string): Date { + const now = new Date(); + const [hours, minutes] = (time ?? '09:00').split(':').map(Number); + const next = new Date(now); + next.setHours(hours, minutes, 0, 0); + + if (frequency === 'daily') { + if (next <= now) next.setDate(next.getDate() + 1); + } else if (frequency === 'weekly') { + const targetDay = dayOfWeek ?? 1; + while (next.getDay() !== targetDay || next <= now) { + next.setDate(next.getDate() + 1); + } + } else if (frequency === 'monthly') { + const targetDay = Math.min(dayOfMonth ?? 1, 28); + next.setDate(targetDay); + if (next <= now) next.setMonth(next.getMonth() + 1); + } + + return next; + } +} + +export const customReportService = new CustomReportService(); diff --git a/backend/src/services/swaps/htlc-manager.ts b/backend/src/services/swaps/htlc-manager.ts new file mode 100644 index 00000000..2f9dad72 --- /dev/null +++ b/backend/src/services/swaps/htlc-manager.ts @@ -0,0 +1,120 @@ +import { prisma } from '../../lib/prisma.js'; +import { AppError } from '../../middleware/errorHandler.js'; + +export interface CreateSwapInput { + tenantId: string; + sender: string; + receiver: string; + tokenA: string; + tokenB: string; + amountA: string; + amountB: string; + hashlock: string; + timelock: number; + disputeDeadline?: number; + feeBps?: number; + feeCollector?: string; + contractAddress?: string; +} + +export interface ClaimSwapInput { + swapId: string; + preimage: string; +} + +export class HtlcManagerService { + async createSwap(data: CreateSwapInput) { + const swap = await prisma.atomicSwap.create({ + data: { + tenantId: data.tenantId, + swapId: BigInt(Date.now()), + sender: data.sender, + receiver: data.receiver, + tokenA: data.tokenA, + tokenB: data.tokenB, + amountA: data.amountA, + amountB: data.amountB, + hashlock: data.hashlock, + timelock: BigInt(data.timelock), + disputeDeadline: data.disputeDeadline ? BigInt(data.disputeDeadline) : null, + feeBps: data.feeBps ?? 30, + feeCollector: data.feeCollector, + contractAddress: data.contractAddress, + network: 'soroban', + expiresAt: new Date(data.timelock * 1000), + }, + }); + return swap; + } + + async getSwap(id: string) { + const swap = await prisma.atomicSwap.findUnique({ + where: { id }, + include: { secret: true }, + }); + if (!swap) throw new AppError(404, 'Swap not found', 'SWAP_NOT_FOUND'); + return swap; + } + + async getSwapByOnChainId(swapId: bigint) { + const swap = await prisma.atomicSwap.findUnique({ + where: { swapId }, + include: { secret: true }, + }); + if (!swap) throw new AppError(404, 'Swap not found', 'SWAP_NOT_FOUND'); + return swap; + } + + async listSwaps(tenantId: string, options?: { status?: string; limit?: number }) { + const where: any = { tenantId }; + if (options?.status) where.status = options.status; + return prisma.atomicSwap.findMany({ + where, + orderBy: { createdAt: 'desc' }, + take: options?.limit ?? 50, + }); + } + + async claimSwap(data: ClaimSwapInput) { + const swap = await prisma.atomicSwap.findUnique({ where: { id: data.swapId } }); + if (!swap) throw new AppError(404, 'Swap not found', 'SWAP_NOT_FOUND'); + if (swap.status !== 'pending') throw new AppError(400, 'Swap is not in pending state', 'SWAP_NOT_PENDING'); + + await prisma.$transaction([ + prisma.atomicSwap.update({ + where: { id: data.swapId }, + data: { status: 'claimed' }, + }), + prisma.atomicSwapSecret.upsert({ + where: { swapId: data.swapId }, + create: { swapId: data.swapId, preimage: data.preimage }, + update: { preimage: data.preimage }, + }), + ]); + + return prisma.atomicSwap.findUnique({ + where: { id: data.swapId }, + include: { secret: true }, + }); + } + + async refundSwap(id: string) { + const swap = await prisma.atomicSwap.findUnique({ where: { id } }); + if (!swap) throw new AppError(404, 'Swap not found', 'SWAP_NOT_FOUND'); + if (swap.status !== 'pending') throw new AppError(400, 'Swap is not in pending state', 'SWAP_NOT_PENDING'); + + return prisma.atomicSwap.update({ + where: { id }, + data: { status: 'refunded' }, + }); + } + + async updateSwapStatus(id: string, status: string) { + return prisma.atomicSwap.update({ + where: { id }, + data: { status: status as any }, + }); + } +} + +export const htlcManagerService = new HtlcManagerService(); diff --git a/backend/src/services/treasury/treasury.service.ts b/backend/src/services/treasury/treasury.service.ts new file mode 100644 index 00000000..1f1dc12b --- /dev/null +++ b/backend/src/services/treasury/treasury.service.ts @@ -0,0 +1,144 @@ +import { prisma } from '../../lib/prisma.js'; +import { AppError } from '../../middleware/errorHandler.js'; + +export interface ProposeInput { + tenantId: string; + proposer: string; + description: string; + target: string; + amount: string; + token?: string; + calldata?: string; + threshold: number; + timelockDelay: number; +} + +export class TreasuryService { + async propose(data: ProposeInput) { + const executeAfter = new Date(Date.now() + data.timelockDelay * 1000); + return prisma.treasuryProposal.create({ + data: { + tenantId: data.tenantId, + proposalId: BigInt(Date.now()), + proposer: data.proposer, + description: data.description, + target: data.target, + amount: data.amount, + token: data.token, + calldata: data.calldata, + status: 'pending', + threshold: data.threshold, + timelockDelay: BigInt(data.timelockDelay), + executeAfter, + }, + }); + } + + async getProposal(id: string) { + const proposal = await prisma.treasuryProposal.findUnique({ + where: { id }, + include: { approvals: true, execution: true }, + }); + if (!proposal) throw new AppError(404, 'Proposal not found', 'PROPOSAL_NOT_FOUND'); + return proposal; + } + + async listProposals(tenantId: string, options?: { status?: string; limit?: number }) { + const where: any = { tenantId }; + if (options?.status) where.status = options.status; + return prisma.treasuryProposal.findMany({ + where, + orderBy: { createdAt: 'desc' }, + take: options?.limit ?? 50, + include: { + approvals: { take: 20 }, + execution: true, + }, + }); + } + + async approve(proposalId: string, signer: string) { + const proposal = await prisma.treasuryProposal.findUnique({ where: { id: proposalId } }); + if (!proposal) throw new AppError(404, 'Proposal not found', 'PROPOSAL_NOT_FOUND'); + if (proposal.status !== 'pending') throw new AppError(400, 'Proposal is not pending', 'PROPOSAL_NOT_PENDING'); + + const existing = await prisma.treasuryApproval.findUnique({ + where: { proposalId_signer: { proposalId, signer } }, + }); + if (existing) throw new AppError(400, 'Already voted', 'ALREADY_VOTED'); + + await prisma.$transaction(async (tx) => { + await tx.treasuryApproval.create({ + data: { proposalId, signer, approved: true }, + }); + + const updated = await tx.treasuryProposal.update({ + where: { id: proposalId }, + data: { approvalCount: { increment: 1 } }, + }); + + if (updated.approvalCount >= updated.threshold) { + await tx.treasuryProposal.update({ + where: { id: proposalId }, + data: { status: 'approved' }, + }); + } + }); + + return this.getProposal(proposalId); + } + + async reject(proposalId: string, signer: string) { + const proposal = await prisma.treasuryProposal.findUnique({ where: { id: proposalId } }); + if (!proposal) throw new AppError(404, 'Proposal not found', 'PROPOSAL_NOT_FOUND'); + if (proposal.status !== 'pending' && proposal.status !== 'approved') { + throw new AppError(400, 'Proposal cannot be rejected in current state', 'INVALID_STATE'); + } + + const existing = await prisma.treasuryApproval.findUnique({ + where: { proposalId_signer: { proposalId, signer } }, + }); + if (existing) throw new AppError(400, 'Already voted', 'ALREADY_VOTED'); + + await prisma.treasuryApproval.create({ + data: { proposalId, signer, approved: false }, + }); + + return this.getProposal(proposalId); + } + + async execute(proposalId: string, executedBy: string, txHash?: string) { + const proposal = await prisma.treasuryProposal.findUnique({ where: { id: proposalId } }); + if (!proposal) throw new AppError(404, 'Proposal not found', 'PROPOSAL_NOT_FOUND'); + if (proposal.status !== 'approved') throw new AppError(400, 'Proposal is not approved', 'PROPOSAL_NOT_APPROVED'); + if (new Date() < proposal.executeAfter) throw new AppError(400, 'Timelock not elapsed', 'TIMELOCK_NOT_ELAPSED'); + + const [execution] = await prisma.$transaction([ + prisma.treasuryExecution.create({ + data: { proposalId, txHash, executedBy }, + }), + prisma.treasuryProposal.update({ + where: { id: proposalId }, + data: { status: 'executed', executedAt: new Date() }, + }), + ]); + + return execution; + } + + async cancel(proposalId: string, caller: string) { + const proposal = await prisma.treasuryProposal.findUnique({ where: { id: proposalId } }); + if (!proposal) throw new AppError(404, 'Proposal not found', 'PROPOSAL_NOT_FOUND'); + if (proposal.status === 'executed') throw new AppError(400, 'Already executed', 'ALREADY_EXECUTED'); + if (proposal.status === 'cancelled') throw new AppError(400, 'Already cancelled', 'ALREADY_CANCELLED'); + + if (proposal.proposer !== caller) throw new AppError(403, 'Only proposer can cancel', 'NOT_PROPOSER'); + + return prisma.treasuryProposal.update({ + where: { id: proposalId }, + data: { status: 'cancelled' }, + }); + } +} + +export const treasuryService = new TreasuryService(); diff --git a/contracts/evm/contracts/Treasury.sol b/contracts/evm/contracts/Treasury.sol new file mode 100644 index 00000000..c1edaf67 --- /dev/null +++ b/contracts/evm/contracts/Treasury.sol @@ -0,0 +1,253 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +error InvalidSignerSet(); +error DuplicateSigner(); +error InvalidThreshold(); +error InvalidTimelock(); +error NotSigner(); +error AlreadyVoted(); +error ProposalNotPending(); +error ProposalNotApproved(); +error TimelockNotElapsed(); +error AlreadyExecuted(); +error AlreadyCancelled(); +error NotProposer(); +error TransferFailed(); +error EmergencyThresholdMet(); +error ZeroAddress(); + +contract Treasury is ReentrancyGuard { + using SafeERC20 for IERC20; + + struct TreasuryConfig { + address[] signers; + uint256 threshold; + uint256 regularTimelock; + uint256 highValueTimelock; + uint256 highValueThreshold; + uint256 emergencyCancelThreshold; + } + + enum ProposalStatus { Pending, Approved, Executed, Rejected, Cancelled, Expired } + + struct Proposal { + uint256 id; + address proposer; + string description; + address target; + uint256 amount; + address token; + bytes data; + ProposalStatus status; + uint256 approvalCount; + uint256 rejectionCount; + uint256 createdAt; + uint256 timelockDelay; + uint256 executeAfter; + } + + TreasuryConfig private _config; + uint256 private _proposalCount; + mapping(uint256 => Proposal) private _proposals; + mapping(uint256 => mapping(address => bool)) private _hasApproved; + mapping(uint256 => mapping(address => bool)) private _hasRejected; + + event Proposed(uint256 indexed id, address indexed proposer, uint256 amount, uint256 executeAfter); + event Approved(uint256 indexed id, address indexed signer, uint256 approvalCount); + event Rejected(uint256 indexed id, address indexed signer); + event Executed(uint256 indexed id, address indexed target); + event Cancelled(uint256 indexed id, address indexed caller); + event EmergencyCancelled(uint256 indexed id, address indexed signer, uint256 rejectionCount); + event ConfigUpdated(address[] signers, uint256 threshold); + + constructor( + address[] memory signers_, + uint256 threshold_, + uint256 regularTimelock_, + uint256 highValueTimelock_, + uint256 highValueThreshold_, + uint256 emergencyCancelThreshold_ + ) { + if (signers_.length == 0 || signers_.length > 20) revert InvalidSignerSet(); + if (threshold_ == 0 || threshold_ > signers_.length) revert InvalidThreshold(); + if (emergencyCancelThreshold_ < threshold_ || emergencyCancelThreshold_ > signers_.length) revert InvalidThreshold(); + if (regularTimelock_ < 60 || regularTimelock_ > 7 days) revert InvalidTimelock(); + if (highValueTimelock_ < 60 || highValueTimelock_ > 7 days) revert InvalidTimelock(); + if (highValueTimelock_ < regularTimelock_) revert InvalidTimelock(); + + for (uint256 i = 0; i < signers_.length; i++) { + for (uint256 j = i + 1; j < signers_.length; j++) { + if (signers_[i] == signers_[j]) revert DuplicateSigner(); + } + if (signers_[i] == address(0)) revert ZeroAddress(); + } + + _config = TreasuryConfig({ + signers: signers_, + threshold: threshold_, + regularTimelock: regularTimelock_, + highValueTimelock: highValueTimelock_, + highValueThreshold: highValueThreshold_, + emergencyCancelThreshold: emergencyCancelThreshold_ + }); + } + + function getConfig() external view returns (TreasuryConfig memory) { + return _config; + } + + function _isSigner(address account) private view returns (bool) { + for (uint256 i = 0; i < _config.signers.length; i++) { + if (_config.signers[i] == account) return true; + } + return false; + } + + function propose( + string calldata description, + address target, + uint256 amount, + address token, + bytes calldata data + ) external returns (uint256) { + if (!_isSigner(msg.sender)) revert NotSigner(); + + _proposalCount++; + uint256 timelock = amount >= _config.highValueThreshold + ? _config.highValueTimelock + : _config.regularTimelock; + uint256 executeAfter = block.timestamp + timelock; + + _proposals[_proposalCount] = Proposal({ + id: _proposalCount, + proposer: msg.sender, + description: description, + target: target, + amount: amount, + token: token, + data: data, + status: ProposalStatus.Pending, + approvalCount: 0, + rejectionCount: 0, + createdAt: block.timestamp, + timelockDelay: timelock, + executeAfter: executeAfter + }); + + emit Proposed(_proposalCount, msg.sender, amount, executeAfter); + return _proposalCount; + } + + function approve(uint256 proposalId) external { + if (!_isSigner(msg.sender)) revert NotSigner(); + + Proposal storage p = _proposals[proposalId]; + if (p.id == 0) revert ProposalNotPending(); + if (p.status != ProposalStatus.Pending) revert ProposalNotPending(); + if (_hasApproved[proposalId][msg.sender] || _hasRejected[proposalId][msg.sender]) revert AlreadyVoted(); + + _hasApproved[proposalId][msg.sender] = true; + p.approvalCount++; + + if (p.approvalCount >= _config.threshold) { + p.status = ProposalStatus.Approved; + } + + emit Approved(proposalId, msg.sender, p.approvalCount); + } + + function reject(uint256 proposalId) external { + if (!_isSigner(msg.sender)) revert NotSigner(); + + Proposal storage p = _proposals[proposalId]; + if (p.id == 0) revert ProposalNotPending(); + if (p.status != ProposalStatus.Pending && p.status != ProposalStatus.Approved) revert ProposalNotPending(); + if (_hasApproved[proposalId][msg.sender] || _hasRejected[proposalId][msg.sender]) revert AlreadyVoted(); + + _hasRejected[proposalId][msg.sender] = true; + p.rejectionCount++; + + uint256 remainingSigners = _config.signers.length - p.rejectionCount; + if (remainingSigners < _config.threshold) { + p.status = ProposalStatus.Rejected; + } + + emit Rejected(proposalId, msg.sender); + } + + function execute(uint256 proposalId) external nonReentrant { + Proposal storage p = _proposals[proposalId]; + if (p.id == 0) revert ProposalNotApproved(); + if (p.status != ProposalStatus.Approved) revert ProposalNotApproved(); + if (block.timestamp < p.executeAfter) revert TimelockNotElapsed(); + + p.status = ProposalStatus.Executed; + + if (p.token == address(0)) { + (bool success, ) = p.target.call{value: p.amount}(p.data); + if (!success) revert TransferFailed(); + } else { + IERC20(p.token).safeTransfer(p.target, p.amount); + } + + emit Executed(proposalId, p.target); + } + + function cancel(uint256 proposalId) external { + Proposal storage p = _proposals[proposalId]; + if (p.id == 0) revert ProposalNotPending(); + if (p.status == ProposalStatus.Executed) revert AlreadyExecuted(); + if (p.status == ProposalStatus.Cancelled) revert AlreadyCancelled(); + + if (msg.sender == p.proposer) { + p.status = ProposalStatus.Cancelled; + } else if (_isSigner(msg.sender) && p.status == ProposalStatus.Pending) { + p.status = ProposalStatus.Cancelled; + } else { + revert NotProposer(); + } + + emit Cancelled(proposalId, msg.sender); + } + + function emergencyCancel(uint256 proposalId) external { + if (!_isSigner(msg.sender)) revert NotSigner(); + + Proposal storage p = _proposals[proposalId]; + if (p.id == 0) revert ProposalNotPending(); + if (p.status == ProposalStatus.Executed) revert AlreadyExecuted(); + + if (_hasRejected[proposalId][msg.sender]) revert AlreadyVoted(); + _hasRejected[proposalId][msg.sender] = true; + p.rejectionCount++; + + if (p.rejectionCount >= _config.emergencyCancelThreshold) { + p.status = ProposalStatus.Cancelled; + } + + emit EmergencyCancelled(proposalId, msg.sender, p.rejectionCount); + } + + function getProposal(uint256 proposalId) external view returns (Proposal memory) { + return _proposals[proposalId]; + } + + function getProposalCount() external view returns (uint256) { + return _proposalCount; + } + + function hasApproved(uint256 proposalId, address signer) external view returns (bool) { + return _hasApproved[proposalId][signer]; + } + + function hasRejected(uint256 proposalId, address signer) external view returns (bool) { + return _hasRejected[proposalId][signer]; + } + + receive() external payable {} +} diff --git a/contracts/soroban/htlc/Cargo.toml b/contracts/soroban/htlc/Cargo.toml new file mode 100644 index 00000000..a6acb2c9 --- /dev/null +++ b/contracts/soroban/htlc/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "agenticpay-htlc" +version = "0.1.0" +edition = "2021" +description = "Soroban Hash Time-Locked Contract for trustless atomic swaps between parties" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk = "21.7.6" + +[dev-dependencies] +soroban-sdk = { version = "21.7.6", features = ["testutils"] } + +[profile.release] +opt-level = "z" +overflow-checks = true +debug = 0 +strip = "symbols" +debug-assertions = false +panic = "abort" +codegen-units = 1 +lto = true diff --git a/contracts/soroban/htlc/src/lib.rs b/contracts/soroban/htlc/src/lib.rs new file mode 100644 index 00000000..5c39dc98 --- /dev/null +++ b/contracts/soroban/htlc/src/lib.rs @@ -0,0 +1,533 @@ +#![no_std] + +use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, vec, Address, BytesN, Env, IntoVal, Vec}; + +const BUMP_AMOUNT: u32 = 518_400; +const BUMP_THRESHOLD: u32 = 100_000; +const MAX_TIMELOCK_SECONDS: u64 = 2_592_000; +const MIN_TIMELOCK_SECONDS: u64 = 60; +const MAX_DISPUTE_WINDOW: u64 = 86_400; + +#[contracttype] +#[derive(Clone)] +pub struct Swap { + pub sender: Address, + pub receiver: Address, + pub token_a: Address, + pub token_b: Address, + pub amount_a: i128, + pub amount_b: i128, + pub hashlock: BytesN<32>, + pub timelock: u64, + pub dispute_deadline: u64, + pub status: SwapStatus, + pub fee_bps: u32, + pub fee_collector: Address, +} + +#[contracttype] +#[derive(Clone, Copy, PartialEq, Eq)] +#[repr(u32)] +pub enum SwapStatus { + Pending = 0, + Claimed = 1, + Refunded = 2, + Disputed = 3, +} + +#[contracttype] +pub enum DataKey { + Swap(u64), + SwapCount, + Secret(u64), + Admin, + Initialized, +} + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum HtlcError { + AlreadyInitialized = 1, + NotInitialized = 2, + Unauthorized = 3, + SwapNotFound = 4, + InvalidAmount = 5, + HashlockMismatch = 6, + TimelockExpired = 7, + TimelockNotExpired = 8, + SwapNotPending = 9, + AlreadyClaimed = 10, + AlreadyRefunded = 11, + InvalidTimelock = 12, + InvalidDisputeWindow = 13, + FeeTooHigh = 14, + DisputeWindowNotElapsed = 15, +} + +#[contract] +pub struct HtlcContract; + +fn bump_instance(env: &Env) { + env.storage().instance().extend_ttl(BUMP_THRESHOLD, BUMP_AMOUNT); +} + +#[contractimpl] +impl HtlcContract { + pub fn initialize(env: Env, admin: Address) -> Result<(), HtlcError> { + if env.storage().instance().has(&symbol_short!("init")) { + return Err(HtlcError::AlreadyInitialized); + } + admin.require_auth(); + env.storage().instance().set(&symbol_short!("admin"), &admin); + env.storage().instance().set(&symbol_short!("init"), &true); + env.storage().instance().set(&DataKey::SwapCount, &0u64); + bump_instance(&env); + Ok(()) + } + + pub fn admin(env: Env) -> Result { + env.storage() + .instance() + .get::<_, Address>(&symbol_short!("admin")) + .ok_or(HtlcError::NotInitialized) + } + + fn _require_initialized(env: &Env) -> Result<(), HtlcError> { + if !env.storage().instance().has(&symbol_short!("init")) { + return Err(HtlcError::NotInitialized); + } + Ok(()) + } + + pub fn create_swap( + env: Env, + sender: Address, + receiver: Address, + token_a: Address, + token_b: Address, + amount_a: i128, + amount_b: i128, + hashlock: BytesN<32>, + timelock_seconds: u64, + dispute_window_seconds: u64, + fee_bps: u32, + fee_collector: Address, + ) -> Result { + Self::_require_initialized(&env)?; + sender.require_auth(); + + if amount_a <= 0 || amount_b <= 0 { + return Err(HtlcError::InvalidAmount); + } + if timelock_seconds < MIN_TIMELOCK_SECONDS || timelock_seconds > MAX_TIMELOCK_SECONDS { + return Err(HtlcError::InvalidTimelock); + } + if dispute_window_seconds > MAX_DISPUTE_WINDOW { + return Err(HtlcError::InvalidDisputeWindow); + } + if fee_bps > 200 { + return Err(HtlcError::FeeTooHigh); + } + + let mut count: u64 = env.storage().instance().get(&DataKey::SwapCount).unwrap_or(0); + count += 1; + + let ledger_timestamp = env.ledger().timestamp(); + let swap = Swap { + sender, + receiver, + token_a, + token_b, + amount_a, + amount_b, + hashlock, + timelock: ledger_timestamp + timelock_seconds, + dispute_deadline: ledger_timestamp + timelock_seconds + dispute_window_seconds, + status: SwapStatus::Pending, + fee_bps, + fee_collector, + }; + + env.storage().persistent().set(&DataKey::Swap(count), &swap); + env.storage().instance().set(&DataKey::SwapCount, &count); + + let topics = (symbol_short!("swap"), symbol_short!("created")); + env.events().publish(topics, (count, swap.amount_a, swap.amount_b, swap.timelock)); + + bump_instance(&env); + Ok(count) + } + + pub fn claim(env: Env, swap_id: u64, preimage: BytesN<32>) -> Result<(), HtlcError> { + Self::_require_initialized(&env)?; + + let mut swap = env.storage() + .persistent() + .get::<_, Swap>(&DataKey::Swap(swap_id)) + .ok_or(HtlcError::SwapNotFound)?; + + if swap.status != SwapStatus::Pending { + return Err(HtlcError::SwapNotPending); + } + + let ledger_timestamp = env.ledger().timestamp(); + if ledger_timestamp >= swap.timelock { + return Err(HtlcError::TimelockExpired); + } + + let computed_hash = env.crypto().sha256(&preimage.into_val(&env)); + if computed_hash != swap.hashlock { + return Err(HtlcError::HashlockMismatch); + } + + swap.status = SwapStatus::Claimed; + env.storage().persistent().set(&DataKey::Swap(swap_id), &swap); + env.storage().persistent().set(&DataKey::Secret(swap_id), &preimage); + + let receiver = swap.receiver.clone(); + let topics = (symbol_short!("swap"), symbol_short!("claimed")); + env.events().publish(topics, (swap_id, receiver)); + + bump_instance(&env); + Ok(()) + } + + pub fn refund(env: Env, swap_id: u64) -> Result<(), HtlcError> { + Self::_require_initialized(&env)?; + + let mut swap = env.storage() + .persistent() + .get::<_, Swap>(&DataKey::Swap(swap_id)) + .ok_or(HtlcError::SwapNotFound)?; + + if swap.status != SwapStatus::Pending && swap.status != SwapStatus::Disputed { + return Err(HtlcError::SwapNotPending); + } + + let ledger_timestamp = env.ledger().timestamp(); + if ledger_timestamp < swap.timelock { + return Err(HtlcError::TimelockNotExpired); + } + + if swap.status == SwapStatus::Disputed && ledger_timestamp < swap.dispute_deadline { + return Err(HtlcError::DisputeWindowNotElapsed); + } + + swap.status = SwapStatus::Refunded; + env.storage().persistent().set(&DataKey::Swap(swap_id), &swap); + + let sender = swap.sender.clone(); + let topics = (symbol_short!("swap"), symbol_short!("refunded")); + env.events().publish(topics, (swap_id, sender)); + + bump_instance(&env); + Ok(()) + } + + pub fn raise_dispute(env: Env, swap_id: u64) -> Result<(), HtlcError> { + Self::_require_initialized(&env)?; + + let mut swap = env.storage() + .persistent() + .get::<_, Swap>(&DataKey::Swap(swap_id)) + .ok_or(HtlcError::SwapNotFound)?; + + if swap.status != SwapStatus::Pending { + return Err(HtlcError::SwapNotPending); + } + + let ledger_timestamp = env.ledger().timestamp(); + if ledger_timestamp >= swap.timelock { + return Err(HtlcError::TimelockExpired); + } + + swap.status = SwapStatus::Disputed; + env.storage().persistent().set(&DataKey::Swap(swap_id), &swap); + + let topics = (symbol_short!("swap"), symbol_short!("disputed")); + env.events().publish(topics, (swap_id,)); + + bump_instance(&env); + Ok(()) + } + + pub fn resolve_dispute(env: Env, admin: Address, swap_id: u64, release_to_receiver: bool) -> Result<(), HtlcError> { + Self::_require_initialized(&env)?; + + let stored_admin: Address = env.storage() + .instance() + .get(&symbol_short!("admin")) + .ok_or(HtlcError::NotInitialized)?; + admin.require_auth(); + if admin != stored_admin { + return Err(HtlcError::Unauthorized); + } + + let mut swap = env.storage() + .persistent() + .get::<_, Swap>(&DataKey::Swap(swap_id)) + .ok_or(HtlcError::SwapNotFound)?; + + if swap.status != SwapStatus::Disputed { + return Err(HtlcError::SwapNotPending); + } + + if release_to_receiver { + swap.status = SwapStatus::Claimed; + } else { + swap.status = SwapStatus::Refunded; + } + env.storage().persistent().set(&DataKey::Swap(swap_id), &swap); + + let topics = (symbol_short!("swap"), symbol_short!("resolved")); + env.events().publish(topics, (swap_id, release_to_receiver)); + + bump_instance(&env); + Ok(()) + } + + pub fn get_swap(env: Env, swap_id: u64) -> Result { + env.storage() + .persistent() + .get::<_, Swap>(&DataKey::Swap(swap_id)) + .ok_or(HtlcError::SwapNotFound) + } + + pub fn get_swap_count(env: Env) -> u64 { + env.storage().instance().get(&DataKey::SwapCount).unwrap_or(0) + } + + pub fn get_secret(env: Env, swap_id: u64) -> Result, HtlcError> { + env.storage() + .persistent() + .get::<_, BytesN<32>>(&DataKey::Secret(swap_id)) + .ok_or(HtlcError::SwapNotFound) + } + + pub fn version(env: Env) -> u32 { + 1 + } +} + +#[cfg(test)] +mod test { + use super::*; + use soroban_sdk::{testutils::Address as _, testutils::Ledger, vec, BytesN, Env, IntoVal}; + + fn setup() -> (Env, Address, Address, Address) { + let env = Env::default(); + let admin = Address::generate(&env); + let alice = Address::generate(&env); + let bob = Address::generate(&env); + HtlcContract::initialize(env.clone(), admin.clone()).unwrap(); + (env, admin, alice, bob) + } + + fn make_hashlock(env: &Env, secret: &BytesN<32>) -> BytesN<32> { + env.crypto().sha256(&secret.into_val(env)) + } + + #[test] + fn test_initialize() { + let env = Env::default(); + let admin = Address::generate(&env); + assert!(HtlcContract::initialize(env.clone(), admin.clone()).is_ok()); + assert!(HtlcContract::initialize(env, admin).is_err()); + } + + #[test] + fn test_create_and_claim_swap() { + let (env, _admin, alice, bob) = setup(); + let token_a = Address::generate(&env); + let token_b = Address::generate(&env); + let fee_collector = Address::generate(&env); + let secret = BytesN::from_array(&env, &[1u8; 32]); + let hashlock = make_hashlock(&env, &secret); + + let timelock_seconds: u64 = 3600; + let dispute_window: u64 = 3600; + + let swap_id = HtlcContract::create_swap( + env.clone(), + alice.clone(), + bob.clone(), + token_a.clone(), + token_b.clone(), + 1000, + 950, + hashlock.clone(), + timelock_seconds, + dispute_window, + 30, + fee_collector, + ).unwrap(); + + assert_eq!(swap_id, 1); + + HtlcContract::claim(env.clone(), swap_id, secret).unwrap(); + + let swap = HtlcContract::get_swap(env.clone(), swap_id).unwrap(); + assert_eq!(swap.status, SwapStatus::Claimed); + + let stored_secret = HtlcContract::get_secret(env.clone(), swap_id).unwrap(); + assert_eq!(stored_secret, secret); + } + + #[test] + fn test_refund_after_timelock() { + let (env, _admin, alice, bob) = setup(); + let token_a = Address::generate(&env); + let token_b = Address::generate(&env); + let fee_collector = Address::generate(&env); + let secret = BytesN::from_array(&env, &[2u8; 32]); + let hashlock = make_hashlock(&env, &secret); + + let swap_id = HtlcContract::create_swap( + env.clone(), + alice.clone(), + bob.clone(), + token_a.clone(), + token_b.clone(), + 1000, + 950, + hashlock, + 3600, + 3600, + 30, + fee_collector, + ).unwrap(); + + env.ledger().set_timestamp(env.ledger().timestamp() + 7200); + + HtlcContract::refund(env.clone(), swap_id).unwrap(); + let swap = HtlcContract::get_swap(env, swap_id).unwrap(); + assert_eq!(swap.status, SwapStatus::Refunded); + } + + #[test] + fn test_claim_rejects_wrong_preimage() { + let (env, _admin, alice, bob) = setup(); + let token_a = Address::generate(&env); + let token_b = Address::generate(&env); + let fee_collector = Address::generate(&env); + let secret = BytesN::from_array(&env, &[3u8; 32]); + let hashlock = make_hashlock(&env, &secret); + + let swap_id = HtlcContract::create_swap( + env.clone(), + alice.clone(), + bob.clone(), + token_a, + token_b, + 1000, + 950, + hashlock, + 3600, + 3600, + 30, + fee_collector, + ).unwrap(); + + let wrong_secret = BytesN::from_array(&env, &[4u8; 32]); + let result = HtlcContract::claim(env.clone(), swap_id, wrong_secret); + assert_eq!(result, Err(HtlcError::HashlockMismatch)); + } + + #[test] + fn test_raise_dispute_and_resolve() { + let (env, admin, alice, bob) = setup(); + let token_a = Address::generate(&env); + let token_b = Address::generate(&env); + let fee_collector = Address::generate(&env); + let secret = BytesN::from_array(&env, &[5u8; 32]); + let hashlock = make_hashlock(&env, &secret); + + let swap_id = HtlcContract::create_swap( + env.clone(), + alice, + bob, + token_a, + token_b, + 1000, + 950, + hashlock, + 3600, + 3600, + 30, + fee_collector, + ).unwrap(); + + HtlcContract::raise_dispute(env.clone(), swap_id).unwrap(); + let swap = HtlcContract::get_swap(env.clone(), swap_id).unwrap(); + assert_eq!(swap.status, SwapStatus::Disputed); + + HtlcContract::resolve_dispute(env.clone(), admin, swap_id, true).unwrap(); + let swap = HtlcContract::get_swap(env, swap_id).unwrap(); + assert_eq!(swap.status, SwapStatus::Claimed); + } + + #[test] + fn test_claim_fails_after_timelock() { + let (env, _admin, alice, bob) = setup(); + let token_a = Address::generate(&env); + let token_b = Address::generate(&env); + let fee_collector = Address::generate(&env); + let secret = BytesN::from_array(&env, &[6u8; 32]); + let hashlock = make_hashlock(&env, &secret); + + let swap_id = HtlcContract::create_swap( + env.clone(), + alice, + bob, + token_a, + token_b, + 1000, + 950, + hashlock, + 3600, + 3600, + 30, + fee_collector, + ).unwrap(); + + env.ledger().set_timestamp(env.ledger().timestamp() + 4000); + let result = HtlcContract::claim(env.clone(), swap_id, secret); + assert_eq!(result, Err(HtlcError::TimelockExpired)); + } + + #[test] + fn test_version() { + let (env, ..) = setup(); + assert_eq!(HtlcContract::version(env), 1); + } + + #[test] + fn test_double_claim_rejected() { + let (env, _admin, alice, bob) = setup(); + let token_a = Address::generate(&env); + let token_b = Address::generate(&env); + let fee_collector = Address::generate(&env); + let secret = BytesN::from_array(&env, &[7u8; 32]); + let hashlock = make_hashlock(&env, &secret); + + let swap_id = HtlcContract::create_swap( + env.clone(), + alice, + bob, + token_a, + token_b, + 1000, + 950, + hashlock, + 3600, + 3600, + 30, + fee_collector, + ).unwrap(); + + HtlcContract::claim(env.clone(), swap_id, secret.clone()).unwrap(); + let result = HtlcContract::claim(env.clone(), swap_id, secret); + assert_eq!(result, Err(HtlcError::SwapNotPending)); + } +} diff --git a/contracts/soroban/treasury/Cargo.toml b/contracts/soroban/treasury/Cargo.toml new file mode 100644 index 00000000..ec618d4a --- /dev/null +++ b/contracts/soroban/treasury/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "agenticpay-treasury" +version = "0.1.0" +edition = "2021" +description = "Soroban multi-signature timelock treasury management system" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk = "21.7.6" + +[dev-dependencies] +soroban-sdk = { version = "21.7.6", features = ["testutils"] } + +[profile.release] +opt-level = "z" +overflow-checks = true +debug = 0 +strip = "symbols" +debug-assertions = false +panic = "abort" +codegen-units = 1 +lto = true diff --git a/contracts/soroban/treasury/src/lib.rs b/contracts/soroban/treasury/src/lib.rs new file mode 100644 index 00000000..5b16ee14 --- /dev/null +++ b/contracts/soroban/treasury/src/lib.rs @@ -0,0 +1,617 @@ +#![no_std] + +use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, Address, Env, Vec, Map}; + +const BUMP_AMOUNT: u32 = 518_400; +const BUMP_THRESHOLD: u32 = 100_000; +const MAX_SIGNERS: u32 = 20; +const MIN_THRESHOLD: u32 = 1; +const MAX_TIMELOCK_SECONDS: u64 = 604_800; +const MIN_TIMELOCK_SECONDS: u64 = 60; +const HIGH_VALUE_THRESHOLD: i128 = 100_000_000_000; + +#[contracttype] +#[derive(Clone)] +pub struct TreasuryConfig { + pub signers: Vec
, + pub threshold: u32, + pub regular_timelock: u64, + pub high_value_timelock: u64, + pub high_value_threshold: i128, + pub emergency_cancel_threshold: u32, +} + +#[contracttype] +#[derive(Clone, Copy, PartialEq, Eq)] +#[repr(u32)] +pub enum ProposalStatus { + Pending = 0, + Approved = 1, + Executed = 2, + Rejected = 3, + Cancelled = 4, + Expired = 5, +} + +#[contracttype] +#[derive(Clone)] +pub struct Proposal { + pub id: u64, + pub proposer: Address, + pub description: String, + pub target: Address, + pub amount: i128, + pub token: Option
, + pub calldata: Vec, + pub status: ProposalStatus, + pub approvals: Map, + pub rejections: Map, + pub approval_count: u32, + pub rejection_count: u32, + pub created_at: u64, + pub timelock_delay: u64, + pub execute_after: u64, +} + +#[contracttype] +pub enum DataKey { + Config, + Proposal(u64), + ProposalCount, + Initialized, + Admin, +} + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum TreasuryError { + AlreadyInitialized = 1, + NotInitialized = 2, + Unauthorized = 3, + NotSigner = 4, + ProposalNotFound = 5, + InvalidThreshold = 6, + InvalidTimelock = 7, + TooManySigners = 8, + DuplicateSigner = 9, + InsufficientSigners = 10, + AlreadyVoted = 11, + ProposalNotPending = 12, + ProposalNotApproved = 13, + TimelockNotElapsed = 14, + AlreadyExecuted = 15, + AlreadyCancelled = 16, + NotProposer = 17, + InsufficientEmergencyApprovals = 18, + CannotExecutePending = 19, + MinTimelockNotMet = 20, + FeeTooHigh = 21, +} + +#[contract] +pub struct TreasuryContract; + +fn bump_instance(env: &Env) { + env.storage().instance().extend_ttl(BUMP_THRESHOLD, BUMP_AMOUNT); +} + +#[contractimpl] +impl TreasuryContract { + pub fn initialize( + env: Env, + admin: Address, + signers: Vec
, + threshold: u32, + regular_timelock: u64, + high_value_timelock: u64, + emergency_cancel_threshold: u32, + ) -> Result<(), TreasuryError> { + if env.storage().instance().has(&symbol_short!("init")) { + return Err(TreasuryError::AlreadyInitialized); + } + admin.require_auth(); + + let signer_count = signers.len(); + if signer_count < MIN_THRESHOLD as u32 || signer_count > MAX_SIGNERS { + return Err(TreasuryError::TooManySigners); + } + if threshold < MIN_THRESHOLD || threshold > signer_count { + return Err(TreasuryError::InvalidThreshold); + } + if emergency_cancel_threshold < threshold || emergency_cancel_threshold > signer_count { + return Err(TreasuryError::InvalidThreshold); + } + if regular_timelock < MIN_TIMELOCK_SECONDS || regular_timelock > MAX_TIMELOCK_SECONDS { + return Err(TreasuryError::InvalidTimelock); + } + if high_value_timelock < MIN_TIMELOCK_SECONDS || high_value_timelock > MAX_TIMELOCK_SECONDS { + return Err(TreasuryError::InvalidTimelock); + } + if high_value_timelock < regular_timelock { + return Err(TreasuryError::InvalidTimelock); + } + + let mut seen = Map::new(&env); + for i in 0..signer_count { + let signer = signers.get(i).unwrap(); + if seen.contains_key(signer.clone()) { + return Err(TreasuryError::DuplicateSigner); + } + seen.set(signer, true); + } + + let config = TreasuryConfig { + signers, + threshold, + regular_timelock, + high_value_timelock, + high_value_threshold: HIGH_VALUE_THRESHOLD, + emergency_cancel_threshold, + }; + + env.storage().instance().set(&symbol_short!("admin"), &admin); + env.storage().instance().set(&DataKey::Config, &config); + env.storage().instance().set(&symbol_short!("init"), &true); + env.storage().instance().set(&DataKey::ProposalCount, &0u64); + bump_instance(&env); + Ok(()) + } + + fn _require_initialized(env: &Env) -> Result<(), TreasuryError> { + if !env.storage().instance().has(&symbol_short!("init")) { + return Err(TreasuryError::NotInitialized); + } + Ok(()) + } + + fn _get_config(env: &Env) -> TreasuryConfig { + env.storage().instance().get(&DataKey::Config).unwrap() + } + + pub fn admin(env: Env) -> Result { + env.storage() + .instance() + .get::<_, Address>(&symbol_short!("admin")) + .ok_or(TreasuryError::NotInitialized) + } + + pub fn get_config(env: Env) -> Result { + Self::_require_initialized(&env)?; + Ok(Self::_get_config(&env)) + } + + pub fn propose( + env: Env, + proposer: Address, + description: String, + target: Address, + amount: i128, + token: Option
, + calldata: Vec, + ) -> Result { + Self::_require_initialized(&env)?; + proposer.require_auth(); + + let config = Self::_get_config(&env); + let is_signer = config.signers.iter().any(|s| s == proposer); + if !is_signer { + return Err(TreasuryError::NotSigner); + } + + let timelock = if amount >= config.high_value_threshold { + config.high_value_timelock + } else { + config.regular_timelock + }; + + let mut count: u64 = env.storage().instance().get(&DataKey::ProposalCount).unwrap_or(0); + count += 1; + let ledger_time = env.ledger().timestamp(); + let execute_after = ledger_time + timelock; + + let proposal = Proposal { + id: count, + proposer: proposer.clone(), + description, + target, + amount, + token, + calldata, + status: ProposalStatus::Pending, + approvals: Map::new(&env), + rejections: Map::new(&env), + approval_count: 0, + rejection_count: 0, + created_at: ledger_time, + timelock_delay: timelock, + execute_after, + }; + + env.storage().persistent().set(&DataKey::Proposal(count), &proposal); + env.storage().instance().set(&DataKey::ProposalCount, &count); + + let topics = (symbol_short!("treasury"), symbol_short!("proposed")); + env.events().publish(topics, (count, proposer, amount, execute_after)); + + bump_instance(&env); + Ok(count) + } + + pub fn approve(env: Env, signer: Address, proposal_id: u64) -> Result<(), TreasuryError> { + Self::_require_initialized(&env)?; + signer.require_auth(); + + let config = Self::_get_config(&env); + let is_signer = config.signers.iter().any(|s| s == signer); + if !is_signer { + return Err(TreasuryError::NotSigner); + } + + let mut proposal = env.storage() + .persistent() + .get::<_, Proposal>(&DataKey::Proposal(proposal_id)) + .ok_or(TreasuryError::ProposalNotFound)?; + + if proposal.status != ProposalStatus::Pending { + return Err(TreasuryError::ProposalNotPending); + } + if proposal.approvals.contains_key(signer.clone()) || proposal.rejections.contains_key(signer.clone()) { + return Err(TreasuryError::AlreadyVoted); + } + + proposal.approvals.set(signer.clone(), true); + proposal.approval_count += 1; + + if proposal.approval_count >= config.threshold { + proposal.status = ProposalStatus::Approved; + } + + env.storage().persistent().set(&DataKey::Proposal(proposal_id), &proposal); + + let topics = (symbol_short!("treasury"), symbol_short!("approved")); + env.events().publish(topics, (proposal_id, signer, proposal.approval_count)); + + bump_instance(&env); + Ok(()) + } + + pub fn reject(env: Env, signer: Address, proposal_id: u64) -> Result<(), TreasuryError> { + Self::_require_initialized(&env)?; + signer.require_auth(); + + let config = Self::_get_config(&env); + let is_signer = config.signers.iter().any(|s| s == signer); + if !is_signer { + return Err(TreasuryError::NotSigner); + } + + let mut proposal = env.storage() + .persistent() + .get::<_, Proposal>(&DataKey::Proposal(proposal_id)) + .ok_or(TreasuryError::ProposalNotFound)?; + + if proposal.status != ProposalStatus::Pending && proposal.status != ProposalStatus::Approved { + return Err(TreasuryError::ProposalNotPending); + } + if proposal.approvals.contains_key(signer.clone()) || proposal.rejections.contains_key(signer.clone()) { + return Err(TreasuryError::AlreadyVoted); + } + + proposal.rejections.set(signer.clone(), true); + proposal.rejection_count += 1; + + let remaining = config.signers.len() - proposal.rejection_count; + if remaining < config.threshold { + proposal.status = ProposalStatus::Rejected; + } + + env.storage().persistent().set(&DataKey::Proposal(proposal_id), &proposal); + + let topics = (symbol_short!("treasury"), symbol_short!("rejected")); + env.events().publish(topics, (proposal_id, signer)); + + bump_instance(&env); + Ok(()) + } + + pub fn execute(env: Env, proposal_id: u64) -> Result<(), TreasuryError> { + Self::_require_initialized(&env)?; + + let proposal = env.storage() + .persistent() + .get::<_, Proposal>(&DataKey::Proposal(proposal_id)) + .ok_or(TreasuryError::ProposalNotFound)?; + + if proposal.status != ProposalStatus::Approved { + return Err(TreasuryError::ProposalNotApproved); + } + + let ledger_time = env.ledger().timestamp(); + if ledger_time < proposal.execute_after { + return Err(TreasuryError::TimelockNotElapsed); + } + + let mut executable = proposal.clone(); + executable.status = ProposalStatus::Executed; + env.storage().persistent().set(&DataKey::Proposal(proposal_id), &executable); + + let topics = (symbol_short!("treasury"), symbol_short!("executed")); + env.events().publish(topics, (proposal_id, proposal.target)); + + bump_instance(&env); + Ok(()) + } + + pub fn cancel(env: Env, caller: Address, proposal_id: u64) -> Result<(), TreasuryError> { + Self::_require_initialized(&env)?; + caller.require_auth(); + + let mut proposal = env.storage() + .persistent() + .get::<_, Proposal>(&DataKey::Proposal(proposal_id)) + .ok_or(TreasuryError::ProposalNotFound)?; + + if proposal.status == ProposalStatus::Executed { + return Err(TreasuryError::AlreadyExecuted); + } + if proposal.status == ProposalStatus::Cancelled { + return Err(TreasuryError::AlreadyCancelled); + } + + let config = Self::_get_config(&env); + let is_proposer = proposal.proposer == caller; + let is_signer = config.signers.iter().any(|s| s == caller); + + if is_proposer { + proposal.status = ProposalStatus::Cancelled; + } else if is_signer && proposal.status == ProposalStatus::Pending { + proposal.status = ProposalStatus::Cancelled; + } else { + return Err(TreasuryError::Unauthorized); + } + + env.storage().persistent().set(&DataKey::Proposal(proposal_id), &proposal); + + let topics = (symbol_short!("treasury"), symbol_short!("cancelled")); + env.events().publish(topics, (proposal_id, caller)); + + bump_instance(&env); + Ok(()) + } + + pub fn emergency_cancel(env: Env, caller: Address, proposal_id: u64) -> Result<(), TreasuryError> { + Self::_require_initialized(&env)?; + caller.require_auth(); + + let config = Self::_get_config(&env); + let is_signer = config.signers.iter().any(|s| s == caller); + if !is_signer { + return Err(TreasuryError::NotSigner); + } + + let mut proposal = env.storage() + .persistent() + .get::<_, Proposal>(&DataKey::Proposal(proposal_id)) + .ok_or(TreasuryError::ProposalNotFound)?; + + if proposal.status == ProposalStatus::Executed { + return Err(TreasuryError::AlreadyExecuted); + } + + proposal.rejections.set(caller.clone(), true); + proposal.rejection_count += 1; + + if proposal.rejection_count >= config.emergency_cancel_threshold { + proposal.status = ProposalStatus::Cancelled; + } + + env.storage().persistent().set(&DataKey::Proposal(proposal_id), &proposal); + + let topics = (symbol_short!("treasury"), symbol_short!("emergency_cancel")); + env.events().publish(topics, (proposal_id, caller, proposal.rejection_count)); + + bump_instance(&env); + Ok(()) + } + + pub fn get_proposal(env: Env, proposal_id: u64) -> Result { + env.storage() + .persistent() + .get::<_, Proposal>(&DataKey::Proposal(proposal_id)) + .ok_or(TreasuryError::ProposalNotFound) + } + + pub fn get_proposal_count(env: Env) -> u64 { + env.storage().instance().get(&DataKey::ProposalCount).unwrap_or(0) + } + + pub fn version(env: Env) -> u32 { + 1 + } +} + +#[cfg(test)] +mod test { + use super::*; + use soroban_sdk::{testutils::Address as _, vec, Map, String}; + + fn setup() -> (Env, Address, Vec
, Address) { + let env = Env::default(); + let admin = Address::generate(&env); + let alice = Address::generate(&env); + let bob = Address::generate(&env); + let carol = Address::generate(&env); + let signers = vec![&env, alice.clone(), bob.clone(), carol.clone()]; + + TreasuryContract::initialize( + env.clone(), + admin.clone(), + signers.clone(), + 2, + 3600, + 86400, + 3, + ).unwrap(); + + (env, admin, signers, alice) + } + + #[test] + fn test_initialize_and_config() { + let env = Env::default(); + let admin = Address::generate(&env); + let alice = Address::generate(&env); + let bob = Address::generate(&env); + let signers = vec![&env, alice, bob]; + + TreasuryContract::initialize( + env.clone(), + admin.clone(), + signers.clone(), + 2, + 3600, + 86400, + 2, + ).unwrap(); + + let config = TreasuryContract::get_config(env.clone()).unwrap(); + assert_eq!(config.threshold, 2); + assert_eq!(config.signers.len(), 2); + } + + #[test] + fn test_propose_and_approve() { + let (env, _admin, signers, alice) = setup(); + let target = Address::generate(&env); + + let pid = TreasuryContract::propose( + env.clone(), + alice.clone(), + String::from_str(&env, "test proposal"), + target, + 1000, + None, + vec![&env], + ).unwrap(); + + assert_eq!(pid, 1); + + let bob = signers.get(1).unwrap(); + TreasuryContract::approve(env.clone(), bob, pid).unwrap(); + + let proposal = TreasuryContract::get_proposal(env.clone(), pid).unwrap(); + assert_eq!(proposal.approval_count, 1); + assert_eq!(proposal.status, ProposalStatus::Pending); + + TreasuryContract::approve(env.clone(), alice, pid).unwrap(); + let proposal = TreasuryContract::get_proposal(env.clone(), pid).unwrap(); + assert_eq!(proposal.approval_count, 2); + assert_eq!(proposal.status, ProposalStatus::Approved); + } + + #[test] + fn test_execute_after_timelock() { + let (env, _admin, signers, alice) = setup(); + let target = Address::generate(&env); + + let pid = TreasuryContract::propose( + env.clone(), + alice.clone(), + String::from_str(&env, "exec proposal"), + target, + 1000, + None, + vec![&env], + ).unwrap(); + + TreasuryContract::approve(env.clone(), alice, pid).unwrap(); + let bob = signers.get(1).unwrap(); + TreasuryContract::approve(env.clone(), bob, pid).unwrap(); + + let proposal = TreasuryContract::get_proposal(env.clone(), pid).unwrap(); + assert_eq!(proposal.status, ProposalStatus::Approved); + + env.ledger().set_timestamp(env.ledger().timestamp() + 7200); + + TreasuryContract::execute(env.clone(), pid).unwrap(); + let proposal = TreasuryContract::get_proposal(env.clone(), pid).unwrap(); + assert_eq!(proposal.status, ProposalStatus::Executed); + } + + #[test] + fn test_execute_before_timelock_fails() { + let (env, _admin, signers, alice) = setup(); + let target = Address::generate(&env); + + let pid = TreasuryContract::propose( + env.clone(), + alice.clone(), + String::from_str(&env, "early exec"), + target, + 1000, + None, + vec![&env], + ).unwrap(); + + TreasuryContract::approve(env.clone(), alice, pid).unwrap(); + let bob = signers.get(1).unwrap(); + TreasuryContract::approve(env.clone(), bob, pid).unwrap(); + + let result = TreasuryContract::execute(env.clone(), pid); + assert_eq!(result, Err(TreasuryError::TimelockNotElapsed)); + } + + #[test] + fn test_reject_proposal() { + let (env, _admin, signers, alice) = setup(); + let target = Address::generate(&env); + + let pid = TreasuryContract::propose( + env.clone(), + alice, + String::from_str(&env, "reject test"), + target, + 1000, + None, + vec![&env], + ).unwrap(); + + let bob = signers.get(1).unwrap(); + let carol = signers.get(2).unwrap(); + + TreasuryContract::reject(env.clone(), bob, pid).unwrap(); + let proposal = TreasuryContract::get_proposal(env.clone(), pid).unwrap(); + assert_eq!(proposal.rejection_count, 1); + + TreasuryContract::reject(env.clone(), carol, pid).unwrap(); + let proposal = TreasuryContract::get_proposal(env, pid).unwrap(); + assert_eq!(proposal.status, ProposalStatus::Rejected); + } + + #[test] + fn test_cancel_by_proposer() { + let (env, _admin, _signers, alice) = setup(); + let target = Address::generate(&env); + + let pid = TreasuryContract::propose( + env.clone(), + alice.clone(), + String::from_str(&env, "cancel test"), + target, + 1000, + None, + vec![&env], + ).unwrap(); + + TreasuryContract::cancel(env.clone(), alice, pid).unwrap(); + let proposal = TreasuryContract::get_proposal(env, pid).unwrap(); + assert_eq!(proposal.status, ProposalStatus::Cancelled); + } + + #[test] + fn test_version() { + let (env, ..) = setup(); + assert_eq!(TreasuryContract::version(env), 1); + } +} diff --git a/frontend/src/app/dashboard/developers/api-keys/page.tsx b/frontend/src/app/dashboard/developers/api-keys/page.tsx new file mode 100644 index 00000000..61f06787 --- /dev/null +++ b/frontend/src/app/dashboard/developers/api-keys/page.tsx @@ -0,0 +1,284 @@ +"use client"; + +import React, { useEffect, useState } from 'react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; + +const tenantId = 't_123'; + +interface ApiKeyUsage { + id: string; + endpoint: string; + method: string; + statusCode: number; + latencyMs: number; + recordedAt: string; +} + +interface UsageSummary { + keyId: string; + hourlyCount: number; + dailyCount: number; + hourlyLimit: number; + dailyLimit: number; + usage: ApiKeyUsage[]; +} + +interface ApiKey { + keyId: string; + description: string | null; + isActive: boolean; + createdAt: string; + _count: { usage: number }; + quota: { requestsPerHour: number; requestsPerDay: number } | null; +} + +export default function ApiKeysPage() { + const [keys, setKeys] = useState([]); + const [selectedKey, setSelectedKey] = useState(null); + const [usageSummary, setUsageSummary] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [showCreate, setShowCreate] = useState(false); + const [description, setDescription] = useState(''); + + const loadKeys = async () => { + setIsLoading(true); + try { + const res = await fetch('/api/v1/developers/api-keys', { + headers: { 'x-tenant-id': tenantId }, + }); + const data = await res.json(); + setKeys(data.keys ?? []); + } catch { + console.error('Failed to load API keys'); + } + setIsLoading(false); + }; + + const loadUsage = async (keyId: string) => { + try { + const res = await fetch(`/api/v1/developers/api-keys/${keyId}/usage`, { + headers: { 'x-tenant-id': tenantId }, + }); + const data = await res.json(); + setUsageSummary(data); + setSelectedKey(keyId); + } catch { + console.error('Failed to load usage'); + } + }; + + useEffect(() => { + loadKeys(); + }, []); + + const handleCreate = async () => { + try { + await fetch('/api/v1/developers/api-keys', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-tenant-id': tenantId }, + body: JSON.stringify({ description }), + }); + setShowCreate(false); + setDescription(''); + loadKeys(); + } catch { + console.error('Failed to create key'); + } + }; + + const handleDelete = async (keyId: string) => { + try { + await fetch(`/api/v1/developers/api-keys/${keyId}`, { + method: 'DELETE', + headers: { 'x-tenant-id': tenantId }, + }); + loadKeys(); + if (selectedKey === keyId) { + setSelectedKey(null); + setUsageSummary(null); + } + } catch { + console.error('Failed to delete key'); + } + }; + + const usagePercent = (summary: UsageSummary) => { + const hourlyPct = Math.round((summary.hourlyCount / summary.hourlyLimit) * 100); + const dailyPct = Math.round((summary.dailyCount / summary.dailyLimit) * 100); + return { hourlyPct, dailyPct }; + }; + + return ( +
+
+
+
+

API Key Usage & Analytics

+

Monitor and manage your API key consumption

+
+ + + + + + + Create API Key + Give your key a descriptive name + +
+
+ + setDescription(e.target.value)} placeholder="Production key" /> +
+ +
+
+
+
+ +
+
+ + + API Keys + {keys.length} active keys + + + {isLoading ? ( +

Loading...

+ ) : keys.length === 0 ? ( +

No API keys yet

+ ) : ( +
+ {keys.map((key) => ( + + ))} +
+ )} +
+
+
+ +
+ {selectedKey && usageSummary ? ( + <> +
+ + + Hourly Usage + + +
{usageSummary.hourlyCount}
+
of {usageSummary.hourlyLimit} limit
+
+
80 + ? 'bg-red-500' + : usagePercent(usageSummary).hourlyPct > 50 + ? 'bg-yellow-500' + : 'bg-green-500' + }`} + style={{ width: `${Math.min(usagePercent(usageSummary).hourlyPct, 100)}%` }} + /> +
+ + + + + Daily Usage + + +
{usageSummary.dailyCount}
+
of {usageSummary.dailyLimit} limit
+
+
80 + ? 'bg-red-500' + : usagePercent(usageSummary).dailyPct > 50 + ? 'bg-yellow-500' + : 'bg-green-500' + }`} + style={{ width: `${Math.min(usagePercent(usageSummary).dailyPct, 100)}%` }} + /> +
+ + +
+ + + +
+ Recent Requests + +
+
+ + {usageSummary.usage.length === 0 ? ( +

No recent requests

+ ) : ( +
+ + + + + + + + + + + + {usageSummary.usage.slice(0, 50).map((u) => ( + + + + + + + + ))} + +
EndpointMethodStatusLatencyTime
{u.endpoint} + {u.method} + + = 400 ? 'text-red-600' : 'text-green-600'}>{u.statusCode} + {u.latencyMs}ms{new Date(u.recordedAt).toLocaleTimeString()}
+
+ )} +
+
+ + ) : ( + + +

Select an API key to view its usage analytics

+
+
+ )} +
+
+
+
+ ); +} diff --git a/frontend/src/app/dashboard/reports/builder/page.tsx b/frontend/src/app/dashboard/reports/builder/page.tsx new file mode 100644 index 00000000..f6f70b4c --- /dev/null +++ b/frontend/src/app/dashboard/reports/builder/page.tsx @@ -0,0 +1,337 @@ +"use client"; + +import React, { useEffect } from 'react'; +import { useReportBuilderStore } from '../../../store/report-builder-store'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Badge } from '@/components/ui/badge'; + +const METRICS = [ + { id: 'request_count', label: 'Request Count' }, + { id: 'total_amount', label: 'Total Amount' }, + { id: 'avg_latency', label: 'Avg Latency' }, + { id: 'success_rate', label: 'Success Rate' }, + { id: 'revenue', label: 'Revenue' }, + { id: 'tx_count', label: 'Transaction Count' }, + { id: 'fees', label: 'Fees' }, + { id: 'unique_users', label: 'Unique Users' }, +]; + +const DIMENSIONS = [ + { id: 'date', label: 'Date' }, + { id: 'chain', label: 'Chain' }, + { id: 'currency', label: 'Currency' }, + { id: 'merchant', label: 'Merchant' }, + { id: 'status', label: 'Status' }, + { id: 'endpoint', label: 'Endpoint' }, +]; + +const CHART_TYPES = [ + { id: 'line' as const, label: 'Line', icon: '📈' }, + { id: 'bar' as const, label: 'Bar', icon: '📊' }, + { id: 'pie' as const, label: 'Pie', icon: '🥧' }, + { id: 'table' as const, label: 'Table', icon: '📋' }, + { id: 'heatmap' as const, label: 'Heatmap', icon: '🗺️' }, + { id: 'area' as const, label: 'Area', icon: '📉' }, +]; + +const DATE_PRESETS = [ + { id: 'last7d' as const, label: 'Last 7 Days' }, + { id: 'last30d' as const, label: 'Last 30 Days' }, + { id: 'thisMonth' as const, label: 'This Month' }, + { id: 'custom' as const, label: 'Custom Range' }, +]; + +export default function ReportBuilderPage() { + const { + currentStep, config, savedReports, templates, + isSaving, isLoading, error, + setStep, nextStep, prevStep, updateConfig, + setMetric, removeMetric, setDimension, removeDimension, + setChartType, setDateRange, + reset, loadReports, saveReport, loadTemplates, + } = useReportBuilderStore(); + + const tenantId = 't_123'; + + useEffect(() => { + loadReports(tenantId); + loadTemplates(); + }, [loadReports, loadTemplates, tenantId]); + + const handleSave = async () => { + await saveReport(tenantId); + }; + + return ( +
+
+
+

Custom Report Builder

+
+ + +
+
+ + {error && ( +
+ {error} +
+ )} + +
+ {['Configure Metrics', 'Select Dimensions', 'Choose Chart', 'Review'].map((label, i) => ( + + + {i < 3 &&
} + + ))} +
+ +
+
+ {currentStep === 0 && ( + + + Select Metrics + Choose the data points to include in your report + + +
+ {METRICS.map((metric) => { + const isSelected = config.metrics.includes(metric.id); + return ( + + ); + })} +
+
+
+ )} + + {currentStep === 1 && ( + + + Select Dimensions + Choose how to group and filter your data + + +
+ {DIMENSIONS.map((dim) => { + const isSelected = config.dimensions.includes(dim.id); + return ( + + ); + })} +
+
+
+ )} + + {currentStep === 2 && ( + + + Choose Visualization + Select how your report data will be displayed + + +
+ +
+ {CHART_TYPES.map((ct) => ( + + ))} +
+
+ +
+ +
+ {DATE_PRESETS.map((preset) => ( + + ))} +
+ {config.dateRange.preset === 'custom' && ( +
+ setDateRange({ ...config.dateRange, start: e.target.value })} + placeholder="Start date" + /> + setDateRange({ ...config.dateRange, end: e.target.value })} + placeholder="End date" + /> +
+ )} +
+
+
+ )} + + {currentStep === 3 && ( + + + Review Report + Review your report configuration before saving + + +
+ + updateConfig({ name: e.target.value })} + placeholder="My Custom Report" + /> +
+
+ +